#305 read XMP from HEIC on Android >=11

This commit is contained in:
Thibault Deckers 2022-08-21 20:13:37 +02:00
parent 28b05acb8a
commit 9b252a9588
5 changed files with 171 additions and 101 deletions

View file

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

View file

@ -11,6 +11,7 @@ import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
@ -127,18 +128,43 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
try {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
// remove this stat as it is not actual XMP data
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
// add schema prefixes for namespace resolution
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
val uuidDirCount = HashMap<String, Int>()
val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
&& it !is AviDirectory
}.groupBy { dir -> dir.name }
for (dirEntry in dirByName) {
val baseDirName = dirEntry.key
@ -266,26 +292,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
if (dir is XmpDirectory) {
try {
for (prop in dir.xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
// remove this stat as it is not actual XMP data
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
// add schema prefixes for namespace resolution
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
processXmp(dir.xmpMeta, dirMap)
}
if (dir is Mp4UuidBoxDirectory) {
@ -363,6 +370,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
checkHeicXmp(uri, mimeType, foundXmp) { xmpMeta ->
val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, dirMap)
}
if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side
// and to identify whether there is an accessible cover image
@ -453,12 +467,51 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
) {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
var foundExif = false
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
xmpMeta.getSafeInt(XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
metadataMap[KEY_RATING] = standardRating
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
// identification of motion photo
if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -497,7 +550,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
val orientation = it
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
if (isFlippedForExifCode(orientation)) {
flags = flags or MASK_IS_FLIPPED
}
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
@ -512,43 +567,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
// XMP
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.PS_DATE_CREATED_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
}
}
xmpMeta.getSafeInt(XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
metadataMap[KEY_RATING] = standardRating
}
}
// identification of panorama (aka photo sphere)
if (xmpMeta.isPanorama()) {
flags = flags or MASK_IS_360
}
// identification of motion photo
if (xmpMeta.isMotionPhoto()) {
flags = flags or MASK_IS_MULTIPAGE
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
// XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
@ -576,7 +595,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
MimeTypes.GIF -> {
// identification of animated GIF
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) {
flags = flags or MASK_IS_ANIMATED
}
}
MimeTypes.WEBP -> {
// identification of animated WEBP
@ -589,7 +610,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
MimeTypes.TIFF -> {
// identification of GeoTIFF
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.containsGeoTiffTags()) flags = flags or MASK_IS_GEOTIFF
if (dir.containsGeoTiffTags()) {
flags = flags or MASK_IS_GEOTIFF
}
}
}
}
@ -636,6 +659,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp)
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
metadataMap[KEY_FLAGS] = flags
@ -830,25 +855,29 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val fields: FieldMap = hashMapOf()
fun processXmp(xmpMeta: XMPMeta) {
try {
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val fields: FieldMap = hashMapOf(
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
}
result.success(fields)
return
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@ -858,7 +887,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp)
if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
} else {
fields["projectionType"] = fields["projectionType"] ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT
result.success(fields)
}
}
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
@ -894,13 +931,23 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val xmpStrings = mutableListOf<String>()
fun processXmp(xmpMeta: XMPMeta) {
try {
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
result.success(xmpStrings.toMutableList())
return
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
@ -914,7 +961,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
result.success(null)
checkHeicXmp(uri, mimeType, foundXmp, ::processXmp)
if (xmpStrings.isEmpty()) {
result.success(null)
} else {
result.success(xmpStrings)
}
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
@ -944,6 +997,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
try {
val value = queryContentResolverProp(uri, mimeType, prop)
result.success(value?.toString())
} catch (e: Exception) {
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 ->
@ -956,19 +1018,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
val projection = arrayOf(prop)
val cursor: Cursor?
try {
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
} catch (e: Exception) {
// throws SQLiteException when the requested prop is not a known column
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
return
}
// 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()) {
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
return
throw Exception("failed to get cursor for contentUri=$contentUri")
}
var value: Any? = null
@ -982,10 +1035,26 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
}
cursor.close()
result.success(value?.toString())
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) {

View file

@ -138,7 +138,7 @@ class MetadataExtractorSafeXmpReader : XmpReader() {
private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
private val PARSE_OPTIONS = ParseOptions().setXMPNodesToLimit(
val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(
mapOf(
"photoshop:DocumentAncestors" to 200,
"xmpMM:History" to 200,

View file

@ -70,7 +70,7 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
}
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
@ -684,7 +684,7 @@ abstract class ImageProvider {
op: ExifOrientationOp,
callback: ImageOpCallback,
) {
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
val success = editExif(context, path, uri, mimeType, callback) { exif ->
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
@ -909,7 +909,7 @@ abstract class ImageProvider {
}
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@ -961,7 +961,7 @@ abstract class ImageProvider {
return
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
@ -1008,7 +1008,7 @@ abstract class ImageProvider {
return
}
val newFields = HashMap<String, Any?>()
val newFields: FieldMap = hashMapOf()
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}

View file

@ -726,7 +726,7 @@ class MediaStoreImageProvider : ImageProvider() {
return scanNewPath(activity, newFile.path, mimeType)
}
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,