From cd2811be0262901c1ce6c236004f2d423253320d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 8 Jan 2021 11:28:14 +0900 Subject: [PATCH] collection: identify multipage TIFF, multitrack HEIC/HEIF --- .../aves/channel/calls/MetadataHandler.kt | 62 +++++++++++++++---- .../deckers/thibault/aves/utils/MimeTypes.kt | 4 +- lib/model/image_entry.dart | 2 + lib/model/image_metadata.dart | 10 ++- lib/theme/icons.dart | 1 + lib/widgets/collection/thumbnail/overlay.dart | 1 + lib/widgets/common/identity/aves_icons.dart | 15 +++++ 7 files changed, 79 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ad72db45f..689391991 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -61,6 +61,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.File import java.util.* import kotlin.math.roundToLong @@ -230,19 +231,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return } - val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes)) + val metadataMap = HashMap() + getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap) if (isMultimedia(mimeType)) { - metadataMap.putAll(getMultimediaCatalogMetadataByMediaMetadataRetriever(uri)) + getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap) } // report success even when empty result.success(metadataMap) } - private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map { - val metadataMap = HashMap() - - var flags = 0 + private fun getCatalogMetadataByMetadataExtractor( + uri: Uri, + mimeType: String, + path: String?, + sizeBytes: Long?, + metadataMap: HashMap, + ) { + var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var foundExif = false if (isSupportedByMetadataExtractor(mimeType)) { @@ -390,13 +396,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e) } } + + if (mimeType == MimeTypes.TIFF && getTiffDirCount(uri) > 1) flags = flags or MASK_IS_MULTIPAGE + metadataMap[KEY_FLAGS] = flags - return metadataMap } - private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map { - val metadataMap = HashMap() - val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap + private fun getMultimediaCatalogMetadataByMediaMetadataRetriever( + uri: Uri, + mimeType: String, + metadataMap: HashMap, + ) { + val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return + + var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int try { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { @@ -417,13 +430,20 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } } + + if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) { + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) { + if (it > 1) flags = flags or MASK_IS_MULTIPAGE + } + } + + metadataMap[KEY_FLAGS] = flags } catch (e: Exception) { Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e) } finally { // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs retriever.release() } - return metadataMap } private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { @@ -622,6 +642,25 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) } + private fun getTiffDirCount(uri: Uri): Int { + var dirCount = 1 + try { + val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + Log.w(LOG_TAG, "failed to get file descriptor for uri=$uri") + } else { + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + dirCount = options.outDirectoryCount + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get TIFF dir count for uri=$uri", e) + } + return dirCount + } + companion object { private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" @@ -640,6 +679,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val MASK_IS_FLIPPED = 1 shl 1 private const val MASK_IS_GEOTIFF = 1 shl 2 private const val MASK_IS_360 = 1 shl 3 + private const val MASK_IS_MULTIPAGE = 1 shl 4 private const val XMP_SUBJECTS_SEPARATOR = ";" // overlay metadata 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 c65025e0e..9f7766d36 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 @@ -8,8 +8,8 @@ object MimeTypes { // generic raster private const val BMP = "image/bmp" const val GIF = "image/gif" - private const val HEIC = "image/heic" - private const val HEIF = "image/heif" + const val HEIC = "image/heic" + const val HEIF = "image/heif" private const val ICO = "image/x-icon" private const val JPEG = "image/jpeg" private const val PNG = "image/png" diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 4cd6f3cd3..87a11cc9e 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -211,6 +211,8 @@ class ImageEntry { bool get is360 => _catalogMetadata?.is360 ?? false; + bool get isMultipage => _catalogMetadata?.isMultipage ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 04086161c..ccbbad3c0 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -29,7 +29,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis; - final bool isAnimated, isGeotiff, is360; + final bool isAnimated, isGeotiff, is360, isMultipage; bool isFlipped; int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; @@ -41,6 +41,7 @@ class CatalogMetadata { static const _isFlippedMask = 1 << 1; static const _isGeotiffMask = 1 << 2; static const _is360Mask = 1 << 3; + static const _isMultipageMask = 1 << 4; CatalogMetadata({ this.contentId, @@ -50,6 +51,7 @@ class CatalogMetadata { this.isFlipped = false, this.isGeotiff = false, this.is360 = false, + this.isMultipage = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -76,6 +78,7 @@ class CatalogMetadata { isFlipped: isFlipped, isGeotiff: isGeotiff, is360: is360, + isMultipage: isMultipage, rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, @@ -94,6 +97,7 @@ class CatalogMetadata { isFlipped: flags & _isFlippedMask != 0, isGeotiff: flags & _isGeotiffMask != 0, is360: flags & _is360Mask != 0, + isMultipage: flags & _isMultipageMask != 0, // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', @@ -107,7 +111,7 @@ class CatalogMetadata { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0), + 'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultipage ? _isMultipageMask : 0), 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -116,7 +120,7 @@ class CatalogMetadata { }; @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultipage=$isMultipage, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } class OverlayMetadata { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index a50c1c169..be3ba1ed9 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -64,6 +64,7 @@ class AIcons { // thumbnail overlay static const IconData animated = Icons.slideshow; static const IconData geo = Icons.language_outlined; + static const IconData multipage = Icons.burst_mode_outlined; static const IconData play = Icons.play_circle_outline; static const IconData threesixty = Icons.threesixty_outlined; static const IconData selected = Icons.check_circle_outline; diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 29b464c74..771faf505 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -36,6 +36,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { children: [ if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize), if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize), + if (entry.isMultipage) MultipageIcon(iconSize: iconSize), if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize), if (entry.isAnimated) AnimatedImageIcon(iconSize: iconSize) diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 4ac616a21..adbf3fa8e 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -102,6 +102,21 @@ class RawIcon extends StatelessWidget { } } +class MultipageIcon extends StatelessWidget { + final double iconSize; + + const MultipageIcon({Key key, this.iconSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return OverlayIcon( + icon: AIcons.multipage, + size: iconSize, + iconScale: .8, + ); + } +} + class OverlayIcon extends StatelessWidget { final IconData icon; final double size;