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 21d4b4387..ba2e52ee4 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 @@ -52,10 +52,10 @@ import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isHeic -import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.isHeic +import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern import deckers.thibault.aves.utils.StorageUtils @@ -104,113 +104,125 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) val uuidDirCount = HashMap() - for (dir in metadata.directories.filter { + val dirByName = metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory && it !is AviDirectory - }) { - // directory name - var dirName = dir.name - if (dir is Mp4UuidBoxDirectory) { - val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') - dirName += " $uuid" - - val count = uuidDirCount[uuid] ?: 0 - uuidDirCount[uuid] = count + 1 - if (count > 0) { - dirName += " ($count)" - } - } + }.groupBy { dir -> dir.name } + for (dirEntry in dirByName) { + val baseDirName = dirEntry.key // exclude directories known to be redundant with info derived on the Dart side // they are excluded by name instead of runtime type because excluding `Mp4Directory` // would also exclude derived directories, such as `Mp4UuidBoxDirectory` - if (allMetadataRedundantDirNames.contains(dirName)) continue + if (allMetadataRedundantDirNames.contains(baseDirName)) continue - // optional parent to distinguish child directories of the same type - dir.parent?.name?.let { dirName = "$it/$dirName" } + val sameNameDirs = dirEntry.value + val sameNameDirCount = sameNameDirs.size + for (dirIndex in 0 until sameNameDirCount) { + val dir = sameNameDirs[dirIndex] - val dirMap = metadataMap[dirName] ?: HashMap() - metadataMap[dirName] = dirMap + // directory name + var thisDirName = baseDirName + if (dir is Mp4UuidBoxDirectory) { + val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-') + thisDirName += " $uuid" - // tags - val tags = dir.tags - if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { - fun tagMapper(it: Tag): Pair { - val name = if (it.hasTagName()) { - it.tagName + val count = uuidDirCount[uuid] ?: 0 + uuidDirCount[uuid] = count + 1 + if (count > 0) { + thisDirName += " ($count)" + } + } else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) { + // optional count for multiple directories of the same type + thisDirName = "$thisDirName[${dirIndex + 1}]" + } + + // optional parent to distinguish child directories of the same type + dir.parent?.name?.let { thisDirName = "$it/$thisDirName" } + + val dirMap = metadataMap[thisDirName] ?: HashMap() + metadataMap[thisDirName] = dirMap + + // tags + val tags = dir.tags + if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { + fun tagMapper(it: Tag): Pair { + val name = if (it.hasTagName()) { + it.tagName + } else { + TiffTags.getTagName(it.tagType) ?: it.tagName + } + return Pair(name, it.description) + } + + if (dir is ExifIFD0Directory && dir.isGeoTiff()) { + // split GeoTIFF tags in their own directory + val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } + metadataMap["GeoTIFF"] = HashMap().apply { + byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + } + byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } } else { - TiffTags.getTagName(it.tagType) ?: it.tagName + dirMap.putAll(tags.map { tagMapper(it) }) } - return Pair(name, it.description) - } - - if (dir is ExifIFD0Directory && dir.isGeoTiff()) { - // split GeoTIFF tags in their own directory - val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } - metadataMap["GeoTIFF"] = HashMap().apply { - byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } - } - byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } } else { - dirMap.putAll(tags.map { tagMapper(it) }) + dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } - } else { - dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) - } - if (dir is XmpDirectory) { - try { - for (prop in dir.xmpMeta) { - if (prop is XMPPropertyInfo) { - val path = prop.path - if (path?.isNotEmpty() == true) { - val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value - if (value?.isNotEmpty() == true) { - dirMap[path] = value + if (dir is XmpDirectory) { + try { + for (prop in dir.xmpMeta) { + if (prop is XMPPropertyInfo) { + val path = prop.path + if (path?.isNotEmpty() == true) { + val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value + if (value?.isNotEmpty() == true) { + dirMap[path] = value + } } } } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) + // remove this stat as it is not actual XMP data + dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) } - // remove this stat as it is not actual XMP data - dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) - } - 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(dirName) - } - QuickTimeMetadata.PROF_UUID -> { - // redundant with info derived on the Dart side - metadataMap.remove(dirName) - } - QuickTimeMetadata.USMT_UUID -> { - val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) - val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) - if (blocks.isNotEmpty()) { - metadataMap.remove(dirName) - dirName = "QuickTime User Media" - val usmt = metadataMap[dirName] ?: HashMap() - metadataMap[dirName] = usmt + 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 + 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})" + var i = 0 + while (usmt.containsKey(key)) { + key = it.type + " (${++i})" + } + if (language != "und") { + value += " ($language)" + } + usmt[key] = value } - if (language != "und") { - value += " ($language)" - } - usmt[key] = value } } } @@ -767,6 +779,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "QuickTime Sound", "QuickTime Video", ) + private val allMetadataMergeableDirNames = setOf( + "Exif SubIFD", + "GIF Control", + "GIF Image", + "HEIF", + "ICC Profile", + "IPTC", + "WebP", + "XMP", + ) // catalog metadata private const val KEY_MIME_TYPE = "mimeType" diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 72a1c0fa8..5a9702eb7 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -40,9 +40,9 @@ class _MetadataSectionSliverState extends State { ValueNotifier> get metadataNotifier => widget.metadataNotifier; - // directory names may contain the name of their parent directory - // if so, they are separated by this character - static const parentChildSeparator = '/'; + // directory names may contain the name of their parent directory (as prefix + '/') + // directory names may contain an index (as suffix in '[]') + static final directoryNamePattern = RegExp(r'^((?.*?)/)?(?.*?)(\[(?\d+)\])?$'); @override void initState() { @@ -138,14 +138,27 @@ class _MetadataSectionSliverState extends State { var directoryName = dirKV.key as String; String? parent; - final parts = directoryName.split(parentChildSeparator); - if (parts.length > 1) { - parent = parts[0]; - directoryName = parts[1]; + int? index; + final match = directoryNamePattern.firstMatch(directoryName); + if (match != null) { + parent = match.namedGroup('parent'); + final nameMatch = match.namedGroup('name'); + if (nameMatch != null) { + directoryName = nameMatch; + } + final indexMatch = match.namedGroup('index'); + if (indexMatch != null) { + index = int.tryParse(indexMatch); + } } final rawTags = dirKV.value as Map; - return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags)); + return MetadataDirectory( + directoryName, + _toSortedTags(rawTags), + parent: parent, + index: index, + ); }).toList(); if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) { @@ -157,6 +170,9 @@ class _MetadataSectionSliverState extends State { if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { title = '${dir.parent}/$title'; } + if (dir.index != null) { + title += ' ${dir.index}'; + } return MapEntry(title, dir); }).toList() ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); @@ -171,7 +187,7 @@ class _MetadataSectionSliverState extends State { final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo); if (formattedMediaTags.isNotEmpty) { // overwrite generic directory found from the platform side - directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, null, _toSortedTags(formattedMediaTags))); + directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags))); } if (mediaInfo.containsKey(Keys.streams)) { @@ -210,7 +226,7 @@ class _MetadataSectionSliverState extends State { final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); if (formattedStreamTags.isNotEmpty) { final color = stringToColor(typeText); - directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color)); + directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color)); } } } @@ -232,7 +248,7 @@ class _MetadataSectionSliverState extends State { final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase); return MapEntry(key, '$count items: ${names.join(', ')}'); }); - directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags))); + directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags))); } } } @@ -254,6 +270,7 @@ class MetadataDirectory { final String name; final Color? color; final String? parent; + final int? index; final SplayTreeMap allTags; final SplayTreeMap tags; @@ -265,14 +282,22 @@ class MetadataDirectory { const MetadataDirectory( this.name, - this.parent, this.allTags, { SplayTreeMap? tags, this.color, + this.parent, + this.index, }) : tags = tags ?? allTags; MetadataDirectory filterKeys(bool Function(String key) testKey) { final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); - return MetadataDirectory(name, parent, tags, tags: filteredTags, color: color); + return MetadataDirectory( + name, + tags, + tags: filteredTags, + color: color, + parent: parent, + index: index, + ); } }