#305 heic motion photo support

This commit is contained in:
Thibault Deckers 2022-08-23 11:49:22 +02:00
parent 9b252a9588
commit 0cce0c1e11
12 changed files with 179 additions and 98 deletions

View file

@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- option to hide confirmation message after moving items to the bin - option to hide confirmation message after moving items to the bin
- Collection / Info: edit description via Exif / IPTC / XMP - Collection / Info: edit description via Exif / IPTC / XMP
- Info: read XMP from HEIC on Android >=11 - Info: read XMP from HEIC on Android >=11
- Collection: support HEIC motion photos on Android >=11
- Dutch translation (thanks Martijn Fabrie, Koen Koppens) - Dutch translation (thanks Martijn Fabrie, Koen Koppens)
### Changed ### Changed

View file

@ -1,13 +1,10 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
@ -69,16 +66,15 @@ import deckers.thibault.aves.metadata.XMP.getSafeString
import deckers.thibault.aves.metadata.XMP.isMotionPhoto import deckers.thibault.aves.metadata.XMP.isMotionPhoto
import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
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.isHeic import deckers.thibault.aves.utils.MimeTypes.isHeic
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 deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
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
@ -370,7 +366,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
checkHeicXmp(uri, mimeType, foundXmp) { xmpMeta -> XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
val thisDirName = XmpDirectory().name val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap() val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap metadataMap[thisDirName] = dirMap
@ -499,7 +495,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// identification of motion photo // identification of motion photo
if (xmpMeta.isMotionPhoto()) { if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE flags = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
} }
} catch (e: XMPException) { } catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
@ -659,7 +655,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
@ -828,16 +824,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
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) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null || sizeBytes == null) { val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
result.error("getMultiPageInfo-args", "missing arguments", null) result.error("getMultiPageInfo-args", "missing arguments", null)
return return
} }
val pages: ArrayList<FieldMap>? = when (mimeType) { val pages: ArrayList<FieldMap>? = if (isMotionPhoto) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
MimeTypes.JPEG -> MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes) } else {
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) when (mimeType) {
else -> null MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null
}
} }
if (pages?.isEmpty() == true) { if (pages?.isEmpty() == true) {
result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null) result.error("getMultiPageInfo-empty", "failed to get pages for mimeType=$mimeType uri=$uri", null)
@ -888,7 +888,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (fields.isEmpty()) { if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null) result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
@ -961,7 +961,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp) XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
if (xmpStrings.isEmpty()) { if (xmpStrings.isEmpty()) {
result.success(null) result.success(null)
@ -998,65 +998,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
try { try {
val value = queryContentResolverProp(uri, mimeType, prop) val value = context.queryContentResolverProp(uri, mimeType, prop)
result.success(value?.toString()) result.success(value?.toString())
} catch (e: Exception) { } catch (e: Exception) {
result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message) result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
} }
} }
private fun queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? {
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(context, contentUri)
}
}
// throws SQLiteException when the requested prop is not a known column
val cursor = context.contentResolver.query(contentUri, arrayOf(prop), null, null, null)
if (cursor == null || !cursor.moveToFirst()) {
throw Exception("failed to get cursor for contentUri=$contentUri")
}
var value: Any? = null
try {
value = when (cursor.getType(0)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
}
cursor.close()
return value
}
// as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
// so we fall back to the native content resolver, if possible
private fun checkHeicXmp(uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) {
if (isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val xmpBytes = queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP)
if (xmpBytes is ByteArray) {
val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, MetadataExtractorSafeXmpReader.PARSE_OPTIONS)
processXmp(xmpMeta)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e)
}
}
}
private fun getDate(call: MethodCall, result: MethodChannel.Result) { private fun getDate(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) }
@ -1231,6 +1179,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val MASK_IS_GEOTIFF = 1 shl 2 private const val MASK_IS_GEOTIFF = 1 shl 2
private const val MASK_IS_360 = 1 shl 3 private const val MASK_IS_360 = 1 shl 3
private const val MASK_IS_MULTIPAGE = 1 shl 4 private const val MASK_IS_MULTIPAGE = 1 shl 4
private const val MASK_IS_MOTION_PHOTO = 1 shl 5
private const val XMP_SUBJECTS_SEPARATOR = ";" private const val XMP_SUBJECTS_SEPARATOR = ";"
// overlay metadata // overlay metadata

View file

@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.adobe.internal.xmp.XMPMeta
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.countPropArrayItems import deckers.thibault.aves.metadata.XMP.countPropArrayItems
import deckers.thibault.aves.metadata.XMP.doesPropExist import deckers.thibault.aves.metadata.XMP.doesPropExist
@ -16,11 +17,15 @@ import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.indexOfBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.DataInputStream
object MultiPage { object MultiPage {
private val LOG_TAG = LogUtils.createTag<MultiPage>() private val LOG_TAG = LogUtils.createTag<MultiPage>()
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
// page info // page info
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_HEIGHT = "height" private const val KEY_HEIGHT = "height"
@ -142,30 +147,53 @@ object MultiPage {
} }
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
if (MimeTypes.isHeic(mimeType)) {
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
// so we ignore the `Item:Length` and look instead for the MP4 marker bytes indicating the start of the video.
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val bytes = ByteArray(sizeBytes.toInt())
DataInputStream(input).use {
it.readFully(bytes)
}
val index = bytes.indexOfBytes(heicMotionPhotoVideoStartIndicator)
if (index != -1) {
return sizeBytes - index
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
}
var offsetFromEnd: Long? = null
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
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)) {
// `Container` motion photo
val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_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
if (MimeTypes.isVideo(mime) && length != null) {
offsetFromEnd = length.toLong()
}
}
}
}
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input) val metadata = MetadataExtractorHelper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
var offsetFromEnd: Long? = null metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
val xmpMeta = dir.xmpMeta
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)) {
// Container motion photo
val count = xmpMeta.countPropArrayItems(XMP.CONTAINER_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
if (MimeTypes.isVideo(mime) && length != null) {
offsetFromEnd = length.toLong()
}
}
}
return offsetFromEnd
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
@ -174,7 +202,10 @@ object MultiPage {
} catch (e: AssertionError) { } catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
} }
return null
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
return offsetFromEnd
} }
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> { fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
@ -218,4 +249,4 @@ object MultiPage {
} }
return null return null
} }
} }

View file

@ -1,13 +1,19 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import com.adobe.internal.xmp.XMPError import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.properties.XMPProperty import com.adobe.internal.xmp.properties.XMPProperty
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.util.* import java.util.*
object XMP { object XMP {
@ -85,6 +91,22 @@ object XMP {
GPANO_FULL_PANO_WIDTH_PROP_NAME, GPANO_FULL_PANO_WIDTH_PROP_NAME,
) )
// as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
// so we fall back to the native content resolver, if possible
fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) {
if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP)
if (xmpBytes is ByteArray) {
val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, MetadataExtractorSafeXmpReader.PARSE_OPTIONS)
processXmp(xmpMeta)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get XMP by content resolver for mimeType=$mimeType uri=$uri", e)
}
}
}
// extensions // extensions
fun XMPMeta.isMotionPhoto(): Boolean { fun XMPMeta.isMotionPhoto(): Boolean {

View file

@ -17,4 +17,27 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
} }
return removed return removed
} }
} }
// Boyer-Moore algorithm for pattern searching
fun ByteArray.indexOfBytes(pattern: ByteArray): Int {
val n: Int = this.size
val m: Int = pattern.size
val badChar = Array(256) { 0 }
var i = 0
while (i < m) {
badChar[pattern[i].toUByte().toInt()] = i
i += 1
}
var j: Int = m - 1
var s = 0
while (s <= (n - m)) {
while (j >= 0 && pattern[j] == this[s + j]) {
j -= 1
}
if (j < 0) return s
s += Integer.max(1, j - badChar[this[s + j].toUByte().toInt()])
j = m - 1
}
return -1
}

View file

@ -3,10 +3,17 @@ package deckers.thibault.aves.utils
import android.app.ActivityManager import android.app.ActivityManager
import android.app.Service import android.app.Service
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import deckers.thibault.aves.utils.UriUtils.tryParseId
object ContextUtils { object ContextUtils {
private val LOG_TAG = LogUtils.createTag<ContextUtils>()
fun Context.resourceUri(resourceId: Int): Uri = with(resources) { fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
Uri.Builder() Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
@ -22,4 +29,40 @@ object ContextUtils {
@Suppress("deprecation") @Suppress("deprecation")
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
} }
fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? {
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
contentUri = when {
MimeTypes.isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
MimeTypes.isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(this, contentUri)
}
}
// throws SQLiteException when the requested prop is not a known column
val cursor = contentResolver.query(contentUri, arrayOf(prop), null, null, null)
if (cursor == null || !cursor.moveToFirst()) {
throw Exception("failed to get cursor for contentUri=$contentUri")
}
var value: Any? = null
try {
value = when (cursor.getType(0)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
}
cursor.close()
return value
}
} }

View file

