#305 heic motion photo support
This commit is contained in:
parent
9b252a9588
commit
0cce0c1e11
12 changed files with 179 additions and 98 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}',
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue