From 25ebc95d4205c404f8bd809d75ce5d49f1d4e83d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 11 Dec 2020 13:01:21 +0900 Subject: [PATCH] improved handling of large TIFF files --- .../aves/channel/calls/DebugHandler.kt | 11 ++--- .../aves/channel/calls/MetadataHandler.kt | 40 ++++++++--------- .../thibault/aves/metadata/Metadata.kt | 44 +++++++++++++++++++ .../thibault/aves/model/SourceImageEntry.kt | 11 ++--- .../deckers/thibault/aves/utils/MimeTypes.kt | 13 +----- 5 files changed, 78 insertions(+), 41 deletions(-) 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 a528bfb98..171a5469f 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 @@ -14,6 +14,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper +import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.isImage @@ -145,9 +146,9 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - if (isSupportedByExifInterface(mimeType, sizeBytes, strict = false)) { + if (isSupportedByExifInterface(mimeType, strict = false)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { metadataMap[tag] = exif.getAttribute(tag) @@ -197,10 +198,10 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } val metadataMap = HashMap() - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir -> if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) 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 5a5e254c0..fa97f7599 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 @@ -92,10 +92,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { var foundExif = false var foundXmp = false - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) @@ -149,10 +149,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { + if (!foundExif && isSupportedByExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) val allTags = describeAll(exif).toMutableMap() if (foundXmp) { @@ -238,10 +238,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { var flags = 0 var foundExif = false - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) // File type @@ -358,10 +358,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { + if (!foundExif && isSupportedByExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { @@ -448,10 +448,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } var foundExif = false - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } @@ -467,10 +467,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } - if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) { + if (!foundExif && isSupportedByExifInterface(mimeType)) { // fallback to read EXIF via ExifInterface try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it } exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime) @@ -519,9 +519,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } val thumbnails = ArrayList() - if (isSupportedByExifInterface(mimeType, sizeBytes)) { + if (isSupportedByExifInterface(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) exif.thumbnailBitmap?.let { bitmap -> @@ -549,10 +549,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return } - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) // data can be large and stored in "Extended XMP", // which is returned as a second XMP directory val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) 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 22442e254..fb6750232 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 @@ -1,6 +1,12 @@ package deckers.thibault.aves.metadata +import android.content.Context +import android.net.Uri import androidx.exifinterface.media.ExifInterface +import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.StorageUtils +import java.io.File +import java.io.InputStream import java.text.ParseException import java.text.SimpleDateFormat import java.util.* @@ -88,4 +94,42 @@ object Metadata { } return dateMillis } + + // opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), + // so we define an arbitrary threshold to avoid a crash on launch. + // It is not clear whether it is because of the file itself or its metadata. + const val tiffSizeBytesMax = 100 * (1 shl 20) // MB + + // 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 = 5 * (1 shl 20) // MB + + private val previewFiles = HashMap() + + private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri { + if (mimeType != MimeTypes.TIFF) return uri + + if (sizeBytes != null && sizeBytes < tiffSizeBytesMax) return uri + + var previewFile = previewFiles[uri] + if (previewFile == null) { + previewFile = File.createTempFile("aves", null, context.cacheDir).apply { + deleteOnExit() + outputStream().use { outputStream -> + StorageUtils.openInputStream(context, uri)?.use { inputStream -> + val b = ByteArray(previewSize) + inputStream.read(b, 0, previewSize) + outputStream.write(b) + } + } + } + previewFiles[uri] = previewFile + } + return Uri.fromFile(previewFile) + } + + fun openSafeInputStream(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): InputStream? { + val safeUri = getSafeUri(context, uri, mimeType, sizeBytes) + return StorageUtils.openInputStream(context, safeUri) + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 87509a39e..304ff9acb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -20,6 +20,7 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMi import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString +import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt @@ -159,13 +160,13 @@ class SourceImageEntry { // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { // skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions - if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType, sizeBytes) + if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType) ) return try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) // do not switch on specific mime types, as the reported mime type could be wrong // (e.g. PNG registered as JPG) @@ -213,10 +214,10 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { - if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return + if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return try { - StorageUtils.openInputStream(context, uri)?.use { input -> + Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) foundExif = true exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } 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 d2d535349..c65025e0e 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 @@ -67,24 +67,15 @@ object MimeTypes { else -> false } - // opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), - // so we define an arbitrary threshold to avoid a crash on launch. - // It is not clear whether it is because of the file itself or its metadata. - private const val tiffSizeBytesMax = 128 * (1 shl 20) // MB - // as of `metadata-extractor` v2.14.0 - fun isSupportedByMetadataExtractor(mimeType: String, sizeBytes: Long?) = when (mimeType) { + fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { WBMP, MP2T, WEBM -> false - TIFF -> sizeBytes != null && sizeBytes < tiffSizeBytesMax else -> true } // as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports // no support for TIFF images, but it can actually open them (maybe other formats too) - fun isSupportedByExifInterface(mimeType: String, sizeBytes: Long?, strict: Boolean = true) = when (mimeType) { - TIFF -> sizeBytes != null && sizeBytes < tiffSizeBytesMax - else -> ExifInterface.isSupportedMimeType(mimeType) || !strict - } + fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict // Glide automatically applies EXIF orientation when decoding images of known formats // but we need to rotate the decoded bitmap for the other formats