diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c96ddc47..e2df9c7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- Support for TIFF images (single page) - Viewer overlay: minimap (optional) ### Changed diff --git a/android/app/build.gradle b/android/app/build.gradle index 65971d9d9..e7bfde0d2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -99,6 +99,7 @@ dependencies { implementation 'androidx.exifinterface:exifinterface:1.3.1' implementation 'com.commonsware.cwac:document:0.4.1' 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' kapt 'androidx.annotation:annotation:1.1.0' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt index 3d0212160..7b9689a7e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/RegionFetcher.kt @@ -37,9 +37,13 @@ class RegionFetcher internal constructor( try { if (currentDecoderRef == null) { - val newDecoder = StorageUtils.openInputStream(context, uri).use { input -> + val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input -> 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) } val decoder = currentDecoderRef.decoder diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt index f0e8fd4c2..e236cd091 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt @@ -20,7 +20,11 @@ import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide +import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodChannel +import org.beyka.tiffbitmapfactory.TiffBitmapFactory +import java.io.File +import java.io.IOException class ThumbnailFetcher internal constructor( private val activity: Activity, @@ -43,12 +47,13 @@ class ThumbnailFetcher internal constructor( var recycle = true var exception: Exception? = null - // fetch low quality thumbnails when size is not specified - if ((width == defaultSize || height == defaultSize) && !isFlipped) { - // as of Android R, the Media Store content resolver may return a thumbnail - // that is automatically rotated according to EXIF orientation, - // but not flipped when necessary - // so we skip this step for flipped entries + if (mimeType == MimeTypes.TIFF) { + bitmap = getTiff() + } else if ((width == defaultSize || height == defaultSize) && !isFlipped) { + // Fetch low quality thumbnails when size is not specified. + // As of Android R, the Media Store content resolver may return a thumbnail + // that is automatically rotated according to EXIF orientation, but not flipped, + // so we skip this step for flipped entries. try { bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore() } catch (e: Exception) { @@ -136,4 +141,44 @@ class ThumbnailFetcher internal constructor( 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) + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index ca99a84c1..0cd0607af 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -15,11 +15,13 @@ import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isVideo 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.EventSink import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.beyka.tiffbitmapfactory.TiffBitmapFactory +import java.io.File import java.io.IOException import java.io.InputStream @@ -71,6 +73,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen if (isVideo(mimeType)) { streamVideoByGlide(uri) + } else if (mimeType == MimeTypes.TIFF) { + streamTiffImage(uri) } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { // decode exotic format on platform side, then encode it in portable format for Flutter streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped) @@ -83,7 +87,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamImageAsIs(uri: Uri) { try { - openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } } + StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) } } catch (e: IOException) { 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) { val buffer = ByteArray(bufferSize) var len: Int 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 b102428af..89c4befcd 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 @@ -58,7 +58,7 @@ object MimeTypes { 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) { JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index ab26c9932..7ae3e3740 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -15,10 +15,7 @@ import android.webkit.MimeTypeMap import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath -import java.io.File -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream +import java.io.* import java.util.* 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 fun ensureTrailingSeparator(dirPath: String): String { diff --git a/lib/model/mime_types.dart b/lib/model/mime_types.dart index 9d6483da2..04e358477 100644 --- a/lib/model/mime_types.dart +++ b/lib/model/mime_types.dart @@ -41,5 +41,5 @@ class MimeTypes { // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; - static const List undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions + static const List undecodable = [crw, psd]; // TODO TLAD make it dynamic if it depends on OS/lib versions } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 285ad9957..e7e88d044 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -26,6 +26,12 @@ class Constants { static const int infoGroupMaxValueLength = 140; static const List 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( name: 'CWAC-Document', license: 'Apache 2.0', diff --git a/pubspec.yaml b/pubspec.yaml index 232c4e1b9..02829545c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 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): # - does not support content URIs (by default, but trivial by fork) # - does not support AVI/XVID, AC3