added TIFF support (single page)

This commit is contained in:
Thibault Deckers 2020-11-12 19:37:02 +09:00
parent af04d40556
commit cd4041fdbc
10 changed files with 120 additions and 15 deletions

View file

@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
### Added ### Added
- Support for TIFF images (single page)
- Viewer overlay: minimap (optional) - Viewer overlay: minimap (optional)
### Changed ### Changed

View file

@ -99,6 +99,7 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.3.1' implementation 'androidx.exifinterface:exifinterface:1.3.1'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.beyka:androidtiffbitmapfactory:0.9.8.7'
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

@ -37,9 +37,13 @@ class RegionFetcher internal constructor(
try { try {
if (currentDecoderRef == null) { if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri).use { input -> val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoder.newInstance(input, false) BitmapRegionDecoder.newInstance(input, false)
} }
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
return
}
currentDecoderRef = LastDecoderRef(uri, newDecoder) currentDecoderRef = LastDecoderRef(uri, newDecoder)
} }
val decoder = currentDecoderRef.decoder val decoder = currentDecoderRef.decoder

View file

@ -20,7 +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 java.io.File
import java.io.IOException
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val activity: Activity, private val activity: Activity,
@ -43,12 +47,13 @@ class ThumbnailFetcher internal constructor(
var recycle = true var recycle = true
var exception: Exception? = null var exception: Exception? = null
// fetch low quality thumbnails when size is not specified if (mimeType == MimeTypes.TIFF) {
if ((width == defaultSize || height == defaultSize) && !isFlipped) { bitmap = getTiff()
// as of Android R, the Media Store content resolver may return a thumbnail } else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
// that is automatically rotated according to EXIF orientation, // Fetch low quality thumbnails when size is not specified.
// but not flipped when necessary // As of Android R, the Media Store content resolver may return a thumbnail
// so we skip this step for flipped entries // that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries.
try { 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) {
@ -136,4 +141,44 @@ class ThumbnailFetcher internal constructor(
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
} }
} }
private fun getTiff(): Bitmap? {
// copy source stream to a temp file
val file: File
try {
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 {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFile(file, options)
if (options.outDirectoryCount == 0) return null
options.inDirectoryNumber = 0
// determine sample size
TiffBitmapFactory.decodeFile(file, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
var sampleSize = 1
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
}
// decode
with(options) {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
return TiffBitmapFactory.decodeFile(file, options)
}
} }

View file

@ -15,11 +15,13 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils.openInputStream import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink 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 java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@ -71,6 +73,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
streamVideoByGlide(uri) streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter // decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped) streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
@ -83,7 +87,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageAsIs(uri: Uri) { private fun streamImageAsIs(uri: Uri) {
try { try {
openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } } StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
} catch (e: IOException) { } catch (e: IOException) {
error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message) error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message)
} }
@ -136,6 +140,38 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
} }
private fun streamTiffImage(uri: Uri) {
// copy source stream to a temp file
val file: File
try {
file = File.createTempFile("aves", ".tiff")
StorageUtils.openInputStream(activity, uri)?.use { input ->
StorageUtils.copyInputStreamToFile(input, file)
}
file.deleteOnExit()
} catch (e: IOException) {
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
if (dirCount > 0) {
val i = 0
options.inDirectoryNumber = i
options.inJustDecodeBounds = false
val bitmap = TiffBitmapFactory.decodeFile(file, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$i) from uri=$uri", null)
}
}
}
private fun streamBytes(inputStream: InputStream) { private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
var len: Int var len: Int

View file

@ -58,7 +58,7 @@ object MimeTypes {
else -> false else -> false
} }
// as of Flutter v1.22.0 // as of Flutter v1.22.0, with additional custom handling for SVG
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)

View file

@ -15,10 +15,7 @@ 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.File import java.io.*
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
@ -408,6 +405,17 @@ 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 {

View file

@ -41,5 +41,5 @@ class MimeTypes {
// groups // groups
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static const List<String> undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions static const List<String> undecodable = [crw, psd]; // TODO TLAD make it dynamic if it depends on OS/lib versions
} }

View file

@ -26,6 +26,12 @@ class Constants {
static const int infoGroupMaxValueLength = 140; static const int infoGroupMaxValueLength = 140;
static const List<Dependency> androidDependencies = [ static const List<Dependency> androidDependencies = [
Dependency(
name: 'Android-TiffBitmapFactory',
license: 'MIT',
licenseUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory',
),
Dependency( Dependency(
name: 'CWAC-Document', name: 'CWAC-Document',
license: 'Apache 2.0', license: 'Apache 2.0',

View file

@ -17,6 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.5+31 version: 1.2.5+31
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)
# - TIFF tile decoding is not public (issue #258)
# video_player (as of v0.10.8+2, backed by ExoPlayer): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)
# - does not support AVI/XVID, AC3 # - does not support AVI/XVID, AC3