@ -754,7 +754,10 @@ class AvesEntry {
bool get isBurst => burstEntries?.isNotEmpty == true; bool get isBurst => burstEntries?.isNotEmpty == true;
bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg; // for backwards compatibility
bool get _isMotionPhotoLegacy => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
bool get isMotionPhoto => (_catalogMetadata?.isMotionPhoto ?? false) || _isMotionPhotoLegacy;
String? get burstKey { String? get burstKey {
if (filenameWithoutExtension != null) { if (filenameWithoutExtension != null) {

View file

@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
class CatalogMetadata { class CatalogMetadata {
final int id; final int id;
final int? dateMillis; final int? dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage; final bool isAnimated, isGeotiff, is360, isMultiPage, isMotionPhoto;
bool isFlipped; bool isFlipped;
int? rotationDegrees; int? rotationDegrees;
final String? mimeType, xmpSubjects, xmpTitleDescription; final String? mimeType, xmpSubjects, xmpTitleDescription;
@ -18,6 +18,7 @@ class CatalogMetadata {
static const _isGeotiffMask = 1 << 2; static const _isGeotiffMask = 1 << 2;
static const _is360Mask = 1 << 3; static const _is360Mask = 1 << 3;
static const _isMultiPageMask = 1 << 4; static const _isMultiPageMask = 1 << 4;
static const _isMotionPhotoMask = 1 << 5;
CatalogMetadata({ CatalogMetadata({
required this.id, required this.id,
@ -28,6 +29,7 @@ class CatalogMetadata {
this.isGeotiff = false, this.isGeotiff = false,
this.is360 = false, this.is360 = false,
this.isMultiPage = false, this.isMultiPage = false,
this.isMotionPhoto = false,
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
@ -67,6 +69,7 @@ class CatalogMetadata {
isGeotiff: isGeotiff, isGeotiff: isGeotiff,
is360: is360, is360: is360,
isMultiPage: isMultiPage ?? this.isMultiPage, isMultiPage: isMultiPage ?? this.isMultiPage,
isMotionPhoto: isMotionPhoto,
rotationDegrees: rotationDegrees ?? this.rotationDegrees, rotationDegrees: rotationDegrees ?? this.rotationDegrees,
xmpSubjects: xmpSubjects, xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription, xmpTitleDescription: xmpTitleDescription,
@ -87,6 +90,7 @@ class CatalogMetadata {
isGeotiff: flags & _isGeotiffMask != 0, isGeotiff: flags & _isGeotiffMask != 0,
is360: flags & _is360Mask != 0, is360: flags & _is360Mask != 0,
isMultiPage: flags & _isMultiPageMask != 0, isMultiPage: flags & _isMultiPageMask != 0,
isMotionPhoto: flags & _isMotionPhotoMask != 0,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0 // `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'], rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '', xmpSubjects: map['xmpSubjects'] ?? '',
@ -101,7 +105,7 @@ class CatalogMetadata {
'id': id, 'id': id,
'mimeType': mimeType, 'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0), 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0) | (isMotionPhoto ? _isMotionPhotoMask : 0),
'rotationDegrees': rotationDegrees, 'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects, 'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription, 'xmpTitleDescription': xmpTitleDescription,
@ -111,5 +115,5 @@ class CatalogMetadata {
}; };
@override @override
String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}'; String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, isMotionPhoto=$isMotionPhoto, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
} }

View file

@ -146,6 +146,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
'isMotionPhoto': entry.isMotionPhoto,
}); });
final pageMaps = ((result as List?) ?? []).cast<Map>(); final pageMaps = ((result as List?) ?? []).cast<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) { if (entry.isMotionPhoto && pageMaps.isNotEmpty) {

View file

@ -73,7 +73,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return true; return true;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryInfoAction.convertMotionPhotoToStillImage:
return entry.canEdit; return entry.canEditXmp;
case EntryInfoAction.viewMotionPhotoVideo: case EntryInfoAction.viewMotionPhotoVideo:
return true; return true;
// debug // debug

View file

@ -135,6 +135,8 @@ class ViewerDebugPage extends StatelessWidget {
'isAnimated': '${entry.isAnimated}', 'isAnimated': '${entry.isAnimated}',
'isGeotiff': '${entry.isGeotiff}', 'isGeotiff': '${entry.isGeotiff}',
'is360': '${entry.is360}', 'is360': '${entry.is360}',
'isMultiPage': '${entry.isMultiPage}',
'isMotionPhoto': '${entry.isMotionPhoto}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditDate': '${entry.canEditDate}', 'canEditDate': '${entry.canEditDate}',
'canEditTags': '${entry.canEditTags}', 'canEditTags': '${entry.canEditTags}',

View file

@ -533,6 +533,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
} }
void _jumpToHorizontalPageByDelta(int delta) { void _jumpToHorizontalPageByDelta(int delta) {
if (_horizontalPager.positions.isEmpty) return;
final page = _horizontalPager.page?.round(); final page = _horizontalPager.page?.round();
if (page != null) { if (page != null) {
_jumpToHorizontalPageByIndex(page + delta); _jumpToHorizontalPageByIndex(page + delta);