#105 mp4 metadata edit

This commit is contained in:
Thibault Deckers 2022-10-19 00:34:39 +02:00
parent 59fe826e24
commit 71ff42997b
33 changed files with 1077 additions and 566 deletions

View file

@ -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
## <a id="v1.7.1"></a>[v1.7.1] - 2022-10-09

View file

@ -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'

View file

@ -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<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label")

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -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)
})
}

View file

@ -126,6 +126,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
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<String, Int>()
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<String, Int>()
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<XmpDirectory>()) {
fallbackProcessXmp(dir.xmpMeta)
}
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
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<String, Any>()
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<String, Any>,
@ -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<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
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<String, Any>,
) {
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<XmpDirectory>()) {
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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -938,10 +988,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
var foundXmp = false
val xmpStrings = mutableListOf<String>()
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<XmpDirectory>()) {
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. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
}
private fun isLargeMp4(mimeType: String, sizeBytes: Long?) = mimeType == MimeTypes.MP4 && Metadata.isDangerouslyLarge(sizeBytes)
private fun exifTagMapper(it: Tag): Pair<String, String> {
val name = if (it.hasTagName()) {
it.tagName

View file

@ -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

View file

@ -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<Mp4ParserHelper>()
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<UserDataBox>(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<Pair<Long, ByteArray>> {
// 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<Pair<Long, ByteArray>>()
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 <T : Box> Container.processBoxes(clazz: Class<T>, 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 <T : Box> Container.removeBoxes(clazz: Class<T>, 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()
}
}

View file

@ -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
}

View file

@ -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<XMP>()
// 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<Directory>) -> 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 {

View file

@ -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())
}
}

View file

@ -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<FieldMap>()
if (modifier.containsKey(TYPE_IPTC)) {
val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance<FieldMap>()
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 {

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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'

View file

@ -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,

View file

@ -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<Set<EntryDataType>> editLocation(LatLng? latLng) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
await _missingDateCheckAndExifEdit(dataTypes);
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
// clear every GPS field
final exifFields = Map<MetadataField, dynamic>.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<MetadataField, dynamic>.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<String, dynamic>.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<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))),
};
if (mimeType == MimeTypes.mp4) {
final mp4Fields = <MetadataField, String?>{};
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<String, String?>.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<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
final xmp = await metadataFetchService.getXmp(this);
final xmpString = xmp?.xmpString;
final extendedXmpString = xmp?.extendedXmpString;
if (xmp == null) {
throw Exception('failed to get XMP');
}
final xmpString = xmp.xmpString;
final extendedXmpString = xmp.extendedXmpString;
final editedXmpString = await XMP.edit(
xmpString,
@ -493,8 +537,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString);
return {
'xmp': editedXmp.xmpString,
'extendedXmp': editedXmp.extendedXmpString,
xmpCoreKey: editedXmp.xmpString,
xmpExtendedKey: editedXmp.extendedXmpString,
};
}
}

View file

@ -32,6 +32,8 @@ enum MetadataType {
jpegAdobe,
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
jpegDucky,
// ISO User Data box content, etc.
mp4,
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
photoshopIrb,
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
@ -78,12 +80,39 @@ extension ExtraMetadataType on MetadataType {
return 'Adobe JPEG';
case MetadataType.jpegDucky:
return 'Ducky';
case MetadataType.mp4:
return 'MP4';
case MetadataType.photoshopIrb:
return 'Photoshop';
case MetadataType.xmp:
return 'XMP';
}
}
String get toPlatform {
switch (this) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif:
return 'exif';
case MetadataType.iccProfile:
return 'icc_profile';
case MetadataType.iptc:
return 'iptc';
case MetadataType.jfif:
return 'jfif';
case MetadataType.jpegAdobe:
return 'jpeg_adobe';
case MetadataType.jpegDucky:
return 'jpeg_ducky';
case MetadataType.mp4:
return 'mp4';
case MetadataType.photoshopIrb:
return 'photoshop_irb';
case MetadataType.xmp:
return 'xmp';
}
}
}
extension ExtraDateFieldSource on DateFieldSource {

View file

@ -37,6 +37,8 @@ enum MetadataField {
exifGpsTrackRef,
exifGpsVersionId,
exifImageDescription,
mp4GpsCoordinates,
mp4Xmp,
xmpXmpCreateDate,
}
@ -117,12 +119,30 @@ extension ExtraMetadataField on MetadataField {
case MetadataField.exifGpsVersionId:
case MetadataField.exifImageDescription:
return MetadataType.exif;
case MetadataField.mp4GpsCoordinates:
case MetadataField.mp4Xmp:
return MetadataType.mp4;
case MetadataField.xmpXmpCreateDate:
return MetadataType.xmp;
}
}
String? get exifInterfaceTag {
String? get toPlatform {
if (type == MetadataType.exif) {
return _toExifInterfaceTag();
} else {
switch (this) {
case MetadataField.mp4GpsCoordinates:
return 'gpsCoordinates';
case MetadataField.mp4Xmp:
return 'xmp';
default:
return null;
}
}
}
String? _toExifInterfaceTag() {
switch (this) {
case MetadataField.exifDate:
return 'DateTime';
@ -196,7 +216,7 @@ extension ExtraMetadataField on MetadataField {
return 'GPSVersionID';
case MetadataField.exifImageDescription:
return 'ImageDescription';
case MetadataField.xmpXmpCreateDate:
default:
return null;
}
}

View file

@ -22,7 +22,7 @@ extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode {
if (!await windowService.isActivity()) return;
final androidInfo = await DeviceInfoPlugin().androidInfo;
if ((androidInfo.version.sdkInt ?? 0) < 23) return;
if (androidInfo.version.sdkInt < 23) return;
debugPrint('Apply display refresh rate: $name');
switch (this) {

View file

@ -136,4 +136,56 @@ class MimeTypes {
}
return null;
}
// `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.
static bool canEditExif(String mimeType) {
switch (mimeType.toLowerCase()) {
// as of androidx.exifinterface:exifinterface:1.3.4
case jpeg:
case png:
case webp:
return true;
default:
return false;
}
}
static bool canEditIptc(String mimeType) {
switch (mimeType.toLowerCase()) {
// as of latest PixyMeta
case jpeg:
case tiff:
return true;
default:
return false;
}
}
static bool canEditXmp(String mimeType) {
switch (mimeType.toLowerCase()) {
// as of latest PixyMeta
case gif:
case jpeg:
case png:
case tiff:
return true;
// using `mp4parser`
case mp4:
return true;
default:
return false;
}
}
static bool canRemoveMetadata(String mimeType) {
switch (mimeType.toLowerCase()) {
// as of latest PixyMeta
case jpeg:
case tiff:
return true;
default:
return false;
}
}
}

View file

@ -12,8 +12,6 @@ abstract class AndroidAppService {
Future<Uint8List> getAppIcon(String packageName, double size);
Future<String?> getAppInstaller();
Future<bool> copyToClipboard(String uri, String? label);
Future<bool> edit(String uri, String mimeType);
@ -73,16 +71,6 @@ class PlatformAndroidAppService implements AndroidAppService {
return Uint8List(0);
}
@override
Future<String?> getAppInstaller() async {
try {
return await _platform.invokeMethod('getAppInstaller');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@override
Future<bool> copyToClipboard(String uri, String? label) async {
try {

View file

@ -146,6 +146,18 @@ class AndroidDebugService {
return {};
}
static Future<String?> getMp4ParserDump(AvesEntry entry) async {
try {
return await _platform.invokeMethod('getMp4ParserDump', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
static Future<Map> getPixyMetadata(AvesEntry entry) async {
try {
// returns map with all data available from the `PixyMeta` library

View file

@ -65,7 +65,7 @@ class PlatformMetadataEditService implements MetadataEditService {
'entry': entry.toPlatformEntryMap(),
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes,
'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.exifInterfaceTag).whereNotNull().toList(),
'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toPlatform).whereNotNull().toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
@ -85,7 +85,7 @@ class PlatformMetadataEditService implements MetadataEditService {
try {
final result = await _platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': entry.toPlatformEntryMap(),
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
'metadata': metadata.map((type, value) => MapEntry(type.toPlatform, value)),
'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
});
if (result != null) return (result as Map).cast<String, dynamic>();
@ -117,7 +117,7 @@ class PlatformMetadataEditService implements MetadataEditService {
try {
final result = await _platform.invokeMethod('removeTypes', <String, dynamic>{
'entry': entry.toPlatformEntryMap(),
'types': types.map(_toPlatformMetadataType).toList(),
'types': types.map((v) => v.toPlatform).toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
@ -127,27 +127,4 @@ class PlatformMetadataEditService implements MetadataEditService {
}
return {};
}
String _toPlatformMetadataType(MetadataType type) {
switch (type) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif:
return 'exif';
case MetadataType.iccProfile:
return 'icc_profile';
case MetadataType.iptc:
return 'iptc';
case MetadataType.jfif:
return 'jfif';
case MetadataType.jpegAdobe:
return 'jpeg_adobe';
case MetadataType.jpegDucky:
return 'jpeg_ducky';
case MetadataType.photoshopIrb:
return 'photoshop_irb';
case MetadataType.xmp:
return 'xmp';
}
}
}

View file

@ -259,7 +259,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'field': field.exifInterfaceTag,
'field': field.toPlatform,
});
if (result is int) {
return dateTimeFromMillis(result, isUtc: false);

View file

@ -15,10 +15,10 @@ class AvesXmp extends Equatable {
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
static AvesXmp fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
return const AvesXmp(xmpString: null);
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:

View file

@ -68,48 +68,59 @@ class Constants {
static const String avesGithub = 'https://github.com/deckerst/aves';
static const String apache2 = 'Apache License 2.0';
static const String bsd2 = 'BSD 2-Clause "Simplified" License';
static const String bsd3 = 'BSD 3-Clause "Revised" License';
static const String eclipse1 = 'Eclipse Public License 1.0';
static const String mit = 'MIT License';
static const List<Dependency> androidDependencies = [
Dependency(
name: 'AndroidX Core-KTX',
license: 'Apache 2.0',
license: apache2,
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
),
Dependency(
name: 'AndroidX Exifinterface',
license: 'Apache 2.0',
license: apache2,
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
),
Dependency(
name: 'AndroidSVG',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
),
Dependency(
name: 'Android-TiffBitmapFactory (Aves fork)',
license: 'MIT',
license: mit,
licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory',
),
Dependency(
name: 'CWAC-Document',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/commonsguy/cwac-document',
),
Dependency(
name: 'Glide',
license: 'Apache 2.0, BSD 2-Clause',
license: '$apache2, $bsd2',
sourceUrl: 'https://github.com/bumptech/glide',
),
Dependency(
name: 'Metadata Extractor',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
),
Dependency(
name: 'MP4 Parser (Aves fork)',
license: apache2,
sourceUrl: 'https://github.com/deckerst/mp4parser',
),
Dependency(
name: 'PixyMeta Android (Aves fork)',
license: 'Eclipse Public License 1.0',
license: eclipse1,
sourceUrl: 'https://github.com/deckerst/pixymeta-android',
),
];
@ -117,71 +128,71 @@ class Constants {
static const List<Dependency> _flutterPluginsCommon = [
Dependency(
name: 'Connectivity Plus',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
),
Dependency(
name: 'Device Info Plus',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus',
),
Dependency(
name: 'Dynamic Color',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/material-foundation/material-dynamic-color-flutter',
),
Dependency(
name: 'fijkplayer (Aves fork)',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/deckerst/fijkplayer',
),
Dependency(
name: 'Flutter Display Mode',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode',
),
Dependency(
name: 'Package Info Plus',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
),
Dependency(
name: 'Permission Handler',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
),
Dependency(
name: 'Printing',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Screen Brightness',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
),
Dependency(
name: 'Shared Preferences',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences',
),
Dependency(
name: 'sqflite',
license: 'BSD 2-Clause',
license: bsd2,
sourceUrl: 'https://github.com/tekartik/sqflite',
),
Dependency(
name: 'Streams Channel (Aves fork)',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
),
Dependency(
name: 'URL Launcher',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher',
),
@ -190,12 +201,12 @@ class Constants {
static const List<Dependency> _googleMobileServices = [
Dependency(
name: 'Google API Availability',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability',
),
Dependency(
name: 'Google Maps for Flutter',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter',
),
@ -204,7 +215,7 @@ class Constants {
static const List<Dependency> _huaweiMobileServices = [
Dependency(
name: 'Huawei Mobile Services (Availability, Map)',
license: 'Apache 2.0',
license: apache2,
licenseUrl: 'https://github.com/HMS-Core/hms-flutter-plugin/blob/master/LICENCE',
sourceUrl: 'https://github.com/HMS-Core/hms-flutter-plugin',
),
@ -222,7 +233,7 @@ class Constants {
..._googleMobileServices,
Dependency(
name: 'FlutterFire (Core, Crashlytics)',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
),
];
@ -237,84 +248,84 @@ class Constants {
static const List<Dependency> flutterPackages = [
Dependency(
name: 'Charts',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/google/charts',
),
Dependency(
name: 'Custom rounded rectangle border',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency(
name: 'Expansion Tile Card (Aves fork)',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
),
Dependency(
name: 'FlexColorPicker',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/rydmike/flex_color_picker',
),
Dependency(
name: 'Flutter Highlight',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/git-touch/highlight',
),
Dependency(
name: 'Flutter Map',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/fleaflet/flutter_map',
),
Dependency(
name: 'Flutter Markdown',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
),
Dependency(
name: 'Flutter Staggered Animations',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
),
Dependency(
name: 'Material Design Icons Flutter',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
),
Dependency(
name: 'Overlay Support',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/boyan01/overlay_support',
),
Dependency(
name: 'Palette Generator',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
),
Dependency(
name: 'Panorama (Aves fork)',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/zesage/panorama',
),
Dependency(
name: 'Percent Indicator',
license: 'BSD 2-Clause',
license: bsd2,
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
),
Dependency(
name: 'Provider',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/rrousselGit/provider',
),
Dependency(
name: 'Smooth Page Indicator',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/Milad-Akarie/smooth_page_indicator',
),
];
@ -322,89 +333,89 @@ class Constants {
static const List<Dependency> dartPackages = [
Dependency(
name: 'Collection',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/dart-lang/collection',
),
Dependency(
name: 'Country Code',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/denixport/dart.country',
),
Dependency(
name: 'Equatable',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/felangel/equatable',
),
Dependency(
name: 'Event Bus',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
),
Dependency(
name: 'Fluster',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
),
Dependency(
name: 'Flutter Lints',
license: 'BSD 3-Clause',
license: bsd3,
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_lints/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_lints',
),
Dependency(
name: 'Get It',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency(
name: 'Intl',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/dart-lang/intl',
),
Dependency(
name: 'LatLong2',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/jifalops/dart-latlong',
),
Dependency(
name: 'Material Color Utilities',
license: 'Apache 2.0',
license: apache2,
licenseUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart/LICENSE',
sourceUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart',
),
Dependency(
name: 'Path',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/dart-lang/path',
),
Dependency(
name: 'PDF for Dart and Flutter',
license: 'Apache 2.0',
license: apache2,
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Proj4dart',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/maRci002/proj4dart',
),
Dependency(
name: 'Stack Trace',
license: 'BSD 3-Clause',
license: bsd3,
sourceUrl: 'https://github.com/dart-lang/stack_trace',
),
Dependency(
name: 'Transparent Image',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/brianegan/transparent_image',
),
Dependency(
name: 'Tuple',
license: 'BSD 2-Clause',
license: bsd2,
sourceUrl: 'https://github.com/google/tuple.dart',
),
Dependency(
name: 'XML',
license: 'MIT',
license: mit,
sourceUrl: 'https://github.com/renggli/dart-xml',
),
];

View file

@ -142,7 +142,6 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
Future<String> _getInfo(BuildContext context) async {
final packageInfo = await PackageInfo.fromPlatform();
final androidInfo = await DeviceInfoPlugin().androidInfo;
final installer = await androidAppService.getAppInstaller();
final flavor = context.read<AppFlavor>().toString().split('.')[1];
return [
'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})',
@ -153,7 +152,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
'System locales: ${WidgetsBinding.instance.window.locales.join(', ')}',
'Aves locale: ${settings.locale ?? 'system'} -> ${settings.appliedLocale}',
'Installer: $installer',
'Installer: ${packageInfo.installerStore}',
].join('\n');
}

View file

@ -22,7 +22,9 @@ class MetadataTab extends StatefulWidget {
}
class _MetadataTabState extends State<MetadataTab> {
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader;
late Future<Map> _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
late Future<String?> _mp4ParserDumpLoader;
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -42,6 +44,7 @@ class _MetadataTabState extends State<MetadataTab> {
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
_mp4ParserDumpLoader = AndroidDebugService.getMp4ParserDump(entry);
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
setState(() {});
@ -85,7 +88,7 @@ class _MetadataTabState extends State<MetadataTab> {
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
return builderFromSnapshotData(context, snapshot.data!, title);
}
@ -112,6 +115,27 @@ class _MetadataTabState extends State<MetadataTab> {
future: _metadataExtractorLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
),
FutureBuilder<String?>(
future: _mp4ParserDumpLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
final data = snapshot.data?.trim();
return AvesExpansionTile(
title: 'MP4 Parser',
children: [
if (data != null && data.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(data),
),
)
],
);
},
),
FutureBuilder<Map>(
future: _pixyMetaLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'),
@ -121,7 +145,7 @@ class _MetadataTabState extends State<MetadataTab> {
future: _tiffStructureLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: snapshot.data!.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(),

View file

@ -91,6 +91,12 @@ class XmpMMNamespace extends XmpNamespace {
XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')),
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)')),
XmpCardData(
RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')),
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
],
),
];
}

View file

@ -25,7 +25,7 @@ class PlatformMobileServices extends MobileServices {
// cf https://github.com/flutter/flutter/issues/23728
// as of google_maps_flutter v2.1.5, Flutter v3.0.1 makes the map hide overlay widgets on API <=22
final androidInfo = await DeviceInfoPlugin().androidInfo;
_canRenderMaps = (androidInfo.version.sdkInt ?? 0) >= 21;
_canRenderMaps = androidInfo.version.sdkInt >= 21;
if (_canRenderMaps) {
final mapsImplementation = GoogleMapsFlutterPlatform.instance;
if (mapsImplementation is GoogleMapsFlutterAndroid) {

View file

@ -147,41 +147,13 @@ packages:
name: connectivity_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.9"
connectivity_plus_linux:
dependency: transitive
description:
name: connectivity_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
connectivity_plus_macos:
dependency: transitive
description:
name: connectivity_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.6"
version: "3.0.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
connectivity_plus_web:
dependency: transitive
description:
name: connectivity_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.5"
connectivity_plus_windows:
dependency: transitive
description:
name: connectivity_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.2"
convert:
dependency: transitive
@ -189,7 +161,7 @@ packages:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.2"
version: "3.1.1"
country_code:
dependency: "direct main"
description:
@ -238,42 +210,14 @@ packages:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.5"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.2"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.2"
version: "7.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.2"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
version: "6.0.0"
dynamic_color:
dependency: "direct main"
description:
@ -361,14 +305,14 @@ packages:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.13"
version: "2.9.0"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.19"
version: "3.3.0"
flex_color_picker:
dependency: "direct main"
description:
@ -559,14 +503,14 @@ packages:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
version: "4.0.2"
image:
dependency: transitive
description:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
version: "3.2.2"
intl:
dependency: "direct main"
description:
@ -715,42 +659,14 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.3+1"
package_info_plus_linux:
dependency: transitive
description:
name: package_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
package_info_plus_macos:
dependency: transitive
description:
name: package_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "3.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
package_info_plus_web:
dependency: transitive
description:
name: package_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.6"
package_info_plus_windows:
dependency: transitive
description:
name: package_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.0.0"
palette_generator:
dependency: "direct main"
description:
@ -824,21 +740,19 @@ packages:
source: hosted
version: "10.1.0"
permission_handler_android:
dependency: "direct overridden"
dependency: transitive
description:
path: permission_handler_android
ref: HEAD
resolved-ref: "279cf44656272c6b89c73b16097108f3c973c31f"
url: "https://github.com/deckerst/flutter-permission-handler"
source: git
version: "9.0.2+1"
name: permission_handler_android
url: "https://pub.dartlang.org"
source: hosted
version: "10.2.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
url: "https://pub.dartlang.org"
source: hosted
version: "9.0.6"
version: "9.0.7"
permission_handler_platform_interface:
dependency: transitive
description:
@ -852,7 +766,7 @@ packages:
name: permission_handler_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
version: "0.1.2"
petitparser:
dependency: transitive
description:
@ -922,14 +836,14 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.3"
version: "6.0.4"
pub_semver:
dependency: transitive
description:
name: pub_semver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.1.2"
qr:
dependency: transitive
description:
@ -992,7 +906,7 @@ packages:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.13"
version: "2.0.14"
shared_preferences_ios:
dependency: transitive
description:
@ -1293,7 +1207,7 @@ packages:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
web_socket_channel:
dependency: transitive
description:
@ -1321,7 +1235,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "3.0.1"
wkt_parser:
dependency: transitive
description:

View file

@ -85,14 +85,6 @@ dependencies:
url_launcher:
xml:
dependency_overrides:
# TODO TLAD as of 2022/10/09, latest version (v10.1.0) does not support Android 13 storage permissions
# `permission_handler_platform_interface` v3.9.0 added support for them but it is not effective
permission_handler_android:
git:
url: https://github.com/deckerst/flutter-permission-handler
path: permission_handler_android
dev_dependencies:
flutter_test:
sdk: flutter