diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1d9a060a..75253c28a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
+- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags)
- Widget: option to open collection on tap
## [v1.7.1] - 2022-10-09
diff --git a/android/app/build.gradle b/android/app/build.gradle
index a26f23c51..3bfc8dff9 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -148,23 +148,43 @@ flutter {
}
repositories {
- maven { url 'https://jitpack.io' }
- maven { url 'https://s3.amazonaws.com/repo.commonsware.com' }
+ maven {
+ url 'https://jitpack.io'
+ content {
+ includeGroup "com.github.deckerst"
+ includeGroup "com.github.deckerst.mp4parser"
+ }
+ }
+ maven {
+ url 'https://s3.amazonaws.com/repo.commonsware.com'
+ content {
+ excludeGroupByRegex "com\\.github\\.deckerst.*"
+ }
+ }
}
dependencies {
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
+
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.multidex:multidex:2.0.1'
+
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
- // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
- implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
- // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
- implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
implementation 'com.github.bumptech.glide:glide:4.14.2'
+ // SLF4J implementation for `mp4parser`
+ implementation 'org.slf4j:slf4j-simple:2.0.3'
+
+ // forked, built by JitPack:
+ // - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
+ // - https://jitpack.io/p/deckerst/mp4parser
+ // - https://jitpack.io/p/deckerst/pixymeta-android
+ implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
+ implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb'
+ implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb'
+ implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// huawei flavor only
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300'
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
index 5a9ea5ace..2ce5db3d5 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
@@ -50,7 +50,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
- "getAppInstaller" -> ioScope.launch { safe(call, result, ::getAppInstaller) }
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
"edit" -> safe(call, result, ::edit)
"open" -> safe(call, result, ::open)
@@ -187,23 +186,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
- private fun getAppInstaller(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
- val packageName = context.packageName
- val pm = context.packageManager
- try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- val info = pm.getInstallSourceInfo(packageName)
- result.success(info.initiatingPackageName ?: info.installingPackageName)
- } else {
- @Suppress("deprecation")
- result.success(pm.getInstallerPackageName(packageName))
- }
- } catch (e: Exception) {
- result.error("getAppInstaller-exception", "failed to get installer for packageName=$packageName", e.message)
- return
- }
- }
-
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument("uri")?.let { Uri.parse(it) }
val label = call.argument("label")
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
index e056cb3af..9135f9430 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt
@@ -18,10 +18,12 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
+import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap
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.canReadWithPixyMeta
@@ -38,7 +40,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
+import org.mp4parser.IsoFile
import java.io.IOException
+import java.nio.channels.Channels
class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -60,6 +64,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
+ "getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) }
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented()
@@ -319,6 +324,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
+ private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
+ val mimeType = call.argument("mimeType")
+ val uri = call.argument("uri")?.let { Uri.parse(it) }
+ if (mimeType == null || uri == null) {
+ result.error("getMp4ParserDump-args", "missing arguments", null)
+ return
+ }
+
+ val sb = StringBuilder()
+ if (mimeType == MimeTypes.MP4) {
+ try {
+ StorageUtils.openInputStream(context, uri)?.use { input ->
+ Channels.newChannel(input).use { channel ->
+ IsoFile(channel).use { isoFile ->
+ isoFile.dumpBoxes(sb)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ result.error("getMp4ParserDump-exception", e.message, e.stackTraceToString())
+ return
+ }
+ }
+ result.success(sb.toString())
+ }
+
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument("mimeType")
val uri = call.argument("uri")?.let { Uri.parse(it) }
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
index bda1f0c97..df2c1c86e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
@@ -68,7 +68,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
- override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
+ override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable)
})
}
@@ -98,7 +98,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
- override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
+ override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable)
})
}
@@ -127,7 +127,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
- override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
+ override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable)
})
}
@@ -154,7 +154,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
- override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message)
+ override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable)
})
}
@@ -182,7 +182,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
- override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
+ override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable)
})
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
index 7df4f8f25..0f053d2a4 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
@@ -126,6 +126,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap) {
+ if (foundXmp) return
+ foundXmp = true
try {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
@@ -148,14 +150,66 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
}
+ val mp4UuidDirCount = HashMap()
+ fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
+ var thisDirName: String
+ when (val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
+ GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
+ val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
+ thisDirName = "Spherical Video"
+ metadataMap[thisDirName] = HashMap(GSpherical(bytes).describe())
+ }
+ QuickTimeMetadata.PROF_UUID -> {
+ // redundant with info derived on the Dart side
+ }
+ QuickTimeMetadata.USMT_UUID -> {
+ val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
+ val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
+ if (blocks.isNotEmpty()) {
+ thisDirName = "QuickTime User Media"
+ val usmt = metadataMap[thisDirName] ?: HashMap()
+ metadataMap[thisDirName] = usmt
+
+ blocks.forEach {
+ var key = it.type
+ var value = it.value
+ val language = it.language
+
+ var i = 0
+ while (usmt.containsKey(key)) {
+ key = it.type + " (${++i})"
+ }
+ if (language != "und") {
+ value += " ($language)"
+ }
+ usmt[key] = value
+ }
+ }
+ }
+ else -> {
+ val uuidPart = uuid.substringBefore('-')
+ thisDirName = "${dir.name} $uuidPart"
+
+ val count = mp4UuidDirCount[uuidPart] ?: 0
+ mp4UuidDirCount[uuidPart] = count + 1
+ if (count > 0) {
+ thisDirName += " ($count)"
+ }
+
+ val dirMap = metadataMap[thisDirName] ?: HashMap()
+ metadataMap[thisDirName] = dirMap
+
+ dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
+ }
+ }
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.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()
val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory
@@ -177,157 +231,116 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// directory name
var thisDirName = baseDirName
- if (dir is Mp4UuidBoxDirectory) {
- val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
- thisDirName += " $uuid"
-
- val count = uuidDirCount[uuid] ?: 0
- uuidDirCount[uuid] = count + 1
- if (count > 0) {
- thisDirName += " ($count)"
- }
- } else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
+ if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
// optional count for multiple directories of the same type
thisDirName = "$thisDirName[${dirIndex + 1}]"
}
-
// optional parent to distinguish child directories of the same type
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
var dirMap = metadataMap[thisDirName] ?: HashMap()
- metadataMap[thisDirName] = dirMap
+ if (dir !is Mp4UuidBoxDirectory) {
+ metadataMap[thisDirName] = dirMap
- // tags
- val tags = dir.tags
- when {
- dir is ExifDirectoryBase -> {
- when {
- dir.containsGeoTiffTags() -> {
- // split GeoTIFF tags in their own directory
- val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap()
- metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap
- val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
- byGeoTiff[true]?.flatMap { tag ->
- when (tag.tagType) {
- ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> {
- val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType))
- geoTiffTags.map { geoTag ->
- val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}"
- val value = geoTag.value
- val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value"
- Pair(name, description)
+ // tags
+ val tags = dir.tags
+ when {
+ dir is ExifDirectoryBase -> {
+ when {
+ dir.containsGeoTiffTags() -> {
+ // split GeoTIFF tags in their own directory
+ val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap()
+ metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap
+ val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
+ byGeoTiff[true]?.flatMap { tag ->
+ when (tag.tagType) {
+ ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> {
+ val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType))
+ geoTiffTags.map { geoTag ->
+ val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}"
+ val value = geoTag.value
+ val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value"
+ Pair(name, description)
+ }
}
+ // skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys
+ ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS,
+ ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList()
+ else -> listOf(exifTagMapper(tag))
}
- // skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys
- ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS,
- ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList()
- else -> listOf(exifTagMapper(tag))
- }
- }?.let { geoTiffDirMap.putAll(it) }
- byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
+ }?.let { geoTiffDirMap.putAll(it) }
+ byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
+ }
+ mimeType == MimeTypes.DNG -> {
+ // split DNG tags in their own directory
+ val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
+ metadataMap[DIR_DNG] = dngDirMap
+ val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) }
+ byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) }
+ byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
+ }
+ else -> dirMap.putAll(tags.map { exifTagMapper(it) })
}
- mimeType == MimeTypes.DNG -> {
- // split DNG tags in their own directory
- val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
- metadataMap[DIR_DNG] = dngDirMap
- val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) }
- byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) }
- byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
- }
- else -> dirMap.putAll(tags.map { exifTagMapper(it) })
}
- }
- dir.isPngTextDir() -> {
- metadataMap.remove(thisDirName)
- dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
- metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
+ dir.isPngTextDir() -> {
+ metadataMap.remove(thisDirName)
+ dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
+ metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
- for (tag in tags) {
- val tagType = tag.tagType
- if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
- val pairs = dir.getObject(tagType) as List<*>
- val textPairs = pairs.map { pair ->
- val kv = pair as KeyValuePair
- val key = kv.key
- // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
- val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
- @SuppressLint("ObsoleteSdkInt")
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
- StandardCharsets.UTF_8
- } else {
- Charset.forName("UTF-8")
- }
- } else {
- kv.value.charset
- }
- val valueString = String(kv.value.bytes, charset)
- val dirs = extractPngProfile(key, valueString)
- if (dirs?.any() == true) {
- dirs.forEach { profileDir ->
- val profileDirName = "${dir.name}/${profileDir.name}"
- val profileDirMap = metadataMap[profileDirName] ?: HashMap()
- metadataMap[profileDirName] = profileDirMap
- val profileTags = profileDir.tags
- if (profileDir is ExifDirectoryBase) {
- profileDirMap.putAll(profileTags.map { exifTagMapper(it) })
+ for (tag in tags) {
+ val tagType = tag.tagType
+ if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
+ val pairs = dir.getObject(tagType) as List<*>
+ val textPairs = pairs.map { pair ->
+ val kv = pair as KeyValuePair
+ val key = kv.key
+ // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
+ val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
+ @SuppressLint("ObsoleteSdkInt")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ StandardCharsets.UTF_8
} else {
- profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
+ Charset.forName("UTF-8")
}
+ } else {
+ kv.value.charset
+ }
+ val valueString = String(kv.value.bytes, charset)
+ val dirs = extractPngProfile(key, valueString)
+ if (dirs?.any() == true) {
+ dirs.forEach { profileDir ->
+ val profileDirName = "${dir.name}/${profileDir.name}"
+ val profileDirMap = metadataMap[profileDirName] ?: HashMap()
+ metadataMap[profileDirName] = profileDirMap
+ val profileTags = profileDir.tags
+ if (profileDir is ExifDirectoryBase) {
+ profileDirMap.putAll(profileTags.map { exifTagMapper(it) })
+ } else {
+ profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) })
+ }
+ }
+ null
+ } else {
+ Pair(key, valueString)
}
- null
- } else {
- Pair(key, valueString)
}
+ dirMap.putAll(textPairs.filterNotNull())
+ } else {
+ dirMap[tag.tagName] = tag.description
}
- dirMap.putAll(textPairs.filterNotNull())
- } else {
- dirMap[tag.tagName] = tag.description
}
}
+ else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
}
- else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
}
- if (dir is XmpDirectory) {
- processXmp(dir.xmpMeta, dirMap)
- }
+ if (!isLargeMp4(mimeType, sizeBytes)) {
+ if (dir is Mp4UuidBoxDirectory) {
+ processMp4Uuid(dir)
+ }
- if (dir is Mp4UuidBoxDirectory) {
- when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
- GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
- val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
- metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
- metadataMap.remove(thisDirName)
- }
- QuickTimeMetadata.PROF_UUID -> {
- // redundant with info derived on the Dart side
- metadataMap.remove(thisDirName)
- }
- QuickTimeMetadata.USMT_UUID -> {
- val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
- val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
- if (blocks.isNotEmpty()) {
- metadataMap.remove(thisDirName)
- thisDirName = "QuickTime User Media"
- val usmt = metadataMap[thisDirName] ?: HashMap()
- metadataMap[thisDirName] = usmt
-
- blocks.forEach {
- var key = it.type
- var value = it.value
- val language = it.language
-
- var i = 0
- while (usmt.containsKey(key)) {
- key = it.type + " (${++i})"
- }
- if (language != "und") {
- value += " ($language)"
- }
- usmt[key] = value
- }
- }
- }
+ if (dir is XmpDirectory) {
+ processXmp(dir.xmpMeta, dirMap)
}
}
@@ -367,13 +380,25 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
+ fun fallbackProcessXmp(xmpMeta: XMPMeta) {
val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, dirMap)
}
+ XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
+ if (isLargeMp4(mimeType, sizeBytes)) {
+ XMP.checkMp4(context, mimeType, uri) { dirs ->
+ for (dir in dirs.filterIsInstance()) {
+ fallbackProcessXmp(dir.xmpMeta)
+ }
+ for (dir in dirs.filterIsInstance()) {
+ processMp4Uuid(dir)
+ }
+ }
+ }
+
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
@@ -447,9 +472,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
val metadataMap = HashMap()
- getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
+ getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
if (isVideo(mimeType) || isHeic(mimeType)) {
- getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
+ getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
}
// report success even when empty
@@ -457,8 +482,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
private fun getCatalogMetadataByMetadataExtractor(
- uri: Uri,
mimeType: String,
+ uri: Uri,
path: String?,
sizeBytes: Long?,
metadataMap: HashMap,
@@ -468,6 +493,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
+ if (foundXmp) return
+ foundXmp = true
try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
@@ -504,12 +531,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
+ fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
+ // identification of spherical video (aka 360° video)
+ if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) {
+ flags = flags or MASK_IS_360
+ }
+ }
+
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.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)) {
@@ -565,16 +598,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
// XMP
- metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
+ if (!isLargeMp4(mimeType, sizeBytes)) {
+ metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
- // XMP fallback to IPTC
- if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
- for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
- if (!metadataMap.containsKey(KEY_XMP_TITLE)) {
- dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it }
- }
- if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
- dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
+ // XMP fallback to IPTC
+ if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
+ for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
+ if (!metadataMap.containsKey(KEY_XMP_TITLE)) {
+ dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it }
+ }
+ if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
+ dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
+ }
}
}
}
@@ -620,12 +655,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- // identification of spherical video (aka 360° video)
- if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
- it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
- }) {
- flags = flags or MASK_IS_360
- }
+ metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).forEach(::processMp4Uuid)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
@@ -662,7 +692,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+ XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
+ if (isLargeMp4(mimeType, sizeBytes)) {
+ XMP.checkMp4(context, mimeType, uri) { dirs ->
+ for (dir in dirs.filterIsInstance()) {
+ processXmp(dir.xmpMeta)
+ }
+ for (dir in dirs.filterIsInstance()) {
+ processMp4Uuid(dir)
+ }
+ }
+ }
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
@@ -670,8 +710,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
- uri: Uri,
mimeType: String,
+ uri: Uri,
metadataMap: HashMap,
) {
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
@@ -862,10 +902,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
- var foundXmp = false
val fields: FieldMap = hashMapOf()
+ var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
+ if (foundXmp) return
+ foundXmp = true
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 }
@@ -879,11 +921,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- if (canReadWithMetadataExtractor(mimeType)) {
+ if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
- foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
@@ -895,7 +936,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+ XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
+ if (isLargeMp4(mimeType, sizeBytes)) {
+ XMP.checkMp4(context, mimeType, uri) { dirs ->
+ for (dir in dirs.filterIsInstance()) {
+ processXmp(dir.xmpMeta)
+ }
+ }
+ }
if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
@@ -929,6 +977,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(null)
}
+ // return XMP components
+ // return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument("mimeType")
val uri = call.argument("uri")?.let { Uri.parse(it) }
@@ -938,10 +988,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
- var foundXmp = false
val xmpStrings = mutableListOf()
+ var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) {
+ if (foundXmp) return
+ foundXmp = true
try {
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
} catch (e: XMPException) {
@@ -949,11 +1001,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- if (canReadWithMetadataExtractor(mimeType)) {
+ if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
- foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
}
} catch (e: Exception) {
@@ -968,13 +1019,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
- XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
-
- if (xmpStrings.isEmpty()) {
- result.success(null)
- } else {
- result.success(xmpStrings)
+ XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
+ if (isLargeMp4(mimeType, sizeBytes)) {
+ XMP.checkMp4(context, mimeType, uri) { dirs ->
+ for (dir in dirs.filterIsInstance()) {
+ processXmp(dir.xmpMeta)
+ }
+ }
}
+
+ result.success(xmpStrings)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
@@ -1161,6 +1215,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
omitXmpMetaElement = false // e.g. ...
}
+ private fun isLargeMp4(mimeType: String, sizeBytes: Long?) = mimeType == MimeTypes.MP4 && Metadata.isDangerouslyLarge(sizeBytes)
+
private fun exifTagMapper(it: Tag): Pair {
val name = if (it.hasTagName()) {
it.tagName
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
index 635148196..8a2952972 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
@@ -42,6 +42,7 @@ object Metadata {
const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
+ const val TYPE_MP4 = "mp4"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp"
@@ -121,6 +122,8 @@ object Metadata {
// It is not clear whether it is because of the file itself or its metadata.
private const val fileSizeBytesMax = 100 * (1 shl 20) // MB
+ fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > fileSizeBytesMax
+
// we try and read metadata from large files by copying an arbitrary amount from its beginning
// to a temporary file, and reusing that preview file for all metadata reading purposes
private const val previewSize: Long = 5 * (1 shl 20) // MB
@@ -134,10 +137,7 @@ object Metadata {
MimeTypes.PSD_VND,
MimeTypes.PSD_X,
MimeTypes.TIFF -> {
- if (sizeBytes != null && sizeBytes < fileSizeBytesMax) {
- // small enough to be safe as it is
- uri
- } else {
+ if (isDangerouslyLarge(sizeBytes)) {
// make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk
var previewFile = previewFiles[uri]
@@ -146,6 +146,9 @@ object Metadata {
previewFiles[uri] = previewFile
}
Uri.fromFile(previewFile)
+ } else {
+ // small enough to be safe as it is
+ uri
}
}
// *probably* safe
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt
new file mode 100644
index 000000000..21c7f0967
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt
@@ -0,0 +1,202 @@
+package deckers.thibault.aves.metadata
+
+import android.content.Context
+import android.net.Uri
+import deckers.thibault.aves.utils.LogUtils
+import deckers.thibault.aves.utils.StorageUtils
+import org.mp4parser.*
+import org.mp4parser.boxes.UserBox
+import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
+import org.mp4parser.boxes.iso14496.part12.FreeBox
+import org.mp4parser.boxes.iso14496.part12.MediaDataBox
+import org.mp4parser.boxes.iso14496.part12.MovieBox
+import org.mp4parser.boxes.iso14496.part12.UserDataBox
+import org.mp4parser.support.AbstractBox
+import org.mp4parser.tools.Path
+import java.io.ByteArrayOutputStream
+import java.io.FileInputStream
+import java.nio.channels.Channels
+
+object Mp4ParserHelper {
+ private val LOG_TAG = LogUtils.createTag()
+
+ fun updateLocation(isoFile: IsoFile, locationIso6709: String?) {
+ // Apple GPS Coordinates Box can be in various locations:
+ // - moov[0]/udta[0]/©xyz
+ // - moov[0]/meta[0]/ilst/©xyz
+ // - others?
+ isoFile.removeBoxes(AppleGPSCoordinatesBox::class.java, true)
+
+ locationIso6709 ?: return
+
+ val movieBox = isoFile.movieBox
+ var userDataBox = Path.getPath(movieBox, UserDataBox.TYPE)
+ if (userDataBox == null) {
+ userDataBox = UserDataBox()
+ movieBox.addBox(userDataBox)
+ }
+
+ userDataBox.addBox(AppleGPSCoordinatesBox().apply {
+ value = locationIso6709
+ })
+ }
+
+ fun updateXmp(isoFile: IsoFile, xmp: String?) {
+ val xmpBox = isoFile.xmpBox
+ if (xmp != null) {
+ val xmpData = xmp.toByteArray(Charsets.UTF_8)
+ if (xmpBox == null) {
+ isoFile.addBox(UserBox(XMP.mp4Uuid).apply {
+ data = xmpData
+ })
+ } else {
+ xmpBox.data = xmpData
+ }
+ } else if (xmpBox != null) {
+ isoFile.removeBox(xmpBox)
+ }
+ }
+
+ fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List> {
+ // we can skip uninteresting boxes with a seekable data source
+ val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
+ pfd.use {
+ FileInputStream(it.fileDescriptor).use { stream ->
+ stream.channel.use { channel ->
+ val boxParser = PropertyBoxParserImpl().apply {
+ skippingBoxes(MediaDataBox.TYPE)
+ }
+ // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
+ IsoFile(channel, boxParser).use { isoFile ->
+ val lastContentBox = isoFile.boxes.reversed().firstOrNull { box ->
+ when {
+ box == isoFile.movieBox -> false
+ testXmpBox(box) -> false
+ box is FreeBox -> false
+ else -> true
+ }
+ }
+ lastContentBox ?: throw Exception("failed to find last context box")
+ val oldFileSize = isoFile.size
+ var appendOffset = (isoFile.getBoxOffset { box -> box == lastContentBox })!! + lastContentBox.size
+
+ val edits = arrayListOf>()
+ fun addFreeBoxEdit(offset: Long, size: Long) = edits.add(Pair(offset, FreeBox(size.toInt() - 8).toBytes()))
+
+ // replace existing movie box by a free box
+ isoFile.getBoxOffset { box -> box.type == MovieBox.TYPE }?.let { offset ->
+ addFreeBoxEdit(offset, isoFile.movieBox.size)
+ }
+
+ // replace existing XMP box by a free box
+ isoFile.getBoxOffset { box -> testXmpBox(box) }?.let { offset ->
+ addFreeBoxEdit(offset, isoFile.xmpBox!!.size)
+ }
+
+ modifier(isoFile)
+
+ // write edited movie box
+ val movieBoxBytes = isoFile.movieBox.toBytes()
+ edits.removeAll { (offset, _) -> offset == appendOffset }
+ edits.add(Pair(appendOffset, movieBoxBytes))
+ appendOffset += movieBoxBytes.size
+
+ // write edited XMP box
+ isoFile.xmpBox?.let { box ->
+ edits.removeAll { (offset, _) -> offset == appendOffset }
+ edits.add(Pair(appendOffset, box.toBytes()))
+ appendOffset += box.size
+ }
+
+ // write trailing free box instead of truncating
+ val trailing = oldFileSize - appendOffset
+ if (trailing > 0) {
+ addFreeBoxEdit(appendOffset, trailing)
+ }
+
+ return edits
+ }
+ }
+ }
+ }
+ }
+
+ // according to XMP Specification Part 3 - Storage in Files,
+ // XMP is embedded in MPEG-4 files using a top-level UUID box
+ private fun testXmpBox(box: Box): Boolean {
+ if (box is UserBox) {
+ if (!box.isParsed) {
+ box.parseDetails()
+ }
+ return box.userType.contentEquals(XMP.mp4Uuid)
+ }
+ return false
+ }
+
+ // extensions
+
+ private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? {
+ var offset = 0L
+ for (box in boxes) {
+ if (test(box)) {
+ return offset
+ }
+ offset += box.size
+ }
+ return null
+ }
+
+ private val IsoFile.xmpBox: UserBox?
+ get() = boxes.firstOrNull { testXmpBox(it) } as UserBox?
+
+ fun Container.processBoxes(clazz: Class, recursive: Boolean, apply: (box: T, parent: Container) -> Unit) {
+ // use a copy, in case box processing removes boxes
+ for (box in ArrayList(boxes)) {
+ if (clazz.isInstance(box)) {
+ @Suppress("unchecked_cast")
+ apply(box as T, this)
+ }
+ if (recursive && box is Container) {
+ box.processBoxes(clazz, true, apply)
+ }
+ }
+ }
+
+ private fun Container.removeBoxes(clazz: Class, recursive: Boolean) {
+ processBoxes(clazz, recursive) { box, parent -> parent.removeBox(box) }
+ }
+
+ private fun Container.removeBox(box: Box) {
+ boxes = boxes.apply { remove(box) }
+ }
+
+ fun Container.dumpBoxes(sb: StringBuilder, indent: Int = 0) {
+ for (box in boxes) {
+ val boxType = box.type
+ try {
+ if (box is AbstractBox && !box.isParsed) {
+ box.parseDetails()
+ }
+ when (box) {
+ is BasicContainer -> {
+ sb.appendLine("${"\t".repeat(indent)}[$boxType] ${box.javaClass.simpleName}")
+ box.dumpBoxes(sb, indent + 1)
+ }
+ is UserBox -> {
+ val userTypeHex = box.userType.joinToString("") { "%02x".format(it) }
+ sb.appendLine("${"\t".repeat(indent)}[$boxType] userType=$userTypeHex $box")
+ }
+ else -> sb.appendLine("${"\t".repeat(indent)}[$boxType] $box")
+ }
+ } catch (e: Exception) {
+ sb.appendLine("${"\t".repeat(indent)}failed to access box type=$boxType exception=${e.message}")
+ }
+ }
+ }
+
+ fun Box.toBytes(): ByteArray {
+ val stream = ByteArrayOutputStream(size.toInt())
+ Channels.newChannel(stream).use { getBox(it) }
+ return stream.toByteArray()
+ }
+}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
index 8152ba065..711d62cad 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
@@ -204,7 +204,7 @@ object MultiPage {
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
}
- XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
+ XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
return offsetFromEnd
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
index 24cb4ec40..55596ed03 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
@@ -10,16 +10,28 @@ import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.properties.XMPProperty
+import com.drew.metadata.Directory
+import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes
+import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes
+import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
+import org.mp4parser.IsoFile
+import org.mp4parser.PropertyBoxParserImpl
+import org.mp4parser.boxes.UserBox
+import org.mp4parser.boxes.iso14496.part12.MediaDataBox
+import java.io.FileInputStream
import java.util.*
object XMP {
private val LOG_TAG = LogUtils.createTag()
+ // BE7ACFCB 97A942E8 9C719994 91E3AFAC / BE7ACFCB-97A9-42E8-9C71-999491E3AFAC
+ val mp4Uuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
+
// standard namespaces
// cf com.adobe.internal.xmp.XMPConst
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
@@ -94,7 +106,13 @@ object XMP {
// 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) {
+ fun checkHeic(
+ context: Context,
+ mimeType: String,
+ uri: Uri,
+ 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)
@@ -108,6 +126,43 @@ object XMP {
}
}
+ // as of `metadata-extractor` v2.18.0, processing large MP4 files may crash,
+ // so we fall back to parsing with `mp4parser`
+ fun checkMp4(
+ context: Context,
+ mimeType: String,
+ uri: Uri,
+ processDirs: (dirs: List) -> Unit,
+ ) {
+ if (mimeType != MimeTypes.MP4) return
+ try {
+ // we can skip uninteresting boxes with a seekable data source
+ val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
+ pfd.use {
+ FileInputStream(it.fileDescriptor).use { stream ->
+ stream.channel.use { channel ->
+ val boxParser = PropertyBoxParserImpl().apply {
+ skippingBoxes(MediaDataBox.TYPE)
+ }
+ // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
+ IsoFile(channel, boxParser).use { isoFile ->
+ isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
+ val bytes = box.toBytes()
+ val payload = bytes.copyOfRange(8, bytes.size)
+
+ val metadata = com.drew.metadata.Metadata()
+ SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null)
+ processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList())
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get XMP by MP4 parser for mimeType=$mimeType uri=$uri", e)
+ }
+ }
+
// extensions
fun XMPMeta.isMotionPhoto(): Boolean {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt
index 35f3e0914..d052d9ff1 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt
@@ -4,20 +4,17 @@ import com.drew.imaging.mp4.Mp4Handler
import com.drew.metadata.Metadata
import com.drew.metadata.mp4.Mp4Context
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
+import deckers.thibault.aves.metadata.XMP
class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) {
override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> {
if (payload != null && payload.size >= 16) {
val payloadUuid = payload.copyOfRange(0, 16)
- if (payloadUuid.contentEquals(xmpUuid)) {
+ if (payloadUuid.contentEquals(XMP.mp4Uuid)) {
SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory)
return this
}
}
return super.processBox(type, payload, boxSize, context)
}
-
- companion object {
- val xmpUuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
- }
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 3503afe89..5a62d29e6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -22,6 +22,10 @@ import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
+import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
+import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
+import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
+import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry
@@ -37,10 +41,8 @@ import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo
-import java.io.ByteArrayInputStream
-import java.io.File
-import java.io.IOException
-import java.io.OutputStream
+import java.io.*
+import java.nio.channels.Channels
import java.util.*
abstract class ImageProvider {
@@ -350,6 +352,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
+ editableFile.delete()
}
val fileName = targetDocFile.name
@@ -457,11 +460,12 @@ abstract class ImageProvider {
}
// copy the edited temporary file back to the original
- copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+ editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
+ editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@@ -524,7 +528,7 @@ abstract class ImageProvider {
iptc != null ->
PixyMetaHelper.setIptc(input, output, iptc)
canRemoveMetadata(mimeType) ->
- PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
+ PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_IPTC))
else -> {
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
PixyMetaHelper.setIptc(input, output, null)
@@ -539,11 +543,12 @@ abstract class ImageProvider {
}
// copy the edited temporary file back to the original
- copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+ editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
+ editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@@ -552,6 +557,60 @@ abstract class ImageProvider {
return true
}
+ private fun editMp4Metadata(
+ context: Context,
+ path: String,
+ uri: Uri,
+ mimeType: String,
+ callback: ImageOpCallback,
+ fields: Map<*, *>
+ ): Boolean {
+ if (mimeType != MimeTypes.MP4) {
+ callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
+ return false
+ }
+
+ try {
+ val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile ->
+ fields.forEach { kv ->
+ val tag = kv.key as String
+ val value = kv.value as String?
+ when (tag) {
+ "gpsCoordinates" -> Mp4ParserHelper.updateLocation(isoFile, value)
+ "xmp" -> Mp4ParserHelper.updateXmp(isoFile, value)
+ }
+ }
+ }
+
+ val pfd = StorageUtils.openOutputFileDescriptor(
+ context = context,
+ mimeType = mimeType,
+ uri = uri,
+ path = path,
+ // do not truncate
+ mode = "w",
+ ) ?: throw Exception("failed to open file descriptor for uri=$uri path=$path")
+ pfd.use {
+ FileOutputStream(it.fileDescriptor).use { outputStream ->
+ outputStream.channel.use { outputChannel ->
+ edits.forEach { (offset, bytes) ->
+ bytes.inputStream().use { inputStream ->
+ Channels.newChannel(inputStream).use { inputChannel ->
+ outputChannel.transferFrom(inputChannel, offset, bytes.size.toLong())
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ callback.onFailure(e)
+ return false
+ }
+
+ return true
+ }
+
// provide `editCoreXmp` to modify existing core XMP,
// or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp(
@@ -571,41 +630,31 @@ abstract class ImageProvider {
return false
}
+ if (mimeType == MimeTypes.MP4) {
+ return editMp4Metadata(
+ context = context,
+ path = path,
+ uri = uri,
+ mimeType = mimeType,
+ callback = callback,
+ fields = mapOf("xmp" to coreXmp),
+ )
+ }
+
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
- var editedXmpString = coreXmp
- var editedExtendedXmp = extendedXmp
- if (editCoreXmp != null) {
- val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
- if (pixyXmp != null) {
- editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
- if (pixyXmp.hasExtendedXmp()) {
- editedExtendedXmp = pixyXmp.extendedXmpDocString()
- }
- }
- }
-
- outputStream().use { output ->
- // reopen input to read from start
- StorageUtils.openInputStream(context, uri)?.use { input ->
- if (editedXmpString != null) {
- if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
- Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
- PixyMetaHelper.setXmp(input, output, editedXmpString, null)
- } else {
- PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
- }
- } else if (canRemoveMetadata(mimeType)) {
- PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
- } else {
- Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
- PixyMetaHelper.setXmp(input, output, null, null)
- }
- }
- }
+ editXmpWithPixy(
+ context = context,
+ uri = uri,
+ mimeType = mimeType,
+ coreXmp = coreXmp,
+ extendedXmp = extendedXmp,
+ editCoreXmp = editCoreXmp,
+ editableFile = this
+ )
} catch (e: Exception) {
callback.onFailure(e)
return false
@@ -614,11 +663,12 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
- copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+ editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
+ editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return false
@@ -627,6 +677,47 @@ abstract class ImageProvider {
return true
}
+ private fun editXmpWithPixy(
+ context: Context,
+ uri: Uri,
+ mimeType: String,
+ coreXmp: String?,
+ extendedXmp: String?,
+ editCoreXmp: ((xmp: String) -> String)?,
+ editableFile: File
+ ) {
+ var editedXmpString = coreXmp
+ var editedExtendedXmp = extendedXmp
+ if (editCoreXmp != null) {
+ val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
+ if (pixyXmp != null) {
+ editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
+ if (pixyXmp.hasExtendedXmp()) {
+ editedExtendedXmp = pixyXmp.extendedXmpDocString()
+ }
+ }
+ }
+
+ editableFile.outputStream().use { output ->
+ // reopen input to read from start
+ StorageUtils.openInputStream(context, uri)?.use { input ->
+ if (editedXmpString != null) {
+ if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
+ Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
+ PixyMetaHelper.setXmp(input, output, editedXmpString, null)
+ } else {
+ PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
+ }
+ } else if (canRemoveMetadata(mimeType)) {
+ PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_XMP))
+ } else {
+ Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
+ PixyMetaHelper.setXmp(input, output, null, null)
+ }
+ }
+ }
+ }
+
// A few bytes are sometimes appended when writing to a document output stream.
// In that case, we need to adjust the trailer video offset accordingly and rewrite the file.
// returns whether the file at `path` is fine
@@ -807,8 +898,8 @@ abstract class ImageProvider {
autoCorrectTrailerOffset: Boolean,
callback: ImageOpCallback,
) {
- if (modifier.containsKey("exif")) {
- val fields = modifier["exif"] as Map<*, *>?
+ if (modifier.containsKey(TYPE_EXIF)) {
+ val fields = modifier[TYPE_EXIF] as Map<*, *>?
if (fields != null && fields.isNotEmpty()) {
if (!editExif(
context = context,
@@ -825,7 +916,7 @@ abstract class ImageProvider {
val value = kv.value
if (value == null) {
// remove attribute
- exif.setAttribute(tag, value)
+ exif.setAttribute(tag, null)
} else {
when (tag) {
ExifInterface.TAG_GPS_LATITUDE,
@@ -864,8 +955,8 @@ abstract class ImageProvider {
}
}
- if (modifier.containsKey("iptc")) {
- val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance()
+ if (modifier.containsKey(TYPE_IPTC)) {
+ val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance()
if (!editIptc(
context = context,
path = path,
@@ -878,8 +969,23 @@ abstract class ImageProvider {
) return
}
- if (modifier.containsKey("xmp")) {
- val xmp = modifier["xmp"] as Map<*, *>?
+ if (modifier.containsKey(TYPE_MP4)) {
+ val fields = modifier[TYPE_MP4] as Map<*, *>?
+ if (fields != null && fields.isNotEmpty()) {
+ if (!editMp4Metadata(
+ context = context,
+ path = path,
+ uri = uri,
+ mimeType = mimeType,
+ callback = callback,
+ fields = fields,
+ )
+ ) return
+ }
+ }
+
+ if (modifier.containsKey(TYPE_XMP)) {
+ val xmp = modifier[TYPE_XMP] as Map<*, *>?
if (xmp != null) {
val coreXmp = xmp["xmp"] as String?
val extendedXmp = xmp["extendedXmp"] as String?
@@ -930,7 +1036,8 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
- copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+ editableFile.transferTo(outputStream(context, mimeType, uri, path))
+ editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return
@@ -973,11 +1080,12 @@ abstract class ImageProvider {
try {
// copy the edited temporary file back to the original
- copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+ editableFile.transferTo(outputStream(context, mimeType, uri, path))
- if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
+ if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return
}
+ editableFile.delete()
} catch (e: IOException) {
callback.onFailure(e)
return
@@ -987,21 +1095,20 @@ abstract class ImageProvider {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
}
- private fun copyFileTo(
+ private fun outputStream(
context: Context,
mimeType: String,
- sourceFile: File,
- targetUri: Uri,
- targetPath: String
- ) {
+ uri: Uri,
+ path: String
+ ): OutputStream {
// truncate is necessary when overwriting a longer file
- val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
- StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri")
+ val mode = "wt"
+ return if (isMediaUriPermissionGranted(context, uri, mimeType)) {
+ StorageUtils.openOutputStream(context, mimeType, uri, mode) ?: throw Exception("failed to open output stream for uri=$uri")
} else {
- val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
- context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri")
+ val documentUri = StorageUtils.getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
+ context.contentResolver.openOutputStream(documentUri, mode) ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$path, uri=$uri")
}
- sourceFile.transferTo(targetStream)
}
interface ImageOpCallback {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
index 6f7cf280e..af0e471c2 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
@@ -104,28 +104,28 @@ object MimeTypes {
else -> false
}
- // as of androidx.exifinterface:exifinterface:1.3.4
fun canEditExif(mimeType: String) = when (mimeType) {
- JPEG,
- PNG,
- WEBP -> true
+ // as of androidx.exifinterface:exifinterface:1.3.4
+ JPEG, PNG, WEBP -> true
else -> false
}
- // as of latest PixyMeta
fun canEditIptc(mimeType: String) = when (mimeType) {
+ // as of latest PixyMeta
JPEG, TIFF -> true
else -> false
}
- // as of latest PixyMeta
fun canEditXmp(mimeType: String) = when (mimeType) {
+ // as of latest PixyMeta
JPEG, TIFF, PNG, GIF -> true
+ // using `mp4parser`
+ MP4 -> true
else -> false
}
- // as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
+ // as of latest PixyMeta
JPEG, TIFF -> true
else -> false
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
index db1bd8e80..078fda691 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
@@ -10,6 +10,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Environment
+import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.MediaStore
@@ -17,6 +18,7 @@ import android.text.TextUtils
import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat
+import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
@@ -580,19 +582,47 @@ object StorageUtils {
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
- Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e)
+ Log.w(LOG_TAG, "failed to open input stream from effectiveUri=$effectiveUri for uri=$uri", e)
null
}
}
- fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? {
+ fun openOutputStream(context: Context, mimeType: String, uri: Uri, mode: String): OutputStream? {
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
return try {
context.contentResolver.openOutputStream(effectiveUri, mode)
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
- Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e)
+ Log.w(LOG_TAG, "failed to open output stream from effectiveUri=$effectiveUri for uri=$uri mode=$mode", e)
+ null
+ }
+ }
+
+ fun openInputFileDescriptor(context: Context, uri: Uri): ParcelFileDescriptor? {
+ val effectiveUri = getOriginalUri(context, uri)
+ return try {
+ context.contentResolver.openFileDescriptor(effectiveUri, "r")
+ } catch (e: Exception) {
+ // among various other exceptions,
+ // opening a file marked pending and owned by another package throws an `IllegalStateException`
+ Log.w(LOG_TAG, "failed to open input file descriptor from effectiveUri=$effectiveUri for uri=$uri", e)
+ null
+ }
+ }
+
+ fun openOutputFileDescriptor(context: Context, mimeType: String, uri: Uri, path: String, mode: String): ParcelFileDescriptor? {
+ val effectiveUri = if (ImageProvider.isMediaUriPermissionGranted(context, uri, mimeType)) {
+ getMediaStoreScopedStorageSafeUri(uri, mimeType)
+ } else {
+ getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
+ }
+ return try {
+ context.contentResolver.openFileDescriptor(effectiveUri, mode)
+ } catch (e: Exception) {
+ // among various other exceptions,
+ // opening a file marked pending and owned by another package throws an `IllegalStateException`
+ Log.w(LOG_TAG, "failed to open output file descriptor from effectiveUri=$effectiveUri for uri=$uri path=$path", e)
null
}
}
diff --git a/android/build.gradle b/android/build.gradle
index 42c5dc7f7..eddb482b6 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,7 +7,7 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' }
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.3.0'
+ classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.14'
diff --git a/lib/model/entry.dart b/lib/model/entry.dart
index a057462b0..3ee822edd 100644
--- a/lib/model/entry.dart
+++ b/lib/model/entry.dart
@@ -284,7 +284,7 @@ class AvesEntry {
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
- bool get canEditLocation => canEdit && canEditExif;
+ bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
bool get canEditTitleDescription => canEdit && canEditXmp;
@@ -294,54 +294,13 @@ class AvesEntry {
bool get canRotateAndFlip => canEdit && canEditExif;
- // `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes,
- // and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files.
- // as of androidx.exifinterface:exifinterface:1.3.4
- bool get canEditExif {
- switch (mimeType.toLowerCase()) {
- case MimeTypes.jpeg:
- case MimeTypes.png:
- case MimeTypes.webp:
- return true;
- default:
- return false;
- }
- }
+ bool get canEditExif => MimeTypes.canEditExif(mimeType);
- // as of latest PixyMeta
- bool get canEditIptc {
- switch (mimeType.toLowerCase()) {
- case MimeTypes.jpeg:
- case MimeTypes.tiff:
- return true;
- default:
- return false;
- }
- }
+ bool get canEditIptc => MimeTypes.canEditIptc(mimeType);
- // as of latest PixyMeta
- bool get canEditXmp {
- switch (mimeType.toLowerCase()) {
- case MimeTypes.gif:
- case MimeTypes.jpeg:
- case MimeTypes.png:
- case MimeTypes.tiff:
- return true;
- default:
- return false;
- }
- }
+ bool get canEditXmp => MimeTypes.canEditXmp(mimeType);
- // as of latest PixyMeta
- bool get canRemoveMetadata {
- switch (mimeType.toLowerCase()) {
- case MimeTypes.jpeg:
- case MimeTypes.tiff:
- return true;
- default:
- return false;
- }
- }
+ bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType);
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
// so it should be registered as width=1920, height=1080, orientation=90,
diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart
index 7692ef761..9593add22 100644
--- a/lib/model/entry_metadata_edition.dart
+++ b/lib/model/entry_metadata_edition.dart
@@ -7,11 +7,13 @@ import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/metadata/fields.dart';
import 'package:aves/ref/exif.dart';
import 'package:aves/ref/iptc.dart';
+import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/xmp.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/utils/xmp_utils.dart';
import 'package:flutter/foundation.dart';
+import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart';
@@ -82,28 +84,63 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
Future> editLocation(LatLng? latLng) async {
final Set dataTypes = {};
+ final Map metadata = {};
- await _missingDateCheckAndExifEdit(dataTypes);
+ final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
- // clear every GPS field
- final exifFields = Map.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
- // add latitude & longitude, if any
- if (latLng != null) {
- final latitude = latLng.latitude;
- final longitude = latLng.longitude;
- if (latitude != 0 && longitude != 0) {
- exifFields.addAll({
- MetadataField.exifGpsLatitude: latitude.abs(),
- MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth,
- MetadataField.exifGpsLongitude: longitude.abs(),
- MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest,
+ if (canEditExif) {
+ // clear every GPS field
+ final exifFields = Map.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
+ // add latitude & longitude, if any
+ if (latLng != null) {
+ final latitude = latLng.latitude;
+ final longitude = latLng.longitude;
+ if (latitude != 0 && longitude != 0) {
+ exifFields.addAll({
+ MetadataField.exifGpsLatitude: latitude.abs(),
+ MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth,
+ MetadataField.exifGpsLongitude: longitude.abs(),
+ MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest,
+ });
+ }
+ }
+ metadata[MetadataType.exif] = Map.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
+
+ if (canEditXmp && missingDate != null) {
+ metadata[MetadataType.xmp] = await _editXmp((descriptions) {
+ editCreateDateXmp(descriptions, missingDate);
+ return true;
});
}
}
- final metadata = {
- MetadataType.exif: Map.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))),
- };
+ if (mimeType == MimeTypes.mp4) {
+ final mp4Fields = {};
+
+ String? iso6709String;
+ if (latLng != null) {
+ final latitude = latLng.latitude;
+ final longitude = latLng.longitude;
+ if (latitude != 0 && longitude != 0) {
+ const locale = 'en_US';
+ final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}';
+ final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}';
+ iso6709String = '$isoLat$isoLon/';
+ }
+ }
+ mp4Fields[MetadataField.mp4GpsCoordinates] = iso6709String;
+
+ if (missingDate != null) {
+ final xmpParts = await _editXmp((descriptions) {
+ editCreateDateXmp(descriptions, missingDate);
+ return true;
+ });
+ mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey];
+ }
+
+ metadata[MetadataType.mp4] = Map.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
+ }
+
final newFields = await metadataEditService.editMetadata(this, metadata);
if (newFields.isNotEmpty) {
dataTypes.addAll({
@@ -160,7 +197,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final description = fields[DescriptionField.description];
if (canEditExif && editDescription) {
- metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description};
+ metadata[MetadataType.exif] = {MetadataField.exifImageDescription.toPlatform!: description};
}
if (canEditIptc) {
@@ -480,10 +517,17 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
}
+ static const xmpCoreKey = 'xmp';
+ static const xmpExtendedKey = 'extendedXmp';
+
Future