#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 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) }
|
||||
|
|
|
@ -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]"
|
||||
}
|
||||
}
|
|
@ -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)) {
|
||||
// `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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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\"",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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+)\]/(.*)')),
|
||||
];
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue