diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d9a060a..75253c28a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags) - Widget: option to open collection on tap ## [v1.7.1] - 2022-10-09 diff --git a/android/app/build.gradle b/android/app/build.gradle index a26f23c51..3bfc8dff9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,23 +148,43 @@ flutter { } repositories { - maven { url 'https://jitpack.io' } - maven { url 'https://s3.amazonaws.com/repo.commonsware.com' } + maven { + url 'https://jitpack.io' + content { + includeGroup "com.github.deckerst" + includeGroup "com.github.deckerst.mp4parser" + } + } + maven { + url 'https://s3.amazonaws.com/repo.commonsware.com' + content { + excludeGroupByRegex "com\\.github\\.deckerst.*" + } + } } dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.multidex:multidex:2.0.1' + implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.commonsware.cwac:document:0.5.0' implementation 'com.drewnoakes:metadata-extractor:2.18.0' - // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory - implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' - // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android - implementation 'com.github.deckerst:pixymeta-android:706bd73d6e' implementation 'com.github.bumptech.glide:glide:4.14.2' + // SLF4J implementation for `mp4parser` + implementation 'org.slf4j:slf4j-simple:2.0.3' + + // forked, built by JitPack: + // - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory + // - https://jitpack.io/p/deckerst/mp4parser + // - https://jitpack.io/p/deckerst/pixymeta-android + implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' + implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb' + implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb' + implementation 'com.github.deckerst:pixymeta-android:706bd73d6e' // huawei flavor only huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 5a9ea5ace..2ce5db3d5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -50,7 +50,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getPackages" -> ioScope.launch { safe(call, result, ::getPackages) } "getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) } - "getAppInstaller" -> ioScope.launch { safe(call, result, ::getAppInstaller) } "copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) } "edit" -> safe(call, result, ::edit) "open" -> safe(call, result, ::open) @@ -187,23 +186,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } } - private fun getAppInstaller(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { - val packageName = context.packageName - val pm = context.packageManager - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val info = pm.getInstallSourceInfo(packageName) - result.success(info.initiatingPackageName ?: info.installingPackageName) - } else { - @Suppress("deprecation") - result.success(pm.getInstallerPackageName(packageName)) - } - } catch (e: Exception) { - result.error("getAppInstaller-exception", "failed to get installer for packageName=$packageName", e.message) - return - } - } - private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } val label = call.argument("label") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index e056cb3af..9135f9430 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -18,10 +18,12 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.Metadata +import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta @@ -38,7 +40,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory +import org.mp4parser.IsoFile import java.io.IOException +import java.nio.channels.Channels class DebugHandler(private val context: Context) : MethodCallHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -60,6 +64,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) } "getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) } "getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) } + "getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) } "getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) } "getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) } else -> result.notImplemented() @@ -319,6 +324,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getMp4ParserDump-args", "missing arguments", null) + return + } + + val sb = StringBuilder() + if (mimeType == MimeTypes.MP4) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + Channels.newChannel(input).use { channel -> + IsoFile(channel).use { isoFile -> + isoFile.dumpBoxes(sb) + } + } + } + } catch (e: Exception) { + result.error("getMp4ParserDump-exception", e.message, e.stackTraceToString()) + return + } + } + result.success(sb.toString()) + } + private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index bda1f0c97..df2c1c86e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -68,7 +68,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable) }) } @@ -98,7 +98,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable) }) } @@ -127,7 +127,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable) }) } @@ -154,7 +154,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable) }) } @@ -182,7 +182,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 7df4f8f25..0f053d2a4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -126,6 +126,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { var foundXmp = false fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap) { + if (foundXmp) return + foundXmp = true try { for (prop in xmpMeta) { if (prop is XMPPropertyInfo) { @@ -148,14 +150,66 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString() } + val mp4UuidDirCount = HashMap() + fun processMp4Uuid(dir: Mp4UuidBoxDirectory) { + var thisDirName: String + when (val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { + GSpherical.SPHERICAL_VIDEO_V1_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + thisDirName = "Spherical Video" + metadataMap[thisDirName] = HashMap(GSpherical(bytes).describe()) + } + QuickTimeMetadata.PROF_UUID -> { + // redundant with info derived on the Dart side + } + QuickTimeMetadata.USMT_UUID -> { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) + if (blocks.isNotEmpty()) { + thisDirName = "QuickTime User Media" + val usmt = metadataMap[thisDirName] ?: HashMap() + metadataMap[thisDirName] = usmt + + blocks.forEach { + var key = it.type + var value = it.value + val language = it.language + + var i = 0 + while (usmt.containsKey(key)) { + key = it.type + " (${++i})" + } + if (language != "und") { + value += " ($language)" + } + usmt[key] = value + } + } + } + else -> { + val uuidPart = uuid.substringBefore('-') + thisDirName = "${dir.name} $uuidPart" + + val count = mp4UuidDirCount[uuidPart] ?: 0 + mp4UuidDirCount[uuidPart] = count + 1 + if (count > 0) { + thisDirName += " ($count)" + } + + val dirMap = metadataMap[thisDirName] ?: HashMap() + metadataMap[thisDirName] = dirMap + + dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) }) + } + } + } + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = Helper.safeRead(input) foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } - foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } - val uuidDirCount = HashMap() val dirByName = metadata.directories.filter { (it.tagCount > 0 || it.errorCount > 0) && it !is FileTypeDirectory @@ -177,157 +231,116 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // directory name var thisDirName = baseDirName - if (dir is Mp4UuidBoxDirectory) { - val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') - thisDirName += " $uuid" - - val count = uuidDirCount[uuid] ?: 0 - uuidDirCount[uuid] = count + 1 - if (count > 0) { - thisDirName += " ($count)" - } - } else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) { + if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) { // optional count for multiple directories of the same type thisDirName = "$thisDirName[${dirIndex + 1}]" } - // optional parent to distinguish child directories of the same type dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } var dirMap = metadataMap[thisDirName] ?: HashMap() - metadataMap[thisDirName] = dirMap + if (dir !is Mp4UuidBoxDirectory) { + metadataMap[thisDirName] = dirMap - // tags - val tags = dir.tags - when { - dir is ExifDirectoryBase -> { - when { - dir.containsGeoTiffTags() -> { - // split GeoTIFF tags in their own directory - val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap() - metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap - val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } - byGeoTiff[true]?.flatMap { tag -> - when (tag.tagType) { - ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> { - val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType)) - geoTiffTags.map { geoTag -> - val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}" - val value = geoTag.value - val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value" - Pair(name, description) + // tags + val tags = dir.tags + when { + dir is ExifDirectoryBase -> { + when { + dir.containsGeoTiffTags() -> { + // split GeoTIFF tags in their own directory + val geoTiffDirMap = metadataMap[DIR_EXIF_GEOTIFF] ?: HashMap() + metadataMap[DIR_EXIF_GEOTIFF] = geoTiffDirMap + val byGeoTiff = tags.groupBy { ExifTags.isGeoTiffTag(it.tagType) } + byGeoTiff[true]?.flatMap { tag -> + when (tag.tagType) { + ExifGeoTiffTags.TAG_GEO_KEY_DIRECTORY -> { + val geoTiffTags = (dir as ExifIFD0Directory).extractGeoKeys(dir.getIntArray(tag.tagType)) + geoTiffTags.map { geoTag -> + val name = GeoTiffKeys.getTagName(geoTag.key) ?: "0x${geoTag.key.toString(16)}" + val value = geoTag.value + val description = if (value is DoubleArray) value.joinToString(" ") { doubleFormat.format(it) } else "$value" + Pair(name, description) + } } + // skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys + ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS, + ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList() + else -> listOf(exifTagMapper(tag)) } - // skip `Geo double/ascii params`, as their content is split and presented through various GeoTIFF keys - ExifGeoTiffTags.TAG_GEO_DOUBLE_PARAMS, - ExifGeoTiffTags.TAG_GEO_ASCII_PARAMS -> ArrayList() - else -> listOf(exifTagMapper(tag)) - } - }?.let { geoTiffDirMap.putAll(it) } - byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } + }?.let { geoTiffDirMap.putAll(it) } + byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } + } + mimeType == MimeTypes.DNG -> { + // split DNG tags in their own directory + val dngDirMap = metadataMap[DIR_DNG] ?: HashMap() + metadataMap[DIR_DNG] = dngDirMap + val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) } + byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) } + byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } + } + else -> dirMap.putAll(tags.map { exifTagMapper(it) }) } - mimeType == MimeTypes.DNG -> { - // split DNG tags in their own directory - val dngDirMap = metadataMap[DIR_DNG] ?: HashMap() - metadataMap[DIR_DNG] = dngDirMap - val byDng = tags.groupBy { ExifTags.isDngTag(it.tagType) } - byDng[true]?.map { exifTagMapper(it) }?.let { dngDirMap.putAll(it) } - byDng[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) } - } - else -> dirMap.putAll(tags.map { exifTagMapper(it) }) } - } - dir.isPngTextDir() -> { - metadataMap.remove(thisDirName) - dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap() - metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap + dir.isPngTextDir() -> { + metadataMap.remove(thisDirName) + dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap() + metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap - for (tag in tags) { - val tagType = tag.tagType - if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { - val pairs = dir.getObject(tagType) as List<*> - val textPairs = pairs.map { pair -> - val kv = pair as KeyValuePair - val key = kv.key - // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 - val charset = if (baseDirName == PNG_ITXT_DIR_NAME) { - @SuppressLint("ObsoleteSdkInt") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - StandardCharsets.UTF_8 - } else { - Charset.forName("UTF-8") - } - } else { - kv.value.charset - } - val valueString = String(kv.value.bytes, charset) - val dirs = extractPngProfile(key, valueString) - if (dirs?.any() == true) { - dirs.forEach { profileDir -> - val profileDirName = "${dir.name}/${profileDir.name}" - val profileDirMap = metadataMap[profileDirName] ?: HashMap() - metadataMap[profileDirName] = profileDirMap - val profileTags = profileDir.tags - if (profileDir is ExifDirectoryBase) { - profileDirMap.putAll(profileTags.map { exifTagMapper(it) }) + for (tag in tags) { + val tagType = tag.tagType + if (tagType == PngDirectory.TAG_TEXTUAL_DATA) { + val pairs = dir.getObject(tagType) as List<*> + val textPairs = pairs.map { pair -> + val kv = pair as KeyValuePair + val key = kv.key + // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 + val charset = if (baseDirName == PNG_ITXT_DIR_NAME) { + @SuppressLint("ObsoleteSdkInt") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + StandardCharsets.UTF_8 } else { - profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) }) + Charset.forName("UTF-8") } + } else { + kv.value.charset + } + val valueString = String(kv.value.bytes, charset) + val dirs = extractPngProfile(key, valueString) + if (dirs?.any() == true) { + dirs.forEach { profileDir -> + val profileDirName = "${dir.name}/${profileDir.name}" + val profileDirMap = metadataMap[profileDirName] ?: HashMap() + metadataMap[profileDirName] = profileDirMap + val profileTags = profileDir.tags + if (profileDir is ExifDirectoryBase) { + profileDirMap.putAll(profileTags.map { exifTagMapper(it) }) + } else { + profileDirMap.putAll(profileTags.map { Pair(it.tagName, it.description) }) + } + } + null + } else { + Pair(key, valueString) } - null - } else { - Pair(key, valueString) } + dirMap.putAll(textPairs.filterNotNull()) + } else { + dirMap[tag.tagName] = tag.description } - dirMap.putAll(textPairs.filterNotNull()) - } else { - dirMap[tag.tagName] = tag.description } } + else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } - else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } - if (dir is XmpDirectory) { - processXmp(dir.xmpMeta, dirMap) - } + if (!isLargeMp4(mimeType, sizeBytes)) { + if (dir is Mp4UuidBoxDirectory) { + processMp4Uuid(dir) + } - if (dir is Mp4UuidBoxDirectory) { - when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) { - GSpherical.SPHERICAL_VIDEO_V1_UUID -> { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) - metadataMap.remove(thisDirName) - } - QuickTimeMetadata.PROF_UUID -> { - // redundant with info derived on the Dart side - metadataMap.remove(thisDirName) - } - QuickTimeMetadata.USMT_UUID -> { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) - if (blocks.isNotEmpty()) { - metadataMap.remove(thisDirName) - thisDirName = "QuickTime User Media" - val usmt = metadataMap[thisDirName] ?: HashMap() - metadataMap[thisDirName] = usmt - - blocks.forEach { - var key = it.type - var value = it.value - val language = it.language - - var i = 0 - while (usmt.containsKey(key)) { - key = it.type + " (${++i})" - } - if (language != "und") { - value += " ($language)" - } - usmt[key] = value - } - } - } + if (dir is XmpDirectory) { + processXmp(dir.xmpMeta, dirMap) } } @@ -367,13 +380,25 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta -> + fun fallbackProcessXmp(xmpMeta: XMPMeta) { val thisDirName = XmpDirectory().name val dirMap = metadataMap[thisDirName] ?: HashMap() metadataMap[thisDirName] = dirMap processXmp(xmpMeta, dirMap) } + XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp) + if (isLargeMp4(mimeType, sizeBytes)) { + XMP.checkMp4(context, mimeType, uri) { dirs -> + for (dir in dirs.filterIsInstance()) { + fallbackProcessXmp(dir.xmpMeta) + } + for (dir in dirs.filterIsInstance()) { + processMp4Uuid(dir) + } + } + } + if (isVideo(mimeType)) { // this is used as fallback when the video metadata cannot be found on the Dart side // and to identify whether there is an accessible cover image @@ -447,9 +472,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap) + getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap) if (isVideo(mimeType) || isHeic(mimeType)) { - getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap) + getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap) } // report success even when empty @@ -457,8 +482,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } private fun getCatalogMetadataByMetadataExtractor( - uri: Uri, mimeType: String, + uri: Uri, path: String?, sizeBytes: Long?, metadataMap: HashMap, @@ -468,6 +493,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { var foundXmp = false fun processXmp(xmpMeta: XMPMeta) { + if (foundXmp) return + foundXmp = true try { if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) { val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME) @@ -504,12 +531,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } + fun processMp4Uuid(dir: Mp4UuidBoxDirectory) { + // identification of spherical video (aka 360° video) + if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) { + flags = flags or MASK_IS_360 + } + } + if (canReadWithMetadataExtractor(mimeType)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = Helper.safeRead(input) foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } - foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } // File type for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { @@ -565,16 +598,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } // XMP - metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp) + if (!isLargeMp4(mimeType, sizeBytes)) { + metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp) - // XMP fallback to IPTC - if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) { - for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { - if (!metadataMap.containsKey(KEY_XMP_TITLE)) { - dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it } - } - if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { - dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) } + // XMP fallback to IPTC + if (!metadataMap.containsKey(KEY_XMP_TITLE) || !metadataMap.containsKey(KEY_XMP_SUBJECTS)) { + for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { + if (!metadataMap.containsKey(KEY_XMP_TITLE)) { + dir.getSafeString(IptcDirectory.TAG_OBJECT_NAME) { metadataMap[KEY_XMP_TITLE] = it } + } + if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { + dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) } + } } } } @@ -620,12 +655,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - // identification of spherical video (aka 360° video) - if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any { - it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID - }) { - flags = flags or MASK_IS_360 - } + metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).forEach(::processMp4Uuid) } } catch (e: Exception) { Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) @@ -662,7 +692,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) + if (isLargeMp4(mimeType, sizeBytes)) { + XMP.checkMp4(context, mimeType, uri) { dirs -> + for (dir in dirs.filterIsInstance()) { + processXmp(dir.xmpMeta) + } + for (dir in dirs.filterIsInstance()) { + processMp4Uuid(dir) + } + } + } if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE @@ -670,8 +710,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } private fun getMultimediaCatalogMetadataByMediaMetadataRetriever( - uri: Uri, mimeType: String, + uri: Uri, metadataMap: HashMap, ) { val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return @@ -862,10 +902,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return } - var foundXmp = false val fields: FieldMap = hashMapOf() + var foundXmp = false fun processXmp(xmpMeta: XMPMeta) { + if (foundXmp) return + foundXmp = true try { xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it } xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it } @@ -879,11 +921,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - if (canReadWithMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = Helper.safeRead(input) - foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp) } } catch (e: Exception) { @@ -895,7 +936,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) + if (isLargeMp4(mimeType, sizeBytes)) { + XMP.checkMp4(context, mimeType, uri) { dirs -> + for (dir in dirs.filterIsInstance()) { + processXmp(dir.xmpMeta) + } + } + } if (fields.isEmpty()) { result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null) @@ -929,6 +977,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(null) } + // return XMP components + // return an empty list if there is no XMP private fun getXmp(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -938,10 +988,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return } - var foundXmp = false val xmpStrings = mutableListOf() + var foundXmp = false fun processXmp(xmpMeta: XMPMeta) { + if (foundXmp) return + foundXmp = true try { xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions)) } catch (e: XMPException) { @@ -949,11 +1001,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - if (canReadWithMetadataExtractor(mimeType)) { + if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = Helper.safeRead(input) - foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp) } } catch (e: Exception) { @@ -968,13 +1019,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) - - if (xmpStrings.isEmpty()) { - result.success(null) - } else { - result.success(xmpStrings) + XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) + if (isLargeMp4(mimeType, sizeBytes)) { + XMP.checkMp4(context, mimeType, uri) { dirs -> + for (dir in dirs.filterIsInstance()) { + processXmp(dir.xmpMeta) + } + } } + + result.success(xmpStrings) } private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) { @@ -1161,6 +1215,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { omitXmpMetaElement = false // e.g. ... } + private fun isLargeMp4(mimeType: String, sizeBytes: Long?) = mimeType == MimeTypes.MP4 && Metadata.isDangerouslyLarge(sizeBytes) + private fun exifTagMapper(it: Tag): Pair { val name = if (it.hasTagName()) { it.tagName diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 635148196..8a2952972 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -42,6 +42,7 @@ object Metadata { const val TYPE_JFIF = "jfif" const val TYPE_JPEG_ADOBE = "jpeg_adobe" const val TYPE_JPEG_DUCKY = "jpeg_ducky" + const val TYPE_MP4 = "mp4" const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" const val TYPE_XMP = "xmp" @@ -121,6 +122,8 @@ object Metadata { // It is not clear whether it is because of the file itself or its metadata. private const val fileSizeBytesMax = 100 * (1 shl 20) // MB + fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > fileSizeBytesMax + // we try and read metadata from large files by copying an arbitrary amount from its beginning // to a temporary file, and reusing that preview file for all metadata reading purposes private const val previewSize: Long = 5 * (1 shl 20) // MB @@ -134,10 +137,7 @@ object Metadata { MimeTypes.PSD_VND, MimeTypes.PSD_X, MimeTypes.TIFF -> { - if (sizeBytes != null && sizeBytes < fileSizeBytesMax) { - // small enough to be safe as it is - uri - } else { + if (isDangerouslyLarge(sizeBytes)) { // make a preview from the beginning of the file, // hoping the metadata is accessible in the copied chunk var previewFile = previewFiles[uri] @@ -146,6 +146,9 @@ object Metadata { previewFiles[uri] = previewFile } Uri.fromFile(previewFile) + } else { + // small enough to be safe as it is + uri } } // *probably* safe diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt new file mode 100644 index 000000000..21c7f0967 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Mp4ParserHelper.kt @@ -0,0 +1,202 @@ +package deckers.thibault.aves.metadata + +import android.content.Context +import android.net.Uri +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.StorageUtils +import org.mp4parser.* +import org.mp4parser.boxes.UserBox +import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox +import org.mp4parser.boxes.iso14496.part12.FreeBox +import org.mp4parser.boxes.iso14496.part12.MediaDataBox +import org.mp4parser.boxes.iso14496.part12.MovieBox +import org.mp4parser.boxes.iso14496.part12.UserDataBox +import org.mp4parser.support.AbstractBox +import org.mp4parser.tools.Path +import java.io.ByteArrayOutputStream +import java.io.FileInputStream +import java.nio.channels.Channels + +object Mp4ParserHelper { + private val LOG_TAG = LogUtils.createTag() + + fun updateLocation(isoFile: IsoFile, locationIso6709: String?) { + // Apple GPS Coordinates Box can be in various locations: + // - moov[0]/udta[0]/©xyz + // - moov[0]/meta[0]/ilst/©xyz + // - others? + isoFile.removeBoxes(AppleGPSCoordinatesBox::class.java, true) + + locationIso6709 ?: return + + val movieBox = isoFile.movieBox + var userDataBox = Path.getPath(movieBox, UserDataBox.TYPE) + if (userDataBox == null) { + userDataBox = UserDataBox() + movieBox.addBox(userDataBox) + } + + userDataBox.addBox(AppleGPSCoordinatesBox().apply { + value = locationIso6709 + }) + } + + fun updateXmp(isoFile: IsoFile, xmp: String?) { + val xmpBox = isoFile.xmpBox + if (xmp != null) { + val xmpData = xmp.toByteArray(Charsets.UTF_8) + if (xmpBox == null) { + isoFile.addBox(UserBox(XMP.mp4Uuid).apply { + data = xmpData + }) + } else { + xmpBox.data = xmpData + } + } else if (xmpBox != null) { + isoFile.removeBox(xmpBox) + } + } + + fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List> { + // we can skip uninteresting boxes with a seekable data source + val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri") + pfd.use { + FileInputStream(it.fileDescriptor).use { stream -> + stream.channel.use { channel -> + val boxParser = PropertyBoxParserImpl().apply { + skippingBoxes(MediaDataBox.TYPE) + } + // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` + IsoFile(channel, boxParser).use { isoFile -> + val lastContentBox = isoFile.boxes.reversed().firstOrNull { box -> + when { + box == isoFile.movieBox -> false + testXmpBox(box) -> false + box is FreeBox -> false + else -> true + } + } + lastContentBox ?: throw Exception("failed to find last context box") + val oldFileSize = isoFile.size + var appendOffset = (isoFile.getBoxOffset { box -> box == lastContentBox })!! + lastContentBox.size + + val edits = arrayListOf>() + fun addFreeBoxEdit(offset: Long, size: Long) = edits.add(Pair(offset, FreeBox(size.toInt() - 8).toBytes())) + + // replace existing movie box by a free box + isoFile.getBoxOffset { box -> box.type == MovieBox.TYPE }?.let { offset -> + addFreeBoxEdit(offset, isoFile.movieBox.size) + } + + // replace existing XMP box by a free box + isoFile.getBoxOffset { box -> testXmpBox(box) }?.let { offset -> + addFreeBoxEdit(offset, isoFile.xmpBox!!.size) + } + + modifier(isoFile) + + // write edited movie box + val movieBoxBytes = isoFile.movieBox.toBytes() + edits.removeAll { (offset, _) -> offset == appendOffset } + edits.add(Pair(appendOffset, movieBoxBytes)) + appendOffset += movieBoxBytes.size + + // write edited XMP box + isoFile.xmpBox?.let { box -> + edits.removeAll { (offset, _) -> offset == appendOffset } + edits.add(Pair(appendOffset, box.toBytes())) + appendOffset += box.size + } + + // write trailing free box instead of truncating + val trailing = oldFileSize - appendOffset + if (trailing > 0) { + addFreeBoxEdit(appendOffset, trailing) + } + + return edits + } + } + } + } + } + + // according to XMP Specification Part 3 - Storage in Files, + // XMP is embedded in MPEG-4 files using a top-level UUID box + private fun testXmpBox(box: Box): Boolean { + if (box is UserBox) { + if (!box.isParsed) { + box.parseDetails() + } + return box.userType.contentEquals(XMP.mp4Uuid) + } + return false + } + + // extensions + + private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? { + var offset = 0L + for (box in boxes) { + if (test(box)) { + return offset + } + offset += box.size + } + return null + } + + private val IsoFile.xmpBox: UserBox? + get() = boxes.firstOrNull { testXmpBox(it) } as UserBox? + + fun Container.processBoxes(clazz: Class, recursive: Boolean, apply: (box: T, parent: Container) -> Unit) { + // use a copy, in case box processing removes boxes + for (box in ArrayList(boxes)) { + if (clazz.isInstance(box)) { + @Suppress("unchecked_cast") + apply(box as T, this) + } + if (recursive && box is Container) { + box.processBoxes(clazz, true, apply) + } + } + } + + private fun Container.removeBoxes(clazz: Class, recursive: Boolean) { + processBoxes(clazz, recursive) { box, parent -> parent.removeBox(box) } + } + + private fun Container.removeBox(box: Box) { + boxes = boxes.apply { remove(box) } + } + + fun Container.dumpBoxes(sb: StringBuilder, indent: Int = 0) { + for (box in boxes) { + val boxType = box.type + try { + if (box is AbstractBox && !box.isParsed) { + box.parseDetails() + } + when (box) { + is BasicContainer -> { + sb.appendLine("${"\t".repeat(indent)}[$boxType] ${box.javaClass.simpleName}") + box.dumpBoxes(sb, indent + 1) + } + is UserBox -> { + val userTypeHex = box.userType.joinToString("") { "%02x".format(it) } + sb.appendLine("${"\t".repeat(indent)}[$boxType] userType=$userTypeHex $box") + } + else -> sb.appendLine("${"\t".repeat(indent)}[$boxType] $box") + } + } catch (e: Exception) { + sb.appendLine("${"\t".repeat(indent)}failed to access box type=$boxType exception=${e.message}") + } + } + } + + fun Box.toBytes(): ByteArray { + val stream = ByteArrayOutputStream(size.toInt()) + Channels.newChannel(stream).use { getBox(it) } + return stream.toByteArray() + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 8152ba065..711d62cad 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -204,7 +204,7 @@ object MultiPage { Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e) } - XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp) + XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) return offsetFromEnd } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 24cb4ec40..55596ed03 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -10,16 +10,28 @@ import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMetaFactory import com.adobe.internal.xmp.properties.XMPProperty +import com.drew.metadata.Directory +import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes +import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes +import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils +import org.mp4parser.IsoFile +import org.mp4parser.PropertyBoxParserImpl +import org.mp4parser.boxes.UserBox +import org.mp4parser.boxes.iso14496.part12.MediaDataBox +import java.io.FileInputStream import java.util.* object XMP { private val LOG_TAG = LogUtils.createTag() + // BE7ACFCB 97A942E8 9C719994 91E3AFAC / BE7ACFCB-97A9-42E8-9C71-999491E3AFAC + val mp4Uuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte()) + // standard namespaces // cf com.adobe.internal.xmp.XMPConst private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/" @@ -94,7 +106,13 @@ object XMP { // as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images, // so we fall back to the native content resolver, if possible - fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) { + fun checkHeic( + context: Context, + mimeType: String, + uri: Uri, + foundXmp: Boolean, + processXmp: (xmpMeta: XMPMeta) -> Unit, + ) { if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { try { val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP) @@ -108,6 +126,43 @@ object XMP { } } + // as of `metadata-extractor` v2.18.0, processing large MP4 files may crash, + // so we fall back to parsing with `mp4parser` + fun checkMp4( + context: Context, + mimeType: String, + uri: Uri, + processDirs: (dirs: List<Directory>) -> Unit, + ) { + if (mimeType != MimeTypes.MP4) return + try { + // we can skip uninteresting boxes with a seekable data source + val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri") + pfd.use { + FileInputStream(it.fileDescriptor).use { stream -> + stream.channel.use { channel -> + val boxParser = PropertyBoxParserImpl().apply { + skippingBoxes(MediaDataBox.TYPE) + } + // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` + IsoFile(channel, boxParser).use { isoFile -> + isoFile.processBoxes(UserBox::class.java, true) { box, _ -> + val bytes = box.toBytes() + val payload = bytes.copyOfRange(8, bytes.size) + + val metadata = com.drew.metadata.Metadata() + SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null) + processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList()) + } + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get XMP by MP4 parser for mimeType=$mimeType uri=$uri", e) + } + } + // extensions fun XMPMeta.isMotionPhoto(): Boolean { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt index 35f3e0914..d052d9ff1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeMp4UuidBoxHandler.kt @@ -4,20 +4,17 @@ import com.drew.imaging.mp4.Mp4Handler import com.drew.metadata.Metadata import com.drew.metadata.mp4.Mp4Context import com.drew.metadata.mp4.media.Mp4UuidBoxHandler +import deckers.thibault.aves.metadata.XMP class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) { override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> { if (payload != null && payload.size >= 16) { val payloadUuid = payload.copyOfRange(0, 16) - if (payloadUuid.contentEquals(xmpUuid)) { + if (payloadUuid.contentEquals(XMP.mp4Uuid)) { SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory) return this } } return super.processBox(type, payload, boxSize, context) } - - companion object { - val xmpUuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte()) - } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 3503afe89..5a62d29e6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -22,6 +22,10 @@ import deckers.thibault.aves.decoder.SvgImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF +import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC +import deckers.thibault.aves.metadata.Metadata.TYPE_MP4 +import deckers.thibault.aves.metadata.Metadata.TYPE_XMP import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.model.AvesEntry @@ -37,10 +41,8 @@ import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isVideo -import java.io.ByteArrayInputStream -import java.io.File -import java.io.IOException -import java.io.OutputStream +import java.io.* +import java.nio.channels.Channels import java.util.* abstract class ImageProvider { @@ -350,6 +352,7 @@ abstract class ImageProvider { // copy the edited temporary file back to the original DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile) + editableFile.delete() } val fileName = targetDocFile.name @@ -457,11 +460,12 @@ abstract class ImageProvider { } // copy the edited temporary file back to the original - copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + editableFile.transferTo(outputStream(context, mimeType, uri, path)) if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } + editableFile.delete() } catch (e: IOException) { callback.onFailure(e) return false @@ -524,7 +528,7 @@ abstract class ImageProvider { iptc != null -> PixyMetaHelper.setIptc(input, output, iptc) canRemoveMetadata(mimeType) -> - PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC)) + PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_IPTC)) else -> { Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType") PixyMetaHelper.setIptc(input, output, null) @@ -539,11 +543,12 @@ abstract class ImageProvider { } // copy the edited temporary file back to the original - copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + editableFile.transferTo(outputStream(context, mimeType, uri, path)) if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } + editableFile.delete() } catch (e: IOException) { callback.onFailure(e) return false @@ -552,6 +557,60 @@ abstract class ImageProvider { return true } + private fun editMp4Metadata( + context: Context, + path: String, + uri: Uri, + mimeType: String, + callback: ImageOpCallback, + fields: Map<*, *> + ): Boolean { + if (mimeType != MimeTypes.MP4) { + callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) + return false + } + + try { + val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile -> + fields.forEach { kv -> + val tag = kv.key as String + val value = kv.value as String? + when (tag) { + "gpsCoordinates" -> Mp4ParserHelper.updateLocation(isoFile, value) + "xmp" -> Mp4ParserHelper.updateXmp(isoFile, value) + } + } + } + + val pfd = StorageUtils.openOutputFileDescriptor( + context = context, + mimeType = mimeType, + uri = uri, + path = path, + // do not truncate + mode = "w", + ) ?: throw Exception("failed to open file descriptor for uri=$uri path=$path") + pfd.use { + FileOutputStream(it.fileDescriptor).use { outputStream -> + outputStream.channel.use { outputChannel -> + edits.forEach { (offset, bytes) -> + bytes.inputStream().use { inputStream -> + Channels.newChannel(inputStream).use { inputChannel -> + outputChannel.transferFrom(inputChannel, offset, bytes.size.toLong()) + } + } + } + } + } + } + } catch (e: Exception) { + callback.onFailure(e) + return false + } + + return true + } + // provide `editCoreXmp` to modify existing core XMP, // or provide `coreXmp` and `extendedXmp` to set them private fun editXmp( @@ -571,41 +630,31 @@ abstract class ImageProvider { return false } + if (mimeType == MimeTypes.MP4) { + return editMp4Metadata( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + fields = mapOf("xmp" to coreXmp), + ) + } + val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val editableFile = File.createTempFile("aves", null).apply { deleteOnExit() try { - var editedXmpString = coreXmp - var editedExtendedXmp = extendedXmp - if (editCoreXmp != null) { - val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } - if (pixyXmp != null) { - editedXmpString = editCoreXmp(pixyXmp.xmpDocString()) - if (pixyXmp.hasExtendedXmp()) { - editedExtendedXmp = pixyXmp.extendedXmpDocString() - } - } - } - - outputStream().use { output -> - // reopen input to read from start - StorageUtils.openInputStream(context, uri)?.use { input -> - if (editedXmpString != null) { - if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) { - Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType") - PixyMetaHelper.setXmp(input, output, editedXmpString, null) - } else { - PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp) - } - } else if (canRemoveMetadata(mimeType)) { - PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP)) - } else { - Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType") - PixyMetaHelper.setXmp(input, output, null, null) - } - } - } + editXmpWithPixy( + context = context, + uri = uri, + mimeType = mimeType, + coreXmp = coreXmp, + extendedXmp = extendedXmp, + editCoreXmp = editCoreXmp, + editableFile = this + ) } catch (e: Exception) { callback.onFailure(e) return false @@ -614,11 +663,12 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + editableFile.transferTo(outputStream(context, mimeType, uri, path)) if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } + editableFile.delete() } catch (e: IOException) { callback.onFailure(e) return false @@ -627,6 +677,47 @@ abstract class ImageProvider { return true } + private fun editXmpWithPixy( + context: Context, + uri: Uri, + mimeType: String, + coreXmp: String?, + extendedXmp: String?, + editCoreXmp: ((xmp: String) -> String)?, + editableFile: File + ) { + var editedXmpString = coreXmp + var editedExtendedXmp = extendedXmp + if (editCoreXmp != null) { + val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } + if (pixyXmp != null) { + editedXmpString = editCoreXmp(pixyXmp.xmpDocString()) + if (pixyXmp.hasExtendedXmp()) { + editedExtendedXmp = pixyXmp.extendedXmpDocString() + } + } + } + + editableFile.outputStream().use { output -> + // reopen input to read from start + StorageUtils.openInputStream(context, uri)?.use { input -> + if (editedXmpString != null) { + if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) { + Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType") + PixyMetaHelper.setXmp(input, output, editedXmpString, null) + } else { + PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp) + } + } else if (canRemoveMetadata(mimeType)) { + PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_XMP)) + } else { + Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType") + PixyMetaHelper.setXmp(input, output, null, null) + } + } + } + } + // A few bytes are sometimes appended when writing to a document output stream. // In that case, we need to adjust the trailer video offset accordingly and rewrite the file. // returns whether the file at `path` is fine @@ -807,8 +898,8 @@ abstract class ImageProvider { autoCorrectTrailerOffset: Boolean, callback: ImageOpCallback, ) { - if (modifier.containsKey("exif")) { - val fields = modifier["exif"] as Map<*, *>? + if (modifier.containsKey(TYPE_EXIF)) { + val fields = modifier[TYPE_EXIF] as Map<*, *>? if (fields != null && fields.isNotEmpty()) { if (!editExif( context = context, @@ -825,7 +916,7 @@ abstract class ImageProvider { val value = kv.value if (value == null) { // remove attribute - exif.setAttribute(tag, value) + exif.setAttribute(tag, null) } else { when (tag) { ExifInterface.TAG_GPS_LATITUDE, @@ -864,8 +955,8 @@ abstract class ImageProvider { } } - if (modifier.containsKey("iptc")) { - val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>() + if (modifier.containsKey(TYPE_IPTC)) { + val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance<FieldMap>() if (!editIptc( context = context, path = path, @@ -878,8 +969,23 @@ abstract class ImageProvider { ) return } - if (modifier.containsKey("xmp")) { - val xmp = modifier["xmp"] as Map<*, *>? + if (modifier.containsKey(TYPE_MP4)) { + val fields = modifier[TYPE_MP4] as Map<*, *>? + if (fields != null && fields.isNotEmpty()) { + if (!editMp4Metadata( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + fields = fields, + ) + ) return + } + } + + if (modifier.containsKey(TYPE_XMP)) { + val xmp = modifier[TYPE_XMP] as Map<*, *>? if (xmp != null) { val coreXmp = xmp["xmp"] as String? val extendedXmp = xmp["extendedXmp"] as String? @@ -930,7 +1036,8 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + editableFile.transferTo(outputStream(context, mimeType, uri, path)) + editableFile.delete() } catch (e: IOException) { callback.onFailure(e) return @@ -973,11 +1080,12 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + editableFile.transferTo(outputStream(context, mimeType, uri, path)) - if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return } + editableFile.delete() } catch (e: IOException) { callback.onFailure(e) return @@ -987,21 +1095,20 @@ abstract class ImageProvider { scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } - private fun copyFileTo( + private fun outputStream( context: Context, mimeType: String, - sourceFile: File, - targetUri: Uri, - targetPath: String - ) { + uri: Uri, + path: String + ): OutputStream { // truncate is necessary when overwriting a longer file - val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { - StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri") + val mode = "wt" + return if (isMediaUriPermissionGranted(context, uri, mimeType)) { + StorageUtils.openOutputStream(context, mimeType, uri, mode) ?: throw Exception("failed to open output stream for uri=$uri") } else { - val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri") - context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri") + val documentUri = StorageUtils.getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri") + context.contentResolver.openOutputStream(documentUri, mode) ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$path, uri=$uri") } - sourceFile.transferTo(targetStream) } interface ImageOpCallback { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 6f7cf280e..af0e471c2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -104,28 +104,28 @@ object MimeTypes { else -> false } - // as of androidx.exifinterface:exifinterface:1.3.4 fun canEditExif(mimeType: String) = when (mimeType) { - JPEG, - PNG, - WEBP -> true + // as of androidx.exifinterface:exifinterface:1.3.4 + JPEG, PNG, WEBP -> true else -> false } - // as of latest PixyMeta fun canEditIptc(mimeType: String) = when (mimeType) { + // as of latest PixyMeta JPEG, TIFF -> true else -> false } - // as of latest PixyMeta fun canEditXmp(mimeType: String) = when (mimeType) { + // as of latest PixyMeta JPEG, TIFF, PNG, GIF -> true + // using `mp4parser` + MP4 -> true else -> false } - // as of latest PixyMeta fun canRemoveMetadata(mimeType: String) = when (mimeType) { + // as of latest PixyMeta JPEG, TIFF -> true else -> false } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index db1bd8e80..078fda691 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -10,6 +10,7 @@ import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.os.Environment +import android.os.ParcelFileDescriptor import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.MediaStore @@ -17,6 +18,7 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat +import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.utils.FileUtils.transferFrom import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo @@ -580,19 +582,47 @@ object StorageUtils { } catch (e: Exception) { // among various other exceptions, // opening a file marked pending and owned by another package throws an `IllegalStateException` - Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e) + Log.w(LOG_TAG, "failed to open input stream from effectiveUri=$effectiveUri for uri=$uri", e) null } } - fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? { + fun openOutputStream(context: Context, mimeType: String, uri: Uri, mode: String): OutputStream? { val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType) return try { context.contentResolver.openOutputStream(effectiveUri, mode) } catch (e: Exception) { // among various other exceptions, // opening a file marked pending and owned by another package throws an `IllegalStateException` - Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e) + Log.w(LOG_TAG, "failed to open output stream from effectiveUri=$effectiveUri for uri=$uri mode=$mode", e) + null + } + } + + fun openInputFileDescriptor(context: Context, uri: Uri): ParcelFileDescriptor? { + val effectiveUri = getOriginalUri(context, uri) + return try { + context.contentResolver.openFileDescriptor(effectiveUri, "r") + } catch (e: Exception) { + // among various other exceptions, + // opening a file marked pending and owned by another package throws an `IllegalStateException` + Log.w(LOG_TAG, "failed to open input file descriptor from effectiveUri=$effectiveUri for uri=$uri", e) + null + } + } + + fun openOutputFileDescriptor(context: Context, mimeType: String, uri: Uri, path: String, mode: String): ParcelFileDescriptor? { + val effectiveUri = if (ImageProvider.isMediaUriPermissionGranted(context, uri, mimeType)) { + getMediaStoreScopedStorageSafeUri(uri, mimeType) + } else { + getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri") + } + return try { + context.contentResolver.openFileDescriptor(effectiveUri, mode) + } catch (e: Exception) { + // among various other exceptions, + // opening a file marked pending and owned by another package throws an `IllegalStateException` + Log.w(LOG_TAG, "failed to open output file descriptor from effectiveUri=$effectiveUri for uri=$uri path=$path", e) null } } diff --git a/android/build.gradle b/android/build.gradle index 42c5dc7f7..eddb482b6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { maven { url 'https://developer.huawei.com/repo/' } } dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // GMS & Firebase Crashlytics (used by some flavors only) classpath 'com.google.gms:google-services:4.3.14' diff --git a/lib/model/entry.dart b/lib/model/entry.dart index a057462b0..3ee822edd 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -284,7 +284,7 @@ class AvesEntry { bool get canEditDate => canEdit && (canEditExif || canEditXmp); - bool get canEditLocation => canEdit && canEditExif; + bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4); bool get canEditTitleDescription => canEdit && canEditXmp; @@ -294,54 +294,13 @@ class AvesEntry { bool get canRotateAndFlip => canEdit && canEditExif; - // `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes, - // and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files. - // as of androidx.exifinterface:exifinterface:1.3.4 - bool get canEditExif { - switch (mimeType.toLowerCase()) { - case MimeTypes.jpeg: - case MimeTypes.png: - case MimeTypes.webp: - return true; - default: - return false; - } - } + bool get canEditExif => MimeTypes.canEditExif(mimeType); - // as of latest PixyMeta - bool get canEditIptc { - switch (mimeType.toLowerCase()) { - case MimeTypes.jpeg: - case MimeTypes.tiff: - return true; - default: - return false; - } - } + bool get canEditIptc => MimeTypes.canEditIptc(mimeType); - // as of latest PixyMeta - bool get canEditXmp { - switch (mimeType.toLowerCase()) { - case MimeTypes.gif: - case MimeTypes.jpeg: - case MimeTypes.png: - case MimeTypes.tiff: - return true; - default: - return false; - } - } + bool get canEditXmp => MimeTypes.canEditXmp(mimeType); - // as of latest PixyMeta - bool get canRemoveMetadata { - switch (mimeType.toLowerCase()) { - case MimeTypes.jpeg: - case MimeTypes.tiff: - return true; - default: - return false; - } - } + bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType); // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, // so it should be registered as width=1920, height=1080, orientation=90, diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 7692ef761..9593add22 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -7,11 +7,13 @@ import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/fields.dart'; import 'package:aves/ref/exif.dart'; import 'package:aves/ref/iptc.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/xmp.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/xmp_utils.dart'; import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; @@ -82,28 +84,63 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { Future<Set<EntryDataType>> editLocation(LatLng? latLng) async { final Set<EntryDataType> dataTypes = {}; + final Map<MetadataType, dynamic> metadata = {}; - await _missingDateCheckAndExifEdit(dataTypes); + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); - // clear every GPS field - final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null))); - // add latitude & longitude, if any - if (latLng != null) { - final latitude = latLng.latitude; - final longitude = latLng.longitude; - if (latitude != 0 && longitude != 0) { - exifFields.addAll({ - MetadataField.exifGpsLatitude: latitude.abs(), - MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth, - MetadataField.exifGpsLongitude: longitude.abs(), - MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest, + if (canEditExif) { + // clear every GPS field + final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null))); + // add latitude & longitude, if any + if (latLng != null) { + final latitude = latLng.latitude; + final longitude = latLng.longitude; + if (latitude != 0 && longitude != 0) { + exifFields.addAll({ + MetadataField.exifGpsLatitude: latitude.abs(), + MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth, + MetadataField.exifGpsLongitude: longitude.abs(), + MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest, + }); + } + } + metadata[MetadataType.exif] = Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value))); + + if (canEditXmp && missingDate != null) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + editCreateDateXmp(descriptions, missingDate); + return true; }); } } - final metadata = { - MetadataType.exif: Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))), - }; + if (mimeType == MimeTypes.mp4) { + final mp4Fields = <MetadataField, String?>{}; + + String? iso6709String; + if (latLng != null) { + final latitude = latLng.latitude; + final longitude = latLng.longitude; + if (latitude != 0 && longitude != 0) { + const locale = 'en_US'; + final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}'; + final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}'; + iso6709String = '$isoLat$isoLon/'; + } + } + mp4Fields[MetadataField.mp4GpsCoordinates] = iso6709String; + + if (missingDate != null) { + final xmpParts = await _editXmp((descriptions) { + editCreateDateXmp(descriptions, missingDate); + return true; + }); + mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey]; + } + + metadata[MetadataType.mp4] = Map<String, String?>.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value))); + } + final newFields = await metadataEditService.editMetadata(this, metadata); if (newFields.isNotEmpty) { dataTypes.addAll({ @@ -160,7 +197,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final description = fields[DescriptionField.description]; if (canEditExif && editDescription) { - metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description}; + metadata[MetadataType.exif] = {MetadataField.exifImageDescription.toPlatform!: description}; } if (canEditIptc) { @@ -480,10 +517,17 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } } + static const xmpCoreKey = 'xmp'; + static const xmpExtendedKey = 'extendedXmp'; + Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async { final xmp = await metadataFetchService.getXmp(this); - final xmpString = xmp?.xmpString; - final extendedXmpString = xmp?.extendedXmpString; + if (xmp == null) { + throw Exception('failed to get XMP'); + } + + final xmpString = xmp.xmpString; + final extendedXmpString = xmp.extendedXmpString; final editedXmpString = await XMP.edit( xmpString, @@ -493,8 +537,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString); return { - 'xmp': editedXmp.xmpString, - 'extendedXmp': editedXmp.extendedXmpString, + xmpCoreKey: editedXmp.xmpString, + xmpExtendedKey: editedXmp.extendedXmpString, }; } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index e35f8c743..dead66e4f 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -32,6 +32,8 @@ enum MetadataType { jpegAdobe, // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky jpegDucky, + // ISO User Data box content, etc. + mp4, // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ photoshopIrb, // XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform @@ -78,12 +80,39 @@ extension ExtraMetadataType on MetadataType { return 'Adobe JPEG'; case MetadataType.jpegDucky: return 'Ducky'; + case MetadataType.mp4: + return 'MP4'; case MetadataType.photoshopIrb: return 'Photoshop'; case MetadataType.xmp: return 'XMP'; } } + + String get toPlatform { + switch (this) { + case MetadataType.comment: + return 'comment'; + case MetadataType.exif: + return 'exif'; + case MetadataType.iccProfile: + return 'icc_profile'; + case MetadataType.iptc: + return 'iptc'; + case MetadataType.jfif: + return 'jfif'; + case MetadataType.jpegAdobe: + return 'jpeg_adobe'; + case MetadataType.jpegDucky: + return 'jpeg_ducky'; + case MetadataType.mp4: + return 'mp4'; + case MetadataType.photoshopIrb: + return 'photoshop_irb'; + case MetadataType.xmp: + return 'xmp'; + } + } } extension ExtraDateFieldSource on DateFieldSource { diff --git a/lib/model/metadata/fields.dart b/lib/model/metadata/fields.dart index 1d5b54c4c..8628717e9 100644 --- a/lib/model/metadata/fields.dart +++ b/lib/model/metadata/fields.dart @@ -37,6 +37,8 @@ enum MetadataField { exifGpsTrackRef, exifGpsVersionId, exifImageDescription, + mp4GpsCoordinates, + mp4Xmp, xmpXmpCreateDate, } @@ -117,12 +119,30 @@ extension ExtraMetadataField on MetadataField { case MetadataField.exifGpsVersionId: case MetadataField.exifImageDescription: return MetadataType.exif; + case MetadataField.mp4GpsCoordinates: + case MetadataField.mp4Xmp: + return MetadataType.mp4; case MetadataField.xmpXmpCreateDate: return MetadataType.xmp; } } - String? get exifInterfaceTag { + String? get toPlatform { + if (type == MetadataType.exif) { + return _toExifInterfaceTag(); + } else { + switch (this) { + case MetadataField.mp4GpsCoordinates: + return 'gpsCoordinates'; + case MetadataField.mp4Xmp: + return 'xmp'; + default: + return null; + } + } + } + + String? _toExifInterfaceTag() { switch (this) { case MetadataField.exifDate: return 'DateTime'; @@ -196,7 +216,7 @@ extension ExtraMetadataField on MetadataField { return 'GPSVersionID'; case MetadataField.exifImageDescription: return 'ImageDescription'; - case MetadataField.xmpXmpCreateDate: + default: return null; } } diff --git a/lib/model/settings/enums/display_refresh_rate_mode.dart b/lib/model/settings/enums/display_refresh_rate_mode.dart index 050c37ffd..37c1d76f2 100644 --- a/lib/model/settings/enums/display_refresh_rate_mode.dart +++ b/lib/model/settings/enums/display_refresh_rate_mode.dart @@ -22,7 +22,7 @@ extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode { if (!await windowService.isActivity()) return; final androidInfo = await DeviceInfoPlugin().androidInfo; - if ((androidInfo.version.sdkInt ?? 0) < 23) return; + if (androidInfo.version.sdkInt < 23) return; debugPrint('Apply display refresh rate: $name'); switch (this) { diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 669f59327..ef07f9959 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -136,4 +136,56 @@ class MimeTypes { } return null; } + + // `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes, + // and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files. + static bool canEditExif(String mimeType) { + switch (mimeType.toLowerCase()) { + // as of androidx.exifinterface:exifinterface:1.3.4 + case jpeg: + case png: + case webp: + return true; + default: + return false; + } + } + + static bool canEditIptc(String mimeType) { + switch (mimeType.toLowerCase()) { + // as of latest PixyMeta + case jpeg: + case tiff: + return true; + default: + return false; + } + } + + static bool canEditXmp(String mimeType) { + switch (mimeType.toLowerCase()) { + // as of latest PixyMeta + case gif: + case jpeg: + case png: + case tiff: + return true; + // using `mp4parser` + case mp4: + return true; + default: + return false; + } + } + + static bool canRemoveMetadata(String mimeType) { + switch (mimeType.toLowerCase()) { + // as of latest PixyMeta + case jpeg: + case tiff: + return true; + default: + return false; + } + } } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index e9fbbade8..ee55f8dca 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -12,8 +12,6 @@ abstract class AndroidAppService { Future<Uint8List> getAppIcon(String packageName, double size); - Future<String?> getAppInstaller(); - Future<bool> copyToClipboard(String uri, String? label); Future<bool> edit(String uri, String mimeType); @@ -73,16 +71,6 @@ class PlatformAndroidAppService implements AndroidAppService { return Uint8List(0); } - @override - Future<String?> getAppInstaller() async { - try { - return await _platform.invokeMethod('getAppInstaller'); - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return null; - } - @override Future<bool> copyToClipboard(String uri, String? label) async { try { diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index f657470fc..29d2b8627 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -146,6 +146,18 @@ class AndroidDebugService { return {}; } + static Future<String?> getMp4ParserDump(AvesEntry entry) async { + try { + return await _platform.invokeMethod('getMp4ParserDump', <String, dynamic>{ + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return null; + } + static Future<Map> getPixyMetadata(AvesEntry entry) async { try { // returns map with all data available from the `PixyMeta` library diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 448c60bca..1ab75766b 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -65,7 +65,7 @@ class PlatformMetadataEditService implements MetadataEditService { 'entry': entry.toPlatformEntryMap(), 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.exifInterfaceTag).whereNotNull().toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toPlatform).whereNotNull().toList(), }); if (result != null) return (result as Map).cast<String, dynamic>(); } on PlatformException catch (e, stack) { @@ -85,7 +85,7 @@ class PlatformMetadataEditService implements MetadataEditService { try { final result = await _platform.invokeMethod('editMetadata', <String, dynamic>{ 'entry': entry.toPlatformEntryMap(), - 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), + 'metadata': metadata.map((type, value) => MapEntry(type.toPlatform, value)), 'autoCorrectTrailerOffset': autoCorrectTrailerOffset, }); if (result != null) return (result as Map).cast<String, dynamic>(); @@ -117,7 +117,7 @@ class PlatformMetadataEditService implements MetadataEditService { try { final result = await _platform.invokeMethod('removeTypes', <String, dynamic>{ 'entry': entry.toPlatformEntryMap(), - 'types': types.map(_toPlatformMetadataType).toList(), + 'types': types.map((v) => v.toPlatform).toList(), }); if (result != null) return (result as Map).cast<String, dynamic>(); } on PlatformException catch (e, stack) { @@ -127,27 +127,4 @@ class PlatformMetadataEditService implements MetadataEditService { } return {}; } - - String _toPlatformMetadataType(MetadataType type) { - switch (type) { - case MetadataType.comment: - return 'comment'; - case MetadataType.exif: - return 'exif'; - case MetadataType.iccProfile: - return 'icc_profile'; - case MetadataType.iptc: - return 'iptc'; - case MetadataType.jfif: - return 'jfif'; - case MetadataType.jpegAdobe: - return 'jpeg_adobe'; - case MetadataType.jpegDucky: - return 'jpeg_ducky'; - case MetadataType.photoshopIrb: - return 'photoshop_irb'; - case MetadataType.xmp: - return 'xmp'; - } - } } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 041d41383..56907877c 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -259,7 +259,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, - 'field': field.exifInterfaceTag, + 'field': field.toPlatform, }); if (result is int) { return dateTimeFromMillis(result, isUtc: false); diff --git a/lib/services/metadata/xmp.dart b/lib/services/metadata/xmp.dart index a6be54191..e56adec46 100644 --- a/lib/services/metadata/xmp.dart +++ b/lib/services/metadata/xmp.dart @@ -15,10 +15,10 @@ class AvesXmp extends Equatable { this.extendedXmpString, }); - static AvesXmp? fromList(List<String> xmpStrings) { + static AvesXmp fromList(List<String> xmpStrings) { switch (xmpStrings.length) { case 0: - return null; + return const AvesXmp(xmpString: null); case 1: return AvesXmp(xmpString: xmpStrings.single); default: diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 3d11b7beb..d1ccb2b34 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -68,48 +68,59 @@ class Constants { static const String avesGithub = 'https://github.com/deckerst/aves'; + static const String apache2 = 'Apache License 2.0'; + static const String bsd2 = 'BSD 2-Clause "Simplified" License'; + static const String bsd3 = 'BSD 3-Clause "Revised" License'; + static const String eclipse1 = 'Eclipse Public License 1.0'; + static const String mit = 'MIT License'; + static const List<Dependency> androidDependencies = [ Dependency( name: 'AndroidX Core-KTX', - license: 'Apache 2.0', + license: apache2, licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt', sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx', ), Dependency( name: 'AndroidX Exifinterface', - license: 'Apache 2.0', + license: apache2, licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt', sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface', ), Dependency( name: 'AndroidSVG', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/BigBadaboom/androidsvg', ), Dependency( name: 'Android-TiffBitmapFactory (Aves fork)', - license: 'MIT', + license: mit, licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt', sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory', ), Dependency( name: 'CWAC-Document', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/commonsguy/cwac-document', ), Dependency( name: 'Glide', - license: 'Apache 2.0, BSD 2-Clause', + license: '$apache2, $bsd2', sourceUrl: 'https://github.com/bumptech/glide', ), Dependency( name: 'Metadata Extractor', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/drewnoakes/metadata-extractor', ), + Dependency( + name: 'MP4 Parser (Aves fork)', + license: apache2, + sourceUrl: 'https://github.com/deckerst/mp4parser', + ), Dependency( name: 'PixyMeta Android (Aves fork)', - license: 'Eclipse Public License 1.0', + license: eclipse1, sourceUrl: 'https://github.com/deckerst/pixymeta-android', ), ]; @@ -117,71 +128,71 @@ class Constants { static const List<Dependency> _flutterPluginsCommon = [ Dependency( name: 'Connectivity Plus', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus', ), Dependency( name: 'Device Info Plus', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus', ), Dependency( name: 'Dynamic Color', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/material-foundation/material-dynamic-color-flutter', ), Dependency( name: 'fijkplayer (Aves fork)', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/deckerst/fijkplayer', ), Dependency( name: 'Flutter Display Mode', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode', ), Dependency( name: 'Package Info Plus', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus', ), Dependency( name: 'Permission Handler', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', ), Dependency( name: 'Printing', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), Dependency( name: 'Screen Brightness', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/aaassseee/screen_brightness', ), Dependency( name: 'Shared Preferences', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences', ), Dependency( name: 'sqflite', - license: 'BSD 2-Clause', + license: bsd2, sourceUrl: 'https://github.com/tekartik/sqflite', ), Dependency( name: 'Streams Channel (Aves fork)', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/deckerst/aves_streams_channel', ), Dependency( name: 'URL Launcher', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher', ), @@ -190,12 +201,12 @@ class Constants { static const List<Dependency> _googleMobileServices = [ Dependency( name: 'Google API Availability', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability', ), Dependency( name: 'Google Maps for Flutter', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter', ), @@ -204,7 +215,7 @@ class Constants { static const List<Dependency> _huaweiMobileServices = [ Dependency( name: 'Huawei Mobile Services (Availability, Map)', - license: 'Apache 2.0', + license: apache2, licenseUrl: 'https://github.com/HMS-Core/hms-flutter-plugin/blob/master/LICENCE', sourceUrl: 'https://github.com/HMS-Core/hms-flutter-plugin', ), @@ -222,7 +233,7 @@ class Constants { ..._googleMobileServices, Dependency( name: 'FlutterFire (Core, Crashlytics)', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', ), ]; @@ -237,84 +248,84 @@ class Constants { static const List<Dependency> flutterPackages = [ Dependency( name: 'Charts', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/google/charts', ), Dependency( name: 'Custom rounded rectangle border', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border', ), Dependency( name: 'Decorated Icon', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', ), Dependency( name: 'Expansion Tile Card (Aves fork)', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/deckerst/expansion_tile_card', ), Dependency( name: 'FlexColorPicker', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/rydmike/flex_color_picker', ), Dependency( name: 'Flutter Highlight', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/git-touch/highlight', ), Dependency( name: 'Flutter Map', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/fleaflet/flutter_map', ), Dependency( name: 'Flutter Markdown', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE', sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown', ), Dependency( name: 'Flutter Staggered Animations', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', ), Dependency( name: 'Material Design Icons Flutter', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter', ), Dependency( name: 'Overlay Support', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/boyan01/overlay_support', ), Dependency( name: 'Palette Generator', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE', sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator', ), Dependency( name: 'Panorama (Aves fork)', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/zesage/panorama', ), Dependency( name: 'Percent Indicator', - license: 'BSD 2-Clause', + license: bsd2, sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator', ), Dependency( name: 'Provider', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/rrousselGit/provider', ), Dependency( name: 'Smooth Page Indicator', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/Milad-Akarie/smooth_page_indicator', ), ]; @@ -322,89 +333,89 @@ class Constants { static const List<Dependency> dartPackages = [ Dependency( name: 'Collection', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/dart-lang/collection', ), Dependency( name: 'Country Code', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/denixport/dart.country', ), Dependency( name: 'Equatable', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/felangel/equatable', ), Dependency( name: 'Event Bus', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/marcojakob/dart-event-bus', ), Dependency( name: 'Fluster', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/alfonsocejudo/fluster', ), Dependency( name: 'Flutter Lints', - license: 'BSD 3-Clause', + license: bsd3, licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_lints/LICENSE', sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_lints', ), Dependency( name: 'Get It', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/fluttercommunity/get_it', ), Dependency( name: 'Intl', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/dart-lang/intl', ), Dependency( name: 'LatLong2', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/jifalops/dart-latlong', ), Dependency( name: 'Material Color Utilities', - license: 'Apache 2.0', + license: apache2, licenseUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart/LICENSE', sourceUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart', ), Dependency( name: 'Path', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/dart-lang/path', ), Dependency( name: 'PDF for Dart and Flutter', - license: 'Apache 2.0', + license: apache2, sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), Dependency( name: 'Proj4dart', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/maRci002/proj4dart', ), Dependency( name: 'Stack Trace', - license: 'BSD 3-Clause', + license: bsd3, sourceUrl: 'https://github.com/dart-lang/stack_trace', ), Dependency( name: 'Transparent Image', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/brianegan/transparent_image', ), Dependency( name: 'Tuple', - license: 'BSD 2-Clause', + license: bsd2, sourceUrl: 'https://github.com/google/tuple.dart', ), Dependency( name: 'XML', - license: 'MIT', + license: mit, sourceUrl: 'https://github.com/renggli/dart-xml', ), ]; diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart index dadbfabc3..027bc904a 100644 --- a/lib/widgets/about/bug_report.dart +++ b/lib/widgets/about/bug_report.dart @@ -142,7 +142,6 @@ class _BugReportState extends State<BugReport> with FeedbackMixin { Future<String> _getInfo(BuildContext context) async { final packageInfo = await PackageInfo.fromPlatform(); final androidInfo = await DeviceInfoPlugin().androidInfo; - final installer = await androidAppService.getAppInstaller(); final flavor = context.read<AppFlavor>().toString().split('.')[1]; return [ 'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})', @@ -153,7 +152,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin { 'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}', 'System locales: ${WidgetsBinding.instance.window.locales.join(', ')}', 'Aves locale: ${settings.locale ?? 'system'} -> ${settings.appliedLocale}', - 'Installer: $installer', + 'Installer: ${packageInfo.installerStore}', ].join('\n'); } diff --git a/lib/widgets/viewer/debug/metadata.dart b/lib/widgets/viewer/debug/metadata.dart index e75435a61..831e0c3a4 100644 --- a/lib/widgets/viewer/debug/metadata.dart +++ b/lib/widgets/viewer/debug/metadata.dart @@ -22,7 +22,9 @@ class MetadataTab extends StatefulWidget { } class _MetadataTabState extends State<MetadataTab> { - late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader; + late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader; + late Future<Map> _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader; + late Future<String?> _mp4ParserDumpLoader; // MediaStore timestamp keys static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; @@ -42,6 +44,7 @@ class _MetadataTabState extends State<MetadataTab> { _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); + _mp4ParserDumpLoader = AndroidDebugService.getMp4ParserDump(entry); _pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry); _tiffStructureLoader = AndroidDebugService.getTiffStructure(entry); setState(() {}); @@ -85,7 +88,7 @@ class _MetadataTabState extends State<MetadataTab> { Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) { if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); return builderFromSnapshotData(context, snapshot.data!, title); } @@ -112,6 +115,27 @@ class _MetadataTabState extends State<MetadataTab> { future: _metadataExtractorLoader, builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'), ), + FutureBuilder<String?>( + future: _mp4ParserDumpLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); + final data = snapshot.data?.trim(); + return AvesExpansionTile( + title: 'MP4 Parser', + children: [ + if (data != null && data.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text(data), + ), + ) + ], + ); + }, + ), FutureBuilder<Map>( future: _pixyMetaLoader, builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'), @@ -121,7 +145,7 @@ class _MetadataTabState extends State<MetadataTab> { future: _tiffStructureLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: snapshot.data!.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(), diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/misc.dart b/lib/widgets/viewer/info/metadata/xmp_ns/misc.dart index 90b07db8f..95e216007 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/misc.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/misc.dart @@ -91,6 +91,12 @@ class XmpMMNamespace extends XmpNamespace { XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')), XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')), XmpCardData(RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)')), - XmpCardData(RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)')), + XmpCardData( + RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)'), + cards: [ + XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')), + XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')), + ], + ), ]; } diff --git a/plugins/aves_services_google/lib/aves_services_platform.dart b/plugins/aves_services_google/lib/aves_services_platform.dart index 4c8f2ef0d..92b643e11 100644 --- a/plugins/aves_services_google/lib/aves_services_platform.dart +++ b/plugins/aves_services_google/lib/aves_services_platform.dart @@ -25,7 +25,7 @@ class PlatformMobileServices extends MobileServices { // cf https://github.com/flutter/flutter/issues/23728 // as of google_maps_flutter v2.1.5, Flutter v3.0.1 makes the map hide overlay widgets on API <=22 final androidInfo = await DeviceInfoPlugin().androidInfo; - _canRenderMaps = (androidInfo.version.sdkInt ?? 0) >= 21; + _canRenderMaps = androidInfo.version.sdkInt >= 21; if (_canRenderMaps) { final mapsImplementation = GoogleMapsFlutterPlatform.instance; if (mapsImplementation is GoogleMapsFlutterAndroid) { diff --git a/pubspec.lock b/pubspec.lock index 743fc4ada..dc2df58ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -147,41 +147,13 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "2.3.9" - connectivity_plus_linux: - dependency: transitive - description: - name: connectivity_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - connectivity_plus_macos: - dependency: transitive - description: - name: connectivity_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.6" + version: "3.0.0" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.2.1" - connectivity_plus_web: - dependency: transitive - description: - name: connectivity_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.5" - connectivity_plus_windows: - dependency: transitive - description: - name: connectivity_plus_windows - url: "https://pub.dartlang.org" - source: hosted version: "1.2.2" convert: dependency: transitive @@ -189,7 +161,7 @@ packages: name: convert url: "https://pub.dartlang.org" source: hosted - version: "3.0.2" + version: "3.1.1" country_code: dependency: "direct main" description: @@ -238,42 +210,14 @@ packages: name: device_info_plus url: "https://pub.dartlang.org" source: hosted - version: "5.0.5" - device_info_plus_linux: - dependency: transitive - description: - name: device_info_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.2" - device_info_plus_macos: - dependency: transitive - description: - name: device_info_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.2" + version: "7.0.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" - device_info_plus_web: - dependency: transitive - description: - name: device_info_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.2" - device_info_plus_windows: - dependency: transitive - description: - name: device_info_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.2" + version: "6.0.0" dynamic_color: dependency: "direct main" description: @@ -361,14 +305,14 @@ packages: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.8.13" + version: "2.9.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.2.19" + version: "3.3.0" flex_color_picker: dependency: "direct main" description: @@ -559,14 +503,14 @@ packages: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" + version: "4.0.2" image: dependency: transitive description: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "3.2.2" intl: dependency: "direct main" description: @@ -715,42 +659,14 @@ packages: name: package_info_plus url: "https://pub.dartlang.org" source: hosted - version: "1.4.3+1" - package_info_plus_linux: - dependency: transitive - description: - name: package_info_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - package_info_plus_macos: - dependency: transitive - description: - name: package_info_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" + version: "3.0.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" - package_info_plus_web: - dependency: transitive - description: - name: package_info_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.6" - package_info_plus_windows: - dependency: transitive - description: - name: package_info_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" + version: "2.0.0" palette_generator: dependency: "direct main" description: @@ -824,21 +740,19 @@ packages: source: hosted version: "10.1.0" permission_handler_android: - dependency: "direct overridden" + dependency: transitive description: - path: permission_handler_android - ref: HEAD - resolved-ref: "279cf44656272c6b89c73b16097108f3c973c31f" - url: "https://github.com/deckerst/flutter-permission-handler" - source: git - version: "9.0.2+1" + name: permission_handler_android + url: "https://pub.dartlang.org" + source: hosted + version: "10.2.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple url: "https://pub.dartlang.org" source: hosted - version: "9.0.6" + version: "9.0.7" permission_handler_platform_interface: dependency: transitive description: @@ -852,7 +766,7 @@ packages: name: permission_handler_windows url: "https://pub.dartlang.org" source: hosted - version: "0.1.1" + version: "0.1.2" petitparser: dependency: transitive description: @@ -922,14 +836,14 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.3" + version: "6.0.4" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.2" qr: dependency: transitive description: @@ -992,7 +906,7 @@ packages: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "2.0.14" shared_preferences_ios: dependency: transitive description: @@ -1293,7 +1207,7 @@ packages: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "1.0.2" web_socket_channel: dependency: transitive description: @@ -1321,7 +1235,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 40f1c27eb..825f08131 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,14 +85,6 @@ dependencies: url_launcher: xml: -dependency_overrides: - # TODO TLAD as of 2022/10/09, latest version (v10.1.0) does not support Android 13 storage permissions - # `permission_handler_platform_interface` v3.9.0 added support for them but it is not effective - permission_handler_android: - git: - url: https://github.com/deckerst/flutter-permission-handler - path: permission_handler_android - dev_dependencies: flutter_test: sdk: flutter