#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 ### Added
- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags)
- Widget: option to open collection on tap - Widget: option to open collection on tap
## <a id="v1.7.1"></a>[v1.7.1] - 2022-10-09 ## <a id="v1.7.1"></a>[v1.7.1] - 2022-10-09

View file

@ -148,23 +148,43 @@ flutter {
} }
repositories { repositories {
maven { url 'https://jitpack.io' } maven {
maven { url 'https://s3.amazonaws.com/repo.commonsware.com' } 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 { 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.core:core-ktx:1.9.0'
implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0' implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.18.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' 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 // huawei flavor only
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300' 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) { when (call.method) {
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) } "getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) } "getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
"getAppInstaller" -> ioScope.launch { safe(call, result, ::getAppInstaller) }
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) } "copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
"edit" -> safe(call, result, ::edit) "edit" -> safe(call, result, ::edit)
"open" -> safe(call, result, ::open) "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) { private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("label") 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.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
@ -38,7 +40,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import org.mp4parser.IsoFile
import java.io.IOException import java.io.IOException
import java.nio.channels.Channels
class DebugHandler(private val context: Context) : MethodCallHandler { class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 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) } "getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) } "getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) } "getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
"getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) }
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) } "getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) } "getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented() else -> result.notImplemented()
@ -319,6 +324,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) 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) { private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -68,7 +68,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback { provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) 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 { provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) 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 { provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) 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 { provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) 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 { provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) 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 var foundXmp = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) { fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
if (foundXmp) return
foundXmp = true
try { try {
for (prop in xmpMeta) { for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) { if (prop is XMPPropertyInfo) {
@ -148,14 +150,66 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString() 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)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input) val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } 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 { val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0) (it.tagCount > 0 || it.errorCount > 0)
&& it !is FileTypeDirectory && it !is FileTypeDirectory
@ -177,157 +231,116 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// directory name // directory name
var thisDirName = baseDirName var thisDirName = baseDirName
if (dir is Mp4UuidBoxDirectory) { if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
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)) {
// optional count for multiple directories of the same type // optional count for multiple directories of the same type
thisDirName = "$thisDirName[${dirIndex + 1}]" thisDirName = "$thisDirName[${dirIndex + 1}]"
} }
// optional parent to distinguish child directories of the same type // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
var dirMap = metadataMap[thisDirName] ?: HashMap() var dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap if (dir !is Mp4UuidBoxDirectory) {
metadataMap[thisDirName] = dirMap
// tags // tags
val tags = dir.tags val tags = dir.tags
when { when {
dir is ExifDirectoryBase -> { dir is ExifDirectoryBase -> {
when { when {
dir.containsGeoTiffTags() -> { dir.containsGeoTiffTags() -> {
// split GeoTIFF tags in their own directory // split GeoTIFF tags in their own directory
val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap() val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap()
metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap
val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) }
byGeoTiff[true]?.flatMap { tag -> byGeoTiff[true]?.flatMap { tag ->
when (tag.tagType) { when (tag.tagType) {
ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> { ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> {
val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType)) val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType))
geoTiffTags.map { geoTag -> geoTiffTags.map { geoTag ->
val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}" val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}"
val value = geoTag.value val value = geoTag.value
val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value" val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value"
Pair(name, description) 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 }?.let { geoTiffDirMap.putAll(it) }
ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS, byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList() }
else -> listOf(exifTagMapper(tag)) mimeType == MimeTypes.DNG -> {
} // split DNG tags in their own directory
}?.let { geoTiffDirMap.putAll(it) } val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } 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() -> {
dir.isPngTextDir() -> { metadataMap.remove(thisDirName)
metadataMap.remove(thisDirName) dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap() metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
for (tag in tags) { for (tag in tags) {
val tagType = tag.tagType val tagType = tag.tagType
if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
val pairs = dir.getObject(tagType) as List<*> val pairs = dir.getObject(tagType) as List<*>
val textPairs = pairs.map { pair -> val textPairs = pairs.map { pair ->
val kv = pair as KeyValuePair val kv = pair as KeyValuePair
val key = kv.key val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 // `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) { val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8 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) })
} else { } 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) { if (!isLargeMp4(mimeType, sizeBytes)) {
processXmp(dir.xmpMeta, dirMap) if (dir is Mp4UuidBoxDirectory) {
} processMp4Uuid(dir)
}
if (dir is Mp4UuidBoxDirectory) { if (dir is XmpDirectory) {
when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { processXmp(dir.xmpMeta, dirMap)
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
}
}
}
} }
} }
@ -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 thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap() val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, 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)) { if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side // 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 // 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>() val metadataMap = HashMap<String, Any>()
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap) getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
if (isVideo(mimeType) || isHeic(mimeType)) { if (isVideo(mimeType) || isHeic(mimeType)) {
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap) getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
} }
// report success even when empty // report success even when empty
@ -457,8 +482,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
private fun getCatalogMetadataByMetadataExtractor( private fun getCatalogMetadataByMetadataExtractor(
uri: Uri,
mimeType: String, mimeType: String,
uri: Uri,
path: String?, path: String?,
sizeBytes: Long?, sizeBytes: Long?,
metadataMap: HashMap<String, Any>, metadataMap: HashMap<String, Any>,
@ -468,6 +493,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var foundXmp = false var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) { fun processXmp(xmpMeta: XMPMeta) {
if (foundXmp) return
foundXmp = true
try { try {
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) { if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
val values = xmpMeta.getPropArrayItemValues(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)) { if (canReadWithMetadataExtractor(mimeType)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input) val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
// File type // File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -565,16 +598,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
// XMP // 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 // XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) { if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
if (!metadataMap.containsKey(KEY_XMP_TITLE)) { if (!metadataMap.containsKey(KEY_XMP_TITLE)) {
dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it } dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it }
} }
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) } 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) metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).forEach(::processMp4Uuid)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
}) {
flags = flags or MASK_IS_360
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) 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 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( private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
uri: Uri,
mimeType: String, mimeType: String,
uri: Uri,
metadataMap: HashMap<String, Any>, metadataMap: HashMap<String, Any>,
) { ) {
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
@ -862,10 +902,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return return
} }
var foundXmp = false
val fields: FieldMap = hashMapOf() val fields: FieldMap = hashMapOf()
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) { fun processXmp(xmpMeta: XMPMeta) {
if (foundXmp) return
foundXmp = true
try { try {
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it } xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it } xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_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 { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(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) metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
} }
} catch (e: Exception) { } 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()) { if (fields.isEmpty()) {
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null) result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
@ -929,6 +977,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(null) result.success(null)
} }
// return XMP components
// return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) { private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -938,10 +988,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return return
} }
var foundXmp = false
val xmpStrings = mutableListOf<String>() val xmpStrings = mutableListOf<String>()
var foundXmp = false
fun processXmp(xmpMeta: XMPMeta) { fun processXmp(xmpMeta: XMPMeta) {
if (foundXmp) return
foundXmp = true
try { try {
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions)) xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
} catch (e: XMPException) { } catch (e: XMPException) {
@ -949,11 +1001,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (canReadWithMetadataExtractor(mimeType)) { if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(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) metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -968,13 +1019,16 @@ 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)) {
if (xmpStrings.isEmpty()) { XMP.checkMp4(context, mimeType, uri) { dirs ->
result.success(null) for (dir in dirs.filterIsInstance<XmpDirectory>()) {
} else { processXmp(dir.xmpMeta)
result.success(xmpStrings) }
}
} }
result.success(xmpStrings)
} }
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) { 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> 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> { private fun exifTagMapper(it: Tag): Pair<String, String> {
val name = if (it.hasTagName()) { val name = if (it.hasTagName()) {
it.tagName it.tagName

View file

@ -42,6 +42,7 @@ object Metadata {
const val TYPE_JFIF = "jfif" const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe" const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_DUCKY = "jpeg_ducky" const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_MP4 = "mp4"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp" 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. // It is not clear whether it is because of the file itself or its metadata.
private const val fileSizeBytesMax = 100 * (1 shl 20) // MB 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 // 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 // to a temporary file, and reusing that preview file for all metadata reading purposes
private const val previewSize: Long = 5 * (1 shl 20) // MB private const val previewSize: Long = 5 * (1 shl 20) // MB
@ -134,10 +137,7 @@ object Metadata {
MimeTypes.PSD_VND, MimeTypes.PSD_VND,
MimeTypes.PSD_X, MimeTypes.PSD_X,
MimeTypes.TIFF -> { MimeTypes.TIFF -> {
if (sizeBytes != null && sizeBytes < fileSizeBytesMax) { if (isDangerouslyLarge(sizeBytes)) {
// small enough to be safe as it is
uri
} else {
// make a preview from the beginning of the file, // make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk // hoping the metadata is accessible in the copied chunk
var previewFile = previewFiles[uri] var previewFile = previewFiles[uri]
@ -146,6 +146,9 @@ object Metadata {
previewFiles[uri] = previewFile previewFiles[uri] = previewFile
} }
Uri.fromFile(previewFile) Uri.fromFile(previewFile)
} else {
// small enough to be safe as it is
uri
} }
} }
// *probably* safe // *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) 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 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.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory import com.adobe.internal.xmp.XMPMetaFactory
import com.adobe.internal.xmp.properties.XMPProperty import com.adobe.internal.xmp.properties.XMPProperty
import 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.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import 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.* import java.util.*
object XMP { object XMP {
private val LOG_TAG = LogUtils.createTag<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 // standard namespaces
// cf com.adobe.internal.xmp.XMPConst // cf com.adobe.internal.xmp.XMPConst
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/" 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, // 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 // 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) { if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try { try {
val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP) 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 // extensions
fun XMPMeta.isMotionPhoto(): Boolean { 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.Metadata
import com.drew.metadata.mp4.Mp4Context import com.drew.metadata.mp4.Mp4Context
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
import deckers.thibault.aves.metadata.XMP
class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) { class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) {
override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> { override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> {
if (payload != null && payload.size >= 16) { if (payload != null && payload.size >= 16) {
val payloadUuid = payload.copyOfRange(0, 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) SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory)
return this return this
} }
} }
return super.processBox(type, payload, boxSize, context) 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.decoder.TiffImage
import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis 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.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry 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.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import java.io.ByteArrayInputStream import java.io.*
import java.io.File import java.nio.channels.Channels
import java.io.IOException
import java.io.OutputStream
import java.util.* import java.util.*
abstract class ImageProvider { abstract class ImageProvider {
@ -350,6 +352,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile) DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
editableFile.delete()
} }
val fileName = targetDocFile.name val fileName = targetDocFile.name
@ -457,11 +460,12 @@ abstract class ImageProvider {
} }
// copy the edited temporary file back to the original // 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)) { if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false return false
} }
editableFile.delete()
} catch (e: IOException) { } catch (e: IOException) {
callback.onFailure(e) callback.onFailure(e)
return false return false
@ -524,7 +528,7 @@ abstract class ImageProvider {
iptc != null -> iptc != null ->
PixyMetaHelper.setIptc(input, output, iptc) PixyMetaHelper.setIptc(input, output, iptc)
canRemoveMetadata(mimeType) -> canRemoveMetadata(mimeType) ->
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC)) PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_IPTC))
else -> { else -> {
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType") Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
PixyMetaHelper.setIptc(input, output, null) PixyMetaHelper.setIptc(input, output, null)
@ -539,11 +543,12 @@ abstract class ImageProvider {
} }
// copy the edited temporary file back to the original // 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)) { if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false return false
} }
editableFile.delete()
} catch (e: IOException) { } catch (e: IOException) {
callback.onFailure(e) callback.onFailure(e)
return false return false
@ -552,6 +557,60 @@ abstract class ImageProvider {
return true 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, // provide `editCoreXmp` to modify existing core XMP,
// or provide `coreXmp` and `extendedXmp` to set them // or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp( private fun editXmp(
@ -571,41 +630,31 @@ abstract class ImageProvider {
return false 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 originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = File.createTempFile("aves", null).apply { val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit() deleteOnExit()
try { try {
var editedXmpString = coreXmp editXmpWithPixy(
var editedExtendedXmp = extendedXmp context = context,
if (editCoreXmp != null) { uri = uri,
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } mimeType = mimeType,
if (pixyXmp != null) { coreXmp = coreXmp,
editedXmpString = editCoreXmp(pixyXmp.xmpDocString()) extendedXmp = extendedXmp,
if (pixyXmp.hasExtendedXmp()) { editCoreXmp = editCoreXmp,
editedExtendedXmp = pixyXmp.extendedXmpDocString() editableFile = this
} )
}
}
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)
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
callback.onFailure(e) callback.onFailure(e)
return false return false
@ -614,11 +663,12 @@ abstract class ImageProvider {
try { try {
// copy the edited temporary file back to the original // 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)) { if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false return false
} }
editableFile.delete()
} catch (e: IOException) { } catch (e: IOException) {
callback.onFailure(e) callback.onFailure(e)
return false return false
@ -627,6 +677,47 @@ abstract class ImageProvider {
return true 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. // 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. // In that case, we need to adjust the trailer video offset accordingly and rewrite the file.
// returns whether the file at `path` is fine // returns whether the file at `path` is fine
@ -807,8 +898,8 @@ abstract class ImageProvider {
autoCorrectTrailerOffset: Boolean, autoCorrectTrailerOffset: Boolean,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
if (modifier.containsKey("exif")) { if (modifier.containsKey(TYPE_EXIF)) {
val fields = modifier["exif"] as Map<*, *>? val fields = modifier[TYPE_EXIF] as Map<*, *>?
if (fields != null && fields.isNotEmpty()) { if (fields != null && fields.isNotEmpty()) {
if (!editExif( if (!editExif(
context = context, context = context,
@ -825,7 +916,7 @@ abstract class ImageProvider {
val value = kv.value val value = kv.value
if (value == null) { if (value == null) {
// remove attribute // remove attribute
exif.setAttribute(tag, value) exif.setAttribute(tag, null)
} else { } else {
when (tag) { when (tag) {
ExifInterface.TAG_GPS_LATITUDE, ExifInterface.TAG_GPS_LATITUDE,
@ -864,8 +955,8 @@ abstract class ImageProvider {
} }
} }
if (modifier.containsKey("iptc")) { if (modifier.containsKey(TYPE_IPTC)) {
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>() val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance<FieldMap>()
if (!editIptc( if (!editIptc(
context = context, context = context,
path = path, path = path,
@ -878,8 +969,23 @@ abstract class ImageProvider {
) return ) return
} }
if (modifier.containsKey("xmp")) { if (modifier.containsKey(TYPE_MP4)) {
val xmp = modifier["xmp"] as Map<*, *>? 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) { if (xmp != null) {
val coreXmp = xmp["xmp"] as String? val coreXmp = xmp["xmp"] as String?
val extendedXmp = xmp["extendedXmp"] as String? val extendedXmp = xmp["extendedXmp"] as String?
@ -930,7 +1036,8 @@ abstract class ImageProvider {
try { try {
// copy the edited temporary file back to the original // 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) { } catch (e: IOException) {
callback.onFailure(e) callback.onFailure(e)
return return
@ -973,11 +1080,12 @@ abstract class ImageProvider {
try { try {
// copy the edited temporary file back to the original // 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 return
} }
editableFile.delete()
} catch (e: IOException) { } catch (e: IOException) {
callback.onFailure(e) callback.onFailure(e)
return return
@ -987,21 +1095,20 @@ abstract class ImageProvider {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} }
private fun copyFileTo( private fun outputStream(
context: Context, context: Context,
mimeType: String, mimeType: String,
sourceFile: File, uri: Uri,
targetUri: Uri, path: String
targetPath: String ): OutputStream {
) {
// truncate is necessary when overwriting a longer file // truncate is necessary when overwriting a longer file
val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { val mode = "wt"
StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri") return if (isMediaUriPermissionGranted(context, uri, mimeType)) {
StorageUtils.openOutputStream(context, mimeType, uri, mode) ?: throw Exception("failed to open output stream for uri=$uri")
} else { } else {
val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file 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, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri") 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 { interface ImageOpCallback {

View file

@ -104,28 +104,28 @@ object MimeTypes {
else -> false else -> false
} }
// as of androidx.exifinterface:exifinterface:1.3.4
fun canEditExif(mimeType: String) = when (mimeType) { fun canEditExif(mimeType: String) = when (mimeType) {
JPEG, // as of androidx.exifinterface:exifinterface:1.3.4
PNG, JPEG, PNG, WEBP -> true
WEBP -> true
else -> false else -> false
} }
// as of latest PixyMeta
fun canEditIptc(mimeType: String) = when (mimeType) { fun canEditIptc(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF -> true JPEG, TIFF -> true
else -> false else -> false
} }
// as of latest PixyMeta
fun canEditXmp(mimeType: String) = when (mimeType) { fun canEditXmp(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF, PNG, GIF -> true JPEG, TIFF, PNG, GIF -> true
// using `mp4parser`
MP4 -> true
else -> false else -> false
} }
// as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) { fun canRemoveMetadata(mimeType: String) = when (mimeType) {
// as of latest PixyMeta
JPEG, TIFF -> true JPEG, TIFF -> true
else -> false else -> false
} }

View file

@ -10,6 +10,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
@ -17,6 +18,7 @@ import android.text.TextUtils
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat 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.FileUtils.transferFrom
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -580,19 +582,47 @@ object StorageUtils {
} catch (e: Exception) { } catch (e: Exception) {
// among various other exceptions, // among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException` // 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 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) val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
return try { return try {
context.contentResolver.openOutputStream(effectiveUri, mode) context.contentResolver.openOutputStream(effectiveUri, mode)
} catch (e: Exception) { } catch (e: Exception) {
// among various other exceptions, // among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException` // 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 null
} }
} }

View file

@ -7,7 +7,7 @@ buildscript {
maven { url 'https://developer.huawei.com/repo/' } maven { url 'https://developer.huawei.com/repo/' }
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only) // GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.14' 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 canEditDate => canEdit && (canEditExif || canEditXmp);
bool get canEditLocation => canEdit && canEditExif; bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
bool get canEditTitleDescription => canEdit && canEditXmp; bool get canEditTitleDescription => canEdit && canEditXmp;
@ -294,54 +294,13 @@ class AvesEntry {
bool get canRotateAndFlip => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes, bool get canEditExif => MimeTypes.canEditExif(mimeType);
// 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;
}
}
// as of latest PixyMeta bool get canEditIptc => MimeTypes.canEditIptc(mimeType);
bool get canEditIptc {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta bool get canEditXmp => MimeTypes.canEditXmp(mimeType);
bool get canEditXmp {
switch (mimeType.toLowerCase()) {
case MimeTypes.gif:
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// as of latest PixyMeta bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType);
bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.tiff:
return true;
default:
return false;
}
}
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, // 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, // 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/model/metadata/fields.dart';
import 'package:aves/ref/exif.dart'; import 'package:aves/ref/exif.dart';
import 'package:aves/ref/iptc.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/common/services.dart';
import 'package:aves/services/metadata/xmp.dart'; import 'package:aves/services/metadata/xmp.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/utils/xmp_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
@ -82,28 +84,63 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async { Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
final Set<EntryDataType> dataTypes = {}; final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
await _missingDateCheckAndExifEdit(dataTypes); final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
// clear every GPS field if (canEditExif) {
final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null))); // clear every GPS field
// add latitude & longitude, if any final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
if (latLng != null) { // add latitude & longitude, if any
final latitude = latLng.latitude; if (latLng != null) {
final longitude = latLng.longitude; final latitude = latLng.latitude;
if (latitude != 0 && longitude != 0) { final longitude = latLng.longitude;
exifFields.addAll({ if (latitude != 0 && longitude != 0) {
MetadataField.exifGpsLatitude: latitude.abs(), exifFields.addAll({
MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth, MetadataField.exifGpsLatitude: latitude.abs(),
MetadataField.exifGpsLongitude: longitude.abs(), MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth,
MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest, 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 = { if (mimeType == MimeTypes.mp4) {
MetadataType.exif: Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))), 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); final newFields = await metadataEditService.editMetadata(this, metadata);
if (newFields.isNotEmpty) { if (newFields.isNotEmpty) {
dataTypes.addAll({ dataTypes.addAll({
@ -160,7 +197,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final description = fields[DescriptionField.description]; final description = fields[DescriptionField.description];
if (canEditExif && editDescription) { if (canEditExif && editDescription) {
metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description}; metadata[MetadataType.exif] = {MetadataField.exifImageDescription.toPlatform!: description};
} }
if (canEditIptc) { 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 { Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
final xmp = await metadataFetchService.getXmp(this); final xmp = await metadataFetchService.getXmp(this);
final xmpString = xmp?.xmpString; if (xmp == null) {
final extendedXmpString = xmp?.extendedXmpString; throw Exception('failed to get XMP');
}
final xmpString = xmp.xmpString;
final extendedXmpString = xmp.extendedXmpString;
final editedXmpString = await XMP.edit( final editedXmpString = await XMP.edit(
xmpString, xmpString,
@ -493,8 +537,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString); final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString);
return { return {
'xmp': editedXmp.xmpString, xmpCoreKey: editedXmp.xmpString,
'extendedXmp': editedXmp.extendedXmpString, xmpExtendedKey: editedXmp.extendedXmpString,
}; };
} }
} }

View file

@ -32,6 +32,8 @@ enum MetadataType {
jpegAdobe, jpegAdobe,
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
jpegDucky, jpegDucky,
// ISO User Data box content, etc.
mp4,
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
photoshopIrb, photoshopIrb,
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform // XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
@ -78,12 +80,39 @@ extension ExtraMetadataType on MetadataType {
return 'Adobe JPEG'; return 'Adobe JPEG';
case MetadataType.jpegDucky: case MetadataType.jpegDucky:
return 'Ducky'; return 'Ducky';
case MetadataType.mp4:
return 'MP4';
case MetadataType.photoshopIrb: case MetadataType.photoshopIrb:
return 'Photoshop'; return 'Photoshop';
case MetadataType.xmp: case MetadataType.xmp:
return '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 { extension ExtraDateFieldSource on DateFieldSource {

View file

@ -37,6 +37,8 @@ enum MetadataField {
exifGpsTrackRef, exifGpsTrackRef,
exifGpsVersionId, exifGpsVersionId,
exifImageDescription, exifImageDescription,
mp4GpsCoordinates,
mp4Xmp,
xmpXmpCreateDate, xmpXmpCreateDate,
} }
@ -117,12 +119,30 @@ extension ExtraMetadataField on MetadataField {
case MetadataField.exifGpsVersionId: case MetadataField.exifGpsVersionId:
case MetadataField.exifImageDescription: case MetadataField.exifImageDescription:
return MetadataType.exif; return MetadataType.exif;
case MetadataField.mp4GpsCoordinates:
case MetadataField.mp4Xmp:
return MetadataType.mp4;
case MetadataField.xmpXmpCreateDate: case MetadataField.xmpXmpCreateDate:
return MetadataType.xmp; 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) { switch (this) {
case MetadataField.exifDate: case MetadataField.exifDate:
return 'DateTime'; return 'DateTime';
@ -196,7 +216,7 @@ extension ExtraMetadataField on MetadataField {
return 'GPSVersionID'; return 'GPSVersionID';
case MetadataField.exifImageDescription: case MetadataField.exifImageDescription:
return 'ImageDescription'; return 'ImageDescription';
case MetadataField.xmpXmpCreateDate: default:
return null; return null;
} }
} }

View file

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

View file

@ -136,4 +136,56 @@ class MimeTypes {
} }
return null; 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<Uint8List> getAppIcon(String packageName, double size);
Future<String?> getAppInstaller();
Future<bool> copyToClipboard(String uri, String? label); Future<bool> copyToClipboard(String uri, String? label);
Future<bool> edit(String uri, String mimeType); Future<bool> edit(String uri, String mimeType);
@ -73,16 +71,6 @@ class PlatformAndroidAppService implements AndroidAppService {
return Uint8List(0); 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 @override
Future<bool> copyToClipboard(String uri, String? label) async { Future<bool> copyToClipboard(String uri, String? label) async {
try { try {

View file

@ -146,6 +146,18 @@ class AndroidDebugService {
return {}; 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 { static Future<Map> getPixyMetadata(AvesEntry entry) async {
try { try {
// returns map with all data available from the `PixyMeta` library // returns map with all data available from the `PixyMeta` library

View file

@ -65,7 +65,7 @@ class PlatformMetadataEditService implements MetadataEditService {
'entry': entry.toPlatformEntryMap(), 'entry': entry.toPlatformEntryMap(),
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes, '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>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -85,7 +85,7 @@ class PlatformMetadataEditService implements MetadataEditService {
try { try {
final result = await _platform.invokeMethod('editMetadata', <String, dynamic>{ final result = await _platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': entry.toPlatformEntryMap(), 'entry': entry.toPlatformEntryMap(),
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), 'metadata': metadata.map((type, value) => MapEntry(type.toPlatform, value)),
'autoCorrectTrailerOffset': autoCorrectTrailerOffset, 'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
@ -117,7 +117,7 @@ class PlatformMetadataEditService implements MetadataEditService {
try { try {
final result = await _platform.invokeMethod('removeTypes', <String, dynamic>{ final result = await _platform.invokeMethod('removeTypes', <String, dynamic>{
'entry': entry.toPlatformEntryMap(), '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>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
@ -127,27 +127,4 @@ class PlatformMetadataEditService implements MetadataEditService {
} }
return {}; 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, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
'field': field.exifInterfaceTag, 'field': field.toPlatform,
}); });
if (result is int) { if (result is int) {
return dateTimeFromMillis(result, isUtc: false); return dateTimeFromMillis(result, isUtc: false);

View file

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

View file

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

View file

@ -22,7 +22,9 @@ class MetadataTab extends StatefulWidget {
} }
class _MetadataTabState extends State<MetadataTab> { 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 // MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -42,6 +44,7 @@ class _MetadataTabState extends State<MetadataTab> {
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
_mp4ParserDumpLoader = AndroidDebugService.getMp4ParserDump(entry);
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry); _pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry); _tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
setState(() {}); setState(() {});
@ -85,7 +88,7 @@ class _MetadataTabState extends State<MetadataTab> {
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) { Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString()); 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); return builderFromSnapshotData(context, snapshot.data!, title);
} }
@ -112,6 +115,27 @@ class _MetadataTabState extends State<MetadataTab> {
future: _metadataExtractorLoader, future: _metadataExtractorLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'), 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>( FutureBuilder<Map>(
future: _pixyMetaLoader, future: _pixyMetaLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'), builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'),
@ -121,7 +145,7 @@ class _MetadataTabState extends State<MetadataTab> {
future: _tiffStructureLoader, future: _tiffStructureLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: snapshot.data!.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(), 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'DerivedFrom/(.*)')),
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')), XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Ingredients\[(\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 // 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 // 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; final androidInfo = await DeviceInfoPlugin().androidInfo;
_canRenderMaps = (androidInfo.version.sdkInt ?? 0) >= 21; _canRenderMaps = androidInfo.version.sdkInt >= 21;
if (_canRenderMaps) { if (_canRenderMaps) {
final mapsImplementation = GoogleMapsFlutterPlatform.instance; final mapsImplementation = GoogleMapsFlutterPlatform.instance;
if (mapsImplementation is GoogleMapsFlutterAndroid) { if (mapsImplementation is GoogleMapsFlutterAndroid) {

View file

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

View file

@ -85,14 +85,6 @@ dependencies:
url_launcher: url_launcher:
xml: 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter