#443 google camera portrait mode item extraction
This commit is contained in:
parent
ebc147771c
commit
0e3cf257bd
15 changed files with 263 additions and 44 deletions
|
@ -11,25 +11,21 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.*
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.XMP.doesPropExist
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
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.Helper
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ContentImageProvider
|
import deckers.thibault.aves.model.provider.ContentImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
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.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
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.canReadWithExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
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) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
||||||
|
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
||||||
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
||||||
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
|
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
|
||||||
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
|
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||||
|
@ -84,6 +81,68 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(thumbnails)
|
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) {
|
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
|
|
@ -134,7 +134,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (prop is XMPPropertyInfo) {
|
if (prop is XMPPropertyInfo) {
|
||||||
val path = prop.path
|
val path = prop.path
|
||||||
if (path?.isNotEmpty() == true) {
|
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) {
|
if (value?.isNotEmpty() == true) {
|
||||||
dirMap[path] = value
|
dirMap[path] = value
|
||||||
}
|
}
|
||||||
|
@ -1281,5 +1281,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
// additional media key
|
// additional media key
|
||||||
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
|
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
|
||||||
|
|
||||||
|
private const val VALUE_SKIPPED_DATA = "[skipped]"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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) {}
|
|
@ -175,14 +175,14 @@ object MultiPage {
|
||||||
if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||||
// `GCamera` motion photo
|
// `GCamera` motion photo
|
||||||
xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
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
|
// `Container` motion photo
|
||||||
val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_DIRECTORY_PROP_NAME)
|
val count = xmpMeta.countPropArrayItems(XMP.GCONTAINER_DIRECTORY_PROP_NAME)
|
||||||
if (count == 2) {
|
if (count == 2) {
|
||||||
// expect the video to be the second item
|
// expect the video to be the second item
|
||||||
val i = 2
|
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 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.CONTAINER_DIRECTORY_PROP_NAME, i, XMP.CONTAINER_ITEM_PROP_NAME, XMP.CONTAINER_ITEM_LENGTH_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) {
|
if (MimeTypes.isVideo(mime) && length != null) {
|
||||||
offsetFromEnd = length.toLong()
|
offsetFromEnd = length.toLong()
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,17 +42,17 @@ class GSpherical(xmlBytes: ByteArray) {
|
||||||
"StitchingSoftware" -> stitchingSoftware = readTag(parser, tag)
|
"StitchingSoftware" -> stitchingSoftware = readTag(parser, tag)
|
||||||
"ProjectionType" -> projectionType = readTag(parser, tag)
|
"ProjectionType" -> projectionType = readTag(parser, tag)
|
||||||
"StereoMode" -> stereoMode = readTag(parser, tag)
|
"StereoMode" -> stereoMode = readTag(parser, tag)
|
||||||
"SourceCount" -> sourceCount = Integer.parseInt(readTag(parser, tag))
|
"SourceCount" -> sourceCount = readTag(parser, tag).toInt()
|
||||||
"InitialViewHeadingDegrees" -> initialViewHeadingDegrees = Integer.parseInt(readTag(parser, tag))
|
"InitialViewHeadingDegrees" -> initialViewHeadingDegrees = readTag(parser, tag).toInt()
|
||||||
"InitialViewPitchDegrees" -> initialViewPitchDegrees = Integer.parseInt(readTag(parser, tag))
|
"InitialViewPitchDegrees" -> initialViewPitchDegrees = readTag(parser, tag).toInt()
|
||||||
"InitialViewRollDegrees" -> initialViewRollDegrees = Integer.parseInt(readTag(parser, tag))
|
"InitialViewRollDegrees" -> initialViewRollDegrees = readTag(parser, tag).toInt()
|
||||||
"Timestamp" -> timestamp = Integer.parseInt(readTag(parser, tag))
|
"Timestamp" -> timestamp = readTag(parser, tag).toInt()
|
||||||
"FullPanoWidthPixels" -> fullPanoWidthPixels = Integer.parseInt(readTag(parser, tag))
|
"FullPanoWidthPixels" -> fullPanoWidthPixels = readTag(parser, tag).toInt()
|
||||||
"FullPanoHeightPixels" -> fullPanoHeightPixels = Integer.parseInt(readTag(parser, tag))
|
"FullPanoHeightPixels" -> fullPanoHeightPixels = readTag(parser, tag).toInt()
|
||||||
"CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = Integer.parseInt(readTag(parser, tag))
|
"CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = readTag(parser, tag).toInt()
|
||||||
"CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = Integer.parseInt(readTag(parser, tag))
|
"CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = readTag(parser, tag).toInt()
|
||||||
"CroppedAreaLeftPixels" -> croppedAreaLeftPixels = Integer.parseInt(readTag(parser, tag))
|
"CroppedAreaLeftPixels" -> croppedAreaLeftPixels = readTag(parser, tag).toInt()
|
||||||
"CroppedAreaTopPixels" -> croppedAreaTopPixels = Integer.parseInt(readTag(parser, tag))
|
"CroppedAreaTopPixels" -> croppedAreaTopPixels = readTag(parser, tag).toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,11 +43,13 @@ object XMP {
|
||||||
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
|
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
|
||||||
|
|
||||||
// other namespaces
|
// 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 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 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 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 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 GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/"
|
||||||
private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01"
|
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 }
|
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
|
// motion photo
|
||||||
|
|
||||||
val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
|
val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
|
||||||
val CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Directory")
|
val GCONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Directory")
|
||||||
val CONTAINER_ITEM_PROP_NAME = XMPPropName(CONTAINER_NS_URI, "Item")
|
val GCONTAINER_ITEM_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Item")
|
||||||
val CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Length")
|
val GCONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Length")
|
||||||
val CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(CONTAINER_ITEM_NS_URI, "Mime")
|
val GCONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Mime")
|
||||||
|
|
||||||
// panorama
|
// panorama
|
||||||
// cf https://developers.google.com/streetview/spherical-metadata
|
// cf https://developers.google.com/streetview/spherical-metadata
|
||||||
|
@ -189,14 +198,14 @@ object XMP {
|
||||||
if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
||||||
|
|
||||||
// Container motion photo
|
// Container motion photo
|
||||||
if (doesPropExist(CONTAINER_DIRECTORY_PROP_NAME)) {
|
if (doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||||
val count = countPropArrayItems(CONTAINER_DIRECTORY_PROP_NAME)
|
val count = countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||||
if (count == 2) {
|
if (count == 2) {
|
||||||
var hasImage = false
|
var hasImage = false
|
||||||
var hasVideo = false
|
var hasVideo = false
|
||||||
for (i in 1 until count + 1) {
|
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 mime = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_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 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
|
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||||
}
|
}
|
||||||
|
|
|
@ -761,8 +761,8 @@ abstract class ImageProvider {
|
||||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"",
|
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"",
|
||||||
).replace(
|
).replace(
|
||||||
// Container motion photo
|
// Container motion photo
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
"${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
"${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boyer-Moore algorithm for pattern searching
|
// 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 n: Int = this.size
|
||||||
val m: Int = pattern.size
|
val m: Int = pattern.size
|
||||||
val badChar = Array(256) { 0 }
|
val badChar = Array(256) { 0 }
|
||||||
|
@ -30,7 +30,7 @@ fun ByteArray.indexOfBytes(pattern: ByteArray): Int {
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
var j: Int = m - 1
|
var j: Int = m - 1
|
||||||
var s = 0
|
var s = start
|
||||||
while (s <= (n - m)) {
|
while (s <= (n - m)) {
|
||||||
while (j >= 0 && pattern[j] == this[s + j]) {
|
while (j >= 0 && pattern[j] == this[s + j]) {
|
||||||
j -= 1
|
j -= 1
|
||||||
|
|
|
@ -6,6 +6,8 @@ import 'package:flutter/services.dart';
|
||||||
abstract class EmbeddedDataService {
|
abstract class EmbeddedDataService {
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractGoogleDeviceItem(AvesEntry entry, String dataUri);
|
||||||
|
|
||||||
Future<Map> extractMotionPhotoImage(AvesEntry entry);
|
Future<Map> extractMotionPhotoImage(AvesEntry entry);
|
||||||
|
|
||||||
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
|
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
|
||||||
|
@ -33,6 +35,23 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
||||||
return [];
|
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
|
@override
|
||||||
Future<Map> extractMotionPhotoImage(AvesEntry entry) async {
|
Future<Map> extractMotionPhotoImage(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -41,6 +41,7 @@ class Namespaces {
|
||||||
static const iptc4xmpExt = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/';
|
static const iptc4xmpExt = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/';
|
||||||
static const lr = 'http://ns.adobe.com/lightroom/1.0/';
|
static const lr = 'http://ns.adobe.com/lightroom/1.0/';
|
||||||
static const mediapro = 'http://ns.iview-multimedia.com/mediapro/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'
|
// 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/';
|
static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/';
|
||||||
|
@ -111,6 +112,7 @@ class Namespaces {
|
||||||
iptc4xmpExt: 'IPTC Extension',
|
iptc4xmpExt: 'IPTC Extension',
|
||||||
lr: 'Lightroom',
|
lr: 'Lightroom',
|
||||||
mediapro: 'MediaPro',
|
mediapro: 'MediaPro',
|
||||||
|
miCamera: 'Mi Camera',
|
||||||
microsoftPhoto: 'Microsoft Photo 1.0',
|
microsoftPhoto: 'Microsoft Photo 1.0',
|
||||||
mp1: 'Microsoft Photo 1.1',
|
mp1: 'Microsoft Photo 1.1',
|
||||||
mp: 'Microsoft Photo 1.2',
|
mp: 'Microsoft Photo 1.2',
|
||||||
|
|
|
@ -35,6 +35,9 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin {
|
||||||
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
||||||
late Map fields;
|
late Map fields;
|
||||||
switch (notification.source) {
|
switch (notification.source) {
|
||||||
|
case EmbeddedDataSource.googleDevice:
|
||||||
|
fields = await embeddedDataService.extractGoogleDeviceItem(entry, notification.dataUri!);
|
||||||
|
break;
|
||||||
case EmbeddedDataSource.motionPhotoVideo:
|
case EmbeddedDataSource.motionPhotoVideo:
|
||||||
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
|
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,20 +1,29 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
|
enum EmbeddedDataSource { googleDevice, motionPhotoVideo, videoCover, xmp }
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class OpenEmbeddedDataNotification extends Notification {
|
class OpenEmbeddedDataNotification extends Notification {
|
||||||
final EmbeddedDataSource source;
|
final EmbeddedDataSource source;
|
||||||
final List<dynamic>? props;
|
final List<dynamic>? props;
|
||||||
final String? mimeType;
|
final String? mimeType, dataUri;
|
||||||
|
|
||||||
const OpenEmbeddedDataNotification._private({
|
const OpenEmbeddedDataNotification._private({
|
||||||
required this.source,
|
required this.source,
|
||||||
this.props,
|
this.props,
|
||||||
this.mimeType,
|
this.mimeType,
|
||||||
|
this.dataUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory OpenEmbeddedDataNotification.googleDevice({
|
||||||
|
required String dataUri,
|
||||||
|
}) =>
|
||||||
|
OpenEmbeddedDataNotification._private(
|
||||||
|
source: EmbeddedDataSource.googleDevice,
|
||||||
|
dataUri: dataUri,
|
||||||
|
);
|
||||||
|
|
||||||
factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private(
|
factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private(
|
||||||
source: EmbeddedDataSource.motionPhotoVideo,
|
source: EmbeddedDataSource.motionPhotoVideo,
|
||||||
);
|
);
|
||||||
|
@ -34,5 +43,5 @@ class OpenEmbeddedDataNotification extends Notification {
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@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}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,7 +91,7 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (keyValues.isEmpty) return const SizedBox.shrink();
|
if (keyValues.isEmpty) return const SizedBox();
|
||||||
|
|
||||||
final _keyStyle = InfoRowGroup.keyStyle(context);
|
final _keyStyle = InfoRowGroup.keyStyle(context);
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ class _XmpCardState extends State<XmpCard> {
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant XmpCard oldWidget) {
|
void didUpdateWidget(covariant XmpCard oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (_indexNotifier.value >= indexedStructCount) {
|
if (isIndexed && _indexNotifier.value >= indexedStructCount) {
|
||||||
_indexNotifier.value = indexedStructCount - 1;
|
_indexNotifier.value = indexedStructCount - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,24 @@ class XmpGDepthNamespace extends XmpGoogleNamespace {
|
||||||
}
|
}
|
||||||
|
|
||||||
class XmpGDeviceNamespace extends XmpNamespace {
|
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
|
@override
|
||||||
late final List<XmpCardData> cards = [
|
late final List<XmpCardData> cards = [
|
||||||
|
@ -82,7 +99,23 @@ class XmpGDeviceNamespace extends XmpNamespace {
|
||||||
XmpCardData(RegExp(r'Camera:ImagingModel/(.*)')),
|
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+)\]/(.*)')),
|
XmpCardData(RegExp(nsPrefix + r'Profiles\[(\d+)\]/(.*)')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue