guard against large tiff

This commit is contained in:
Thibault Deckers 2020-11-30 19:23:27 +09:00
parent 8c5a600151
commit 0d946b5a43
8 changed files with 142 additions and 78 deletions

View file

@ -52,6 +52,7 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
@ -86,6 +87,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
return
@ -95,10 +97,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var foundExif = false
var foundXmp = false
if (isSupportedByMetadataExtractor(mimeType)) {
if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
@ -138,7 +140,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
@ -192,12 +194,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val path = call.argument<String>("path")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
}
@ -213,15 +216,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// - XMP / dc:title
// - XMP / dc:description
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map<String, Any> {
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?, sizeBytes: Long?): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
// File type
@ -311,7 +314,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
@ -371,6 +374,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return
@ -396,10 +400,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
@ -415,7 +419,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
if (!foundExif) {
if (!foundExif && isSupportedByExifInterface(mimeType, sizeBytes)) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
@ -488,26 +492,31 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
val metadataMap = HashMap<String, String?>()
if (isSupportedByExifInterface(mimeType, sizeBytes, strict = false)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
}
result.success(metadataMap)
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
return
}
}
result.success(metadataMap)
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
@ -563,46 +572,45 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.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)
} else ""
}
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
} else ""
}
for (dir in metadata.directories) {
val dirName = dir.name ?: ""
var index = 0
while (metadataMap.containsKey("$dirName ($index)")) index++
var value = "${dir.tagCount} tags"
dir.parent?.let { value += ", parent: ${it.name}" }
metadataMap["$dirName ($index)"] = value
if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
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)
} else ""
}
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
} else ""
}
for (dir in metadata.directories) {
val dirName = dir.name ?: ""
var index = 0
while (metadataMap.containsKey("$dirName ($index)")) index++
var value = "${dir.tagCount} tags"
dir.parent?.let { value += ", parent: ${it.name}" }
metadataMap["$dirName ($index)"] = value
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getMetadataExtractorSummary-failure", "failed to get metadata for uri=$uri", null)
}
result.success(metadataMap)
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
@ -628,26 +636,30 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
if (isSupportedByExifInterface(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
result.success(thumbnails)
}
@ -655,16 +667,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) {
if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) {
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {

View file

@ -27,6 +27,7 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
class SourceImageEntry {
@ -129,7 +130,10 @@ class SourceImageEntry {
fillByExifInterface(context)
}
if (!isSized) {
fillByBitmapDecode(context)
when (sourceMimeType) {
MimeTypes.TIFF -> fillByTiffDecode(context)
else -> fillByBitmapDecode(context)
}
}
return this
}
@ -155,11 +159,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) || MimeTypes.isRaw(sourceMimeType)) return
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType, sizeBytes)
|| MimeTypes.isRaw(sourceMimeType)
) return
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1)
// do not switch on specific mime types, as the reported mime type could be wrong
// (e.g. PNG registered as JPG)
@ -207,7 +213,7 @@ class SourceImageEntry {
// finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) {
if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return;
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return;
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
@ -240,6 +246,22 @@ class SourceImageEntry {
}
}
private fun fillByTiffDecode(context: Context) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
width = options.outWidth
height = options.outHeight
}
} catch (e: Exception) {
// ignore
}
}
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
object MimeTypes {
private const val IMAGE = "image"
@ -65,12 +67,25 @@ object MimeTypes {
else -> false
}
// as of metadata-extractor v2.14.0
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
// 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) {
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
}
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide:

View file

@ -173,7 +173,7 @@ class ImageEntry {
bool get isSvg => mimeType == MimeTypes.svg;
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].contains(mimeType) || isRaw;
// Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
// but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,

View file

@ -1,6 +1,7 @@
import 'package:flutter/painting.dart';
class BrandColors {
static const Color adobeAfterEffects = Color(0xFF9A9AFF);
static const Color adobeIllustrator = Color(0xFFFF9B00);
static const Color adobePhotoshop = Color(0xFF2DAAFF);
static const Color android = Color(0xFF3DDC84);
@ -9,6 +10,8 @@ class BrandColors {
static Color get(String text) {
if (text != null) {
switch (text.toLowerCase()) {
case 'after effects':
return adobeAfterEffects;
case 'illustrator':
return adobeIllustrator;
case 'photoshop':

View file

@ -7,6 +7,7 @@ class XMP {
'adsml-at': 'AdsML',
'aux': 'Exif Aux',
'Camera': 'Camera',
'creatorAtom': 'After Effects',
'crs': 'Camera Raw Settings',
'dc': 'Dublin Core',
'drone-dji': 'DJI Drone',

View file

@ -17,6 +17,7 @@ class MetadataService {
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return result as Map;
} on PlatformException catch (e) {
@ -44,6 +45,7 @@ class MetadataService {
'mimeType': entry.mimeType,
'uri': entry.uri,
'path': entry.path,
'sizeBytes': entry.sizeBytes,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
@ -69,6 +71,7 @@ class MetadataService {
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) {
@ -108,7 +111,9 @@ class MetadataService {
try {
// return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return result;
} on PlatformException catch (e) {
@ -134,7 +139,9 @@ class MetadataService {
try {
// return map with the mime type and tag count for each directory found by `metadata-extractor`
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
}) as Map;
return result;
} on PlatformException catch (e) {
@ -155,10 +162,12 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'uri': uri,
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
@ -172,6 +181,7 @@ class MetadataService {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {

View file

@ -36,7 +36,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
_loader = MetadataService.getEmbeddedPictures(uri);
break;
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(uri);
_loader = MetadataService.getExifThumbnails(entry);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(entry);