From 16aa28342548232cd4cdc7624fb13e240a378d6e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 10 Dec 2023 01:40:02 +0100 Subject: [PATCH] #838 viewer: mpf multipage retrieval / thumbnails --- .../aves/channel/calls/EmbeddedDataHandler.kt | 51 ++--- .../channel/calls/MetadataFetchHandler.kt | 3 +- .../channel/calls/fetchers/RegionFetcher.kt | 10 +- .../calls/fetchers/ThumbnailFetcher.kt | 9 +- .../channel/streams/ImageByteStreamHandler.kt | 7 +- ...Module.kt => MultiPageImageGlideModule.kt} | 38 ++-- .../thibault/aves/metadata/MultiPage.kt | 177 +++++++++++++----- .../aves/model/provider/ImageProvider.kt | 33 +++- lib/services/app_service.dart | 2 +- lib/services/media/embedded_data_service.dart | 6 +- .../info/embedded/embedded_data_opener.dart | 2 +- 11 files changed, 217 insertions(+), 121 deletions(-) rename android/app/src/main/kotlin/deckers/thibault/aves/decoder/{MultiTrackImageGlideModule.kt => MultiPageImageGlideModule.kt} (53%) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 500133fbc..1515fea65 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -20,7 +20,6 @@ import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.metadata.XMPPropName import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry -import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ImageProvider @@ -51,7 +50,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) } "extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) } - "extractJpegMultiPictureFormat" -> ioScope.launch { safe(call, result, ::extractJpegMultiPictureFormat) } + "extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) } "extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) } "extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) } "extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) } @@ -151,48 +150,38 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { result.error("extractGoogleDeviceItem-empty", "failed to extract item from Google Device XMP at uri=$uri dataUri=$dataUri", null) } - private fun extractJpegMultiPictureFormat(call: MethodCall, result: MethodChannel.Result) { + private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() val displayName = call.argument("displayName") val id = call.argument("id") if (mimeType == null || uri == null || sizeBytes == null || id == null) { - result.error("extractJpegMultiPictureFormat-args", "missing arguments", null) + result.error("extractJpegMpfItem-args", "missing arguments", null) return } - if (canReadWithMetadataExtractor(mimeType)) { - try { - Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> - val metadata = Helper.safeRead(input) - metadata.getDirectoriesOfType(MpEntryDirectory::class.java).first { it.id == id }?.let { dir -> - val mpEntry = dir.entry - MpEntry.getMimeType(dir.entry.format)?.let { embedMimeType -> - var dataOffset = mpEntry.dataOffset - if (dataOffset > 0) { - val baseOffset = MultiPage.getJpegMultiPictureFormatBaseOffset(context, uri, sizeBytes) - if (baseOffset != null) { - dataOffset += baseOffset - } - } - StorageUtils.openInputStream(context, uri)?.let { input -> - input.skip(dataOffset) - copyEmbeddedBytes(result, embedMimeType, displayName, input, mpEntry.size) - } - return - } + val pageIndex = id - 1 + val mpEntries = MultiPage.getJpegMpfEntries(context, uri) + if (mpEntries != null && pageIndex < mpEntries.size) { + val mpEntry = mpEntries[pageIndex] + MpEntry.getMimeType(mpEntry.format)?.let { embedMimeType -> + var dataOffset = mpEntry.dataOffset + if (dataOffset > 0) { + val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri) + if (baseOffset != null) { + dataOffset += baseOffset } } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract file from MPF", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract file from MPF", e) - } catch (e: AssertionError) { - Log.w(LOG_TAG, "failed to extract file from MPF", e) + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(dataOffset) + copyEmbeddedBytes(result, embedMimeType, displayName, input, mpEntry.size) + } + return } } - result.error("extractJpegMultiPictureFormat-empty", "failed to extract file index=$id from MPF at uri=$uri", null) + + result.error("extractJpegMpfItem-empty", "failed to extract file index=$id from MPF at uri=$uri", null) } private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index d4845d5af..49c2fdb8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -933,10 +933,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } val pages: ArrayList? = if (isMotionPhoto) { - MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes) + MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes) } else { when (mimeType) { MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) + MimeTypes.JPEG -> MultiPage.getJpegMpfPages(context, uri) MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) else -> null } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index 65a3af050..b3efb8a3c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -10,7 +10,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions -import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.utils.BitmapRegionDecoderCompat import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.MimeTypes @@ -40,10 +40,10 @@ class RegionFetcher internal constructor( imageHeight: Int, result: MethodChannel.Result, ) { - if (MimeTypes.isHeic(mimeType) && pageId != null) { + if (pageId != null && MultiPageImage.isSupported(mimeType)) { val id = Pair(uri, pageId) fetch( - uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) }, + uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) }, mimeType = MimeTypes.JPEG, pageId = null, sampleSize = sampleSize, @@ -104,11 +104,11 @@ class RegionFetcher internal constructor( } } - private fun createJpegForPage(sourceUri: Uri, pageId: Int): Uri { + private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri { val target = Glide.with(context) .asBitmap() .apply(multiTrackGlideOptions) - .load(MultiTrackImage(context, sourceUri, pageId)) + .load(MultiPageImage(context, sourceUri, mimeType, pageId)) .submit() try { val bitmap = target.get() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 752633094..98e44e135 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -12,7 +12,7 @@ import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ObjectKey -import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.SvgImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.VideoThumbnail @@ -20,7 +20,6 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.SVG -import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide @@ -47,8 +46,8 @@ class ThumbnailFetcher internal constructor( private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize private val svgFetch = mimeType == SVG private val tiffFetch = mimeType == MimeTypes.TIFF - private val multiTrackFetch = isHeic(mimeType) && pageId != null - private val customFetch = svgFetch || tiffFetch || multiTrackFetch + private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType) + private val customFetch = svgFetch || tiffFetch || multiPageFetch suspend fun fetch() { var bitmap: Bitmap? = null @@ -135,7 +134,7 @@ class ThumbnailFetcher internal constructor( val model: Any = when { svgFetch -> SvgImage(context, uri) tiffFetch -> TiffImage(context, uri, pageId) - multiTrackFetch -> MultiTrackImage(context, uri, pageId) + multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId) else -> StorageUtils.getGlideSafeUri(context, uri, mimeType) } Glide.with(context) 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 56e40cc9d..e767ad064 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 @@ -9,7 +9,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions -import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation @@ -18,7 +18,6 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter -import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils @@ -131,8 +130,8 @@ class ImageByteStreamHandler(private val context: Context, private val arguments rotationDegrees: Int, isFlipped: Boolean, ) { - val model: Any = if (isHeic(mimeType) && pageId != null) { - MultiTrackImage(context, uri, pageId) + val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) { + MultiPageImage(context, uri, mimeType, pageId) } else if (mimeType == MimeTypes.TIFF) { TiffImage(context, uri, pageId) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiPageImageGlideModule.kt similarity index 53% rename from android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiPageImageGlideModule.kt index 63fe82405..353b99112 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiPageImageGlideModule.kt @@ -17,32 +17,38 @@ import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.signature.ObjectKey +import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.metadata.MultiTrackMedia +import deckers.thibault.aves.utils.MimeTypes @GlideModule -class MultiTrackImageGlideModule : LibraryGlideModule() { +class MultiPageImageGlideModule : LibraryGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - registry.append(MultiTrackImage::class.java, Bitmap::class.java, MultiTrackThumbnailLoader.Factory()) + registry.append(MultiPageImage::class.java, Bitmap::class.java, MultiPageThumbnailLoader.Factory()) } } -class MultiTrackImage(val context: Context, val uri: Uri, val trackIndex: Int?) +class MultiPageImage(val context: Context, val uri: Uri, val mimeType: String, val pageId: Int?) { + companion object { + fun isSupported(mimeType: String) = MimeTypes.isHeic(mimeType) || mimeType == MimeTypes.JPEG + } +} -internal class MultiTrackThumbnailLoader : ModelLoader { - override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { - return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height)) +internal class MultiPageThumbnailLoader : ModelLoader { + override fun buildLoadData(model: MultiPageImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { + return ModelLoader.LoadData(ObjectKey(model.uri), MultiPageImageFetcher(model, width, height)) } - override fun handles(model: MultiTrackImage): Boolean = true + override fun handles(model: MultiPageImage): Boolean = true - internal class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MultiTrackThumbnailLoader() + internal class Factory : ModelLoaderFactory { + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = MultiPageThumbnailLoader() override fun teardown() {} } } -internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher { +internal class MultiPageImageFetcher(val model: MultiPageImage, val width: Int, val height: Int) : DataFetcher { override fun loadData(priority: Priority, callback: DataCallback) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { callback.onLoadFailed(Exception("unsupported Android version")) @@ -51,9 +57,17 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int val context = model.context val uri = model.uri - val trackIndex = model.trackIndex + val mimeType = model.mimeType + + var bitmap: Bitmap? = null + if (MimeTypes.isHeic(mimeType)) { + val trackIndex = model.pageId + bitmap = MultiTrackMedia.getImage(context, uri, trackIndex) + } else if (mimeType == MimeTypes.JPEG) { + val pageIndex = model.pageId ?: 0 + bitmap = MultiPage.getJpegMpfBitmap(context, uri, pageIndex) + } - val bitmap = MultiTrackMedia.getImage(context, uri, trackIndex) if (bitmap == null) { callback.onLoadFailed(Exception("null bitmap")) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index d992a14ff..f6aa79c6d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -1,6 +1,8 @@ package deckers.thibault.aves.metadata import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri @@ -15,9 +17,12 @@ import deckers.thibault.aves.metadata.XMP.doesPropExist import deckers.thibault.aves.metadata.XMP.getSafeLong import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.metadata.metadataextractor.Helper +import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry +import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.indexOfBytes import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.DataInputStream @@ -48,13 +53,13 @@ object MultiPage { val tracks = ArrayList() val extractor = MediaExtractor() extractor.setDataSource(context, uri, null) - for (i in 0 until extractor.trackCount) { + for (pageIndex in 0 until extractor.trackCount) { try { - val format = extractor.getTrackFormat(i) + val format = extractor.getTrackFormat(pageIndex) format.getString(MediaFormat.KEY_MIME)?.let { mime -> val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime val track: FieldMap = hashMapOf( - KEY_PAGE to i, + KEY_PAGE to pageIndex, KEY_MIME_TYPE to trackMime, ) @@ -73,13 +78,115 @@ object MultiPage { tracks.add(track) } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, track num=$i", e) + Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, pageIndex=$pageIndex", e) } } extractor.release() return tracks } + // starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]` + fun getJpegMpfBaseOffset(context: Context, uri: Uri): Int? { + val app2Marker = JpegSegmentType.APP2.byteValue + val mpfMarker = "MPF".toByteArray() + 0x00 + + try { + Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, null)?.use { input -> + var offset = 0 + while (true) { + do { + val b = input.read().toByte() + offset++ + } while (b != app2Marker) + // skip 2 bytes for segment size + input.skip(2) + offset += 2 + val marker = ByteArray(4) + input.read(marker, 0, marker.size) + offset += 4 + if (marker.contentEquals(mpfMarker)) { + return offset + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get MPF base offset from uri=$uri", e) + } + return null + } + + fun getJpegMpfEntries(context: Context, uri: Uri): List? { + try { + Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, null)?.use { input -> + val metadata = Helper.safeRead(input) + return metadata.getDirectoriesOfType(MpEntryDirectory::class.java).map { it.entry } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to find MPF entries", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to find MPF entries", e) + } catch (e: AssertionError) { + Log.w(LOG_TAG, "failed to find MPF entries", e) + } + return null + } + + fun getJpegMpfPages(context: Context, uri: Uri): ArrayList { + val pages = ArrayList() + val baseOffset = getJpegMpfBaseOffset(context, uri) + val mpEntries = getJpegMpfEntries(context, uri) + if (mpEntries != null && baseOffset != null) { + for ((pageIndex, mpEntry) in mpEntries.withIndex()) { + MpEntry.getMimeType(mpEntry.format)?.let { embedMimeType -> + val page = hashMapOf( + KEY_PAGE to pageIndex, + KEY_MIME_TYPE to embedMimeType, + KEY_IS_DEFAULT to (pageIndex == 0), + // TODO TLAD [MPF] page[KEY_ROTATION_DEGREES] = same as primary + KEY_ROTATION_DEGREES to 0, + ) + + var dataOffset = mpEntry.dataOffset + if (dataOffset > 0) { + dataOffset += baseOffset + } + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(dataOffset) + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(input, null, options) + options.outWidth.takeIf { it >= 0 }?.let { page[KEY_WIDTH] = it } + options.outHeight.takeIf { it >= 0 }?.let { page[KEY_HEIGHT] = it } + + pages.add(page) + } + } + } + } + + return pages + } + + fun getJpegMpfBitmap(context: Context, uri: Uri, pageIndex: Int): Bitmap? { + val mpEntries = getJpegMpfEntries(context, uri) + if (mpEntries != null && pageIndex < mpEntries.size) { + val mpEntry = mpEntries[pageIndex] + var dataOffset = mpEntry.dataOffset + if (dataOffset > 0) { + val baseOffset = getJpegMpfBaseOffset(context, uri) + if (baseOffset != null) { + dataOffset += baseOffset + } + } + StorageUtils.openInputStream(context, uri)?.let { input -> + input.skip(dataOffset) + return BitmapFactory.decodeStream(input) + } + } + return null + } + fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList { fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { if (this.containsKey(key)) save(this.getInteger(key)) @@ -89,7 +196,7 @@ object MultiPage { if (this.containsKey(key)) save(this.getLong(key)) } - val tracks = ArrayList() + val pages = ArrayList() val extractor = MediaExtractor() var pfd: ParcelFileDescriptor? = null try { @@ -99,10 +206,10 @@ object MultiPage { pfd?.fileDescriptor?.let { fd -> extractor.setDataSource(fd, videoStartOffset, videoSizeBytes) // set the original image as the first and default track - var trackCount = 0 - tracks.add( + var pageIndex = 0 + pages.add( hashMapOf( - KEY_PAGE to trackCount++, + KEY_PAGE to pageIndex++, KEY_MIME_TYPE to mimeType, KEY_IS_DEFAULT to true, ) @@ -115,18 +222,18 @@ object MultiPage { val format = extractor.getTrackFormat(trackIndex) format.getString(MediaFormat.KEY_MIME)?.let { mime -> if (MimeTypes.isVideo(mime)) { - val track: FieldMap = hashMapOf( - KEY_PAGE to trackCount++, + val page: FieldMap = hashMapOf( + KEY_PAGE to pageIndex++, KEY_MIME_TYPE to MimeTypes.MP4, KEY_IS_DEFAULT to false, ) - format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } - format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } + format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it } + format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } + format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it } } - format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 } - tracks.add(track) + format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 } + pages.add(page) } } } catch (e: Exception) { @@ -141,7 +248,7 @@ object MultiPage { extractor.release() pfd?.close() } - return tracks + return pages } fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { @@ -204,40 +311,10 @@ object MultiPage { return offsetFromEnd } - // starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]` - fun getJpegMultiPictureFormatBaseOffset(context: Context, uri: Uri, sizeBytes: Long): Int? { - val app2Marker = JpegSegmentType.APP2.byteValue - val mpfMarker = "MPF".toByteArray() + 0x00 - - try { - Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, sizeBytes)?.use { input -> - var offset = 0 - while (true) { - do { - val b = input.read().toByte() - offset++ - } while (b != app2Marker) - // skip 2 bytes for segment size - input.skip(2) - offset += 2 - val marker = ByteArray(4) - input.read(marker, 0, marker.size) - offset += 4 - if (marker.contentEquals(mpfMarker)) { - return offset - } - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get MPF base offset from uri=$uri", e) - } - return null - } - fun getTiffPages(context: Context, uri: Uri): ArrayList { - fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap { + fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap { return hashMapOf( - KEY_PAGE to page, + KEY_PAGE to pageIndex, KEY_MIME_TYPE to MimeTypes.TIFF, KEY_WIDTH to options.outWidth, KEY_HEIGHT to options.outHeight, @@ -248,8 +325,8 @@ object MultiPage { getTiffPageInfo(context, uri, 0)?.let { first -> pages.add(toMap(0, first)) val pageCount = first.outDirectoryCount - for (i in 1 until pageCount) { - getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) } + for (pageIndex in 1 until pageCount) { + getTiffPageInfo(context, uri, pageIndex)?.let { pages.add(toMap(pageIndex, it)) } } } return pages diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 8abdc5648..766c3b936 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -19,25 +19,36 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.SvgImage import deckers.thibault.aves.decoder.TiffImage -import deckers.thibault.aves.metadata.* +import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_MP4 import deckers.thibault.aves.metadata.Metadata.TYPE_XMP +import deckers.thibault.aves.metadata.Mp4ParserHelper import deckers.thibault.aves.metadata.Mp4ParserHelper.updateLocation import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp +import deckers.thibault.aves.metadata.MultiPage +import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString +import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.metadataextractor.Helper -import deckers.thibault.aves.model.* -import deckers.thibault.aves.utils.* +import deckers.thibault.aves.model.AvesEntry +import deckers.thibault.aves.model.ExifOrientationOp +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy +import deckers.thibault.aves.model.SourceEntry +import deckers.thibault.aves.utils.BitmapUtils +import deckers.thibault.aves.utils.BmpWriter import deckers.thibault.aves.utils.FileUtils.transferFrom import deckers.thibault.aves.utils.FileUtils.transferTo +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditIptc import deckers.thibault.aves.utils.MimeTypes.canEditXmp @@ -46,13 +57,19 @@ import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import pixy.meta.meta.Metadata import pixy.meta.meta.MetadataType -import java.io.* +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream import java.nio.channels.Channels -import java.util.* +import java.util.Date +import java.util.TimeZone import kotlin.math.absoluteValue abstract class ImageProvider { @@ -291,8 +308,8 @@ abstract class ImageProvider { targetHeightPx = sourceEntry.height * targetHeightPx / 100 } - val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { - MultiTrackImage(activity, sourceUri, pageId) + val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) { + MultiPageImage(activity, sourceUri, sourceMimeType, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { TiffImage(activity, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.SVG) { diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 2f4a8c418..9941df2f3 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -38,7 +38,7 @@ class PlatformAppService implements AppService { 'com.sony.playmemories.mobile': {'Imaging Edge Mobile'}, 'nekox.messenger': {'NekoX'}, 'org.telegram.messenger': {'Telegram Images', 'Telegram Video'}, - 'com.whatsapp': {'Whatsapp', 'WhatsApp Animated Gifs', 'WhatsApp Images', 'WhatsApp Video'} + 'com.whatsapp': {'Whatsapp', 'WhatsApp Animated Gifs', 'WhatsApp Documents', 'WhatsApp Images', 'WhatsApp Video'} }; @override diff --git a/lib/services/media/embedded_data_service.dart b/lib/services/media/embedded_data_service.dart index c86608fbc..bb3a00aa5 100644 --- a/lib/services/media/embedded_data_service.dart +++ b/lib/services/media/embedded_data_service.dart @@ -12,7 +12,7 @@ abstract class EmbeddedDataService { Future extractMotionPhotoVideo(AvesEntry entry); - Future extractJpegMultiPictureFormat(AvesEntry entry, int index); + Future extractJpegMpfItem(AvesEntry entry, int index); Future extractVideoEmbeddedPicture(AvesEntry entry); @@ -87,9 +87,9 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { } @override - Future extractJpegMultiPictureFormat(AvesEntry entry, int id) async { + Future extractJpegMpfItem(AvesEntry entry, int id) async { try { - final result = await _platform.invokeMethod('extractJpegMultiPictureFormat', { + final result = await _platform.invokeMethod('extractJpegMpfItem', { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, diff --git a/lib/widgets/viewer/info/embedded/embedded_data_opener.dart b/lib/widgets/viewer/info/embedded/embedded_data_opener.dart index a34c53aa6..331743110 100644 --- a/lib/widgets/viewer/info/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/info/embedded/embedded_data_opener.dart @@ -45,7 +45,7 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { case EmbeddedDataSource.motionPhotoVideo: fields = await embeddedDataService.extractMotionPhotoVideo(entry); case EmbeddedDataSource.mpf: - fields = await embeddedDataService.extractJpegMultiPictureFormat(entry, notification.mpfId!); + fields = await embeddedDataService.extractJpegMpfItem(entry, notification.mpfId!); case EmbeddedDataSource.videoCover: fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); case EmbeddedDataSource.xmp: