access TIFF images via FileDescriptor instead of File

This commit is contained in:
Thibault Deckers 2020-11-13 18:39:05 +09:00
parent a2ce68c150
commit e99e648753
4 changed files with 79 additions and 98 deletions

View file

@ -101,8 +101,8 @@ dependencies {
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray // as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
// as of 20201113, its `q_support` branch allows decoding TIFF without a `File`, but is not released // as of 20201113, its `q_support` branch allows decoding TIFF without a `File`, but is not released
// we forked it to bypass official releases, upgrading its Android/Gradle structure to make it compatible with JitPack // we forked it to bypass official releases, upgrading its Android/Gradle structure to make it compatible with JitPack
// JitPack build result is available at https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/q_support-SNAPSHOT/build.log // JitPack build result is available at https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/<commit>/build.log
implementation 'com.github.deckerst:Android-TiffBitmapFactory:0f1f0aaab1' implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'com.github.bumptech.glide:glide:4.11.0'
kapt 'androidx.annotation:annotation:1.1.0' kapt 'androidx.annotation:annotation:1.1.0'

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.ContentUris import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -20,14 +20,11 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File
import java.io.IOException
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val activity: Activity, private val context: Context,
uri: String, uri: String,
private val mimeType: String, private val mimeType: String,
private val dateModifiedSecs: Long, private val dateModifiedSecs: Long,
@ -47,6 +44,7 @@ class ThumbnailFetcher internal constructor(
var recycle = true var recycle = true
var exception: Exception? = null var exception: Exception? = null
try {
if (mimeType == MimeTypes.TIFF) { if (mimeType == MimeTypes.TIFF) {
bitmap = getTiff() bitmap = getTiff()
} else if ((width == defaultSize || height == defaultSize) && !isFlipped) { } else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
@ -54,12 +52,11 @@ class ThumbnailFetcher internal constructor(
// As of Android R, the Media Store content resolver may return a thumbnail // As of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation, but not flipped, // that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries. // so we skip this step for flipped entries.
try {
bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore() bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore()
}
} catch (e: Exception) { } catch (e: Exception) {
exception = e exception = e
} }
}
// fallback if the native methods failed or for higher quality thumbnails // fallback if the native methods failed or for higher quality thumbnails
if (bitmap == null) { if (bitmap == null) {
@ -84,17 +81,17 @@ class ThumbnailFetcher internal constructor(
@RequiresApi(api = Build.VERSION_CODES.Q) @RequiresApi(api = Build.VERSION_CODES.Q)
private fun getByResolver(): Bitmap? { private fun getByResolver(): Bitmap? {
val resolver = activity.contentResolver val resolver = context.contentResolver
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null) var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
if (needRotationAfterContentResolverThumbnail(mimeType)) { if (needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
return bitmap return bitmap
} }
private fun getByMediaStore(): Bitmap? { private fun getByMediaStore(): Bitmap? {
val contentId = ContentUris.parseId(uri) val contentId = ContentUris.parseId(uri)
val resolver = activity.contentResolver val resolver = context.contentResolver
return if (isVideo(mimeType)) { return if (isVideo(mimeType)) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null) MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
@ -103,7 +100,7 @@ class ThumbnailFetcher internal constructor(
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android Q, returned thumbnail is already rotated according to EXIF orientation // from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
bitmap bitmap
} }
@ -118,13 +115,13 @@ class ThumbnailFetcher internal constructor(
val target = if (isVideo(mimeType)) { val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(activity) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(VideoThumbnail(activity, uri)) .load(VideoThumbnail(context, uri))
.submit(width, height) .submit(width, height)
} else { } else {
Glide.with(activity) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(uri) .load(uri)
@ -134,51 +131,38 @@ class ThumbnailFetcher internal constructor(
return try { return try {
var bitmap = target.get() var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
bitmap bitmap
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(context).clear(target)
} }
} }
private fun getTiff(): Bitmap? { private fun getTiff(): Bitmap? {
// copy source stream to a temp file // determine sample size
val file: File var sampleSize = 1
try { context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
file = File.createTempFile("aves", ".tiff")
StorageUtils.openInputStream(activity, uri)?.use { input ->
StorageUtils.copyInputStreamToFile(input, file)
}
file.deleteOnExit()
} catch (e: IOException) {
return null
}
// check directory count
val options = TiffBitmapFactory.Options().apply { val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true
} }
TiffBitmapFactory.decodeFile(file, options) TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (options.outDirectoryCount == 0) return null
options.inDirectoryNumber = 0
// determine sample size
TiffBitmapFactory.decodeFile(file, options)
val imageWidth = options.outWidth val imageWidth = options.outWidth
val imageHeight = options.outHeight val imageHeight = options.outHeight
var sampleSize = 1
if (imageHeight > height || imageWidth > width) { if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) { while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2 sampleSize *= 2
} }
} }
}
// decode // decode
with(options) { return context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false inJustDecodeBounds = false
inSampleSize = sampleSize inSampleSize = sampleSize
} }
return TiffBitmapFactory.decodeFile(file, options) return TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
}
} }
} }

View file

@ -21,7 +21,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@ -110,11 +109,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null) error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
var errorDetails = e.message error("streamImage-image-decode-exception", "failed to get image from uri=$uri", toErrorDetails(e))
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", errorDetails)
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
} }
@ -141,29 +136,26 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
private fun streamTiffImage(uri: Uri) { private fun streamTiffImage(uri: Uri) {
// copy source stream to a temp file val resolver = activity.contentResolver
val file: File
try { try {
file = File.createTempFile("aves", ".tiff") var dirCount = 0
StorageUtils.openInputStream(activity, uri)?.use { input -> resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
StorageUtils.copyInputStreamToFile(input, file) val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
} }
file.deleteOnExit() TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
} catch (e: IOException) { dirCount = options.outDirectoryCount
error("streamImage-tiff-copy", "failed to copy file from uri=$uri", null)
return
} }
val options = TiffBitmapFactory.Options()
options.inJustDecodeBounds = true
TiffBitmapFactory.decodeFile(file, options)
val dirCount: Int = options.outDirectoryCount
// TODO TLAD handle multipage TIFF // TODO TLAD handle multipage TIFF
if (dirCount > 0) { if (dirCount > 0) {
val i = 0 val i = 0
options.inDirectoryNumber = i resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
options.inJustDecodeBounds = false val options = TiffBitmapFactory.Options().apply {
val bitmap = TiffBitmapFactory.decodeFile(file, options) inJustDecodeBounds = false
inDirectoryNumber = i
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) { if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else { } else {
@ -171,6 +163,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
} }
} }
} catch (e: Exception) {
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
}
}
private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) {
errorDetails.split("\n".toRegex(), 2).first()
} else {
errorDetails
}
}
private fun streamBytes(inputStream: InputStream) { private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)

View file

@ -15,7 +15,10 @@ import android.webkit.MimeTypeMap
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.* import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
@ -405,17 +408,6 @@ object StorageUtils {
} }
} }
@Throws(IOException::class)
fun copyInputStreamToFile(inputStream: InputStream, file: File) {
FileOutputStream(file).use { outputStream ->
var read: Int
val bytes = ByteArray(1024)
while (inputStream.read(bytes).also { read = it } != -1) {
outputStream.write(bytes, 0, read)
}
}
}
// convenience methods // convenience methods
fun ensureTrailingSeparator(dirPath: String): String { fun ensureTrailingSeparator(dirPath: String): String {