#838 viewer: mpf multipage retrieval / thumbnails

This commit is contained in:
Thibault Deckers 2023-12-10 01:40:02 +01:00
parent 82b4c8aaa1
commit 16aa283425
11 changed files with 217 additions and 121 deletions

View file

@ -20,7 +20,6 @@ import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.metadata.XMPPropName import deckers.thibault.aves.metadata.XMPPropName
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry 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.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.model.provider.ImageProvider
@ -51,7 +50,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) } "getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) } "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) } "extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) } "extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) } "extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
@ -151,27 +150,25 @@ 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) 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<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
val id = call.argument<Int>("id") val id = call.argument<Int>("id")
if (mimeType == null || uri == null || sizeBytes == null || id == null) { if (mimeType == null || uri == null || sizeBytes == null || id == null) {
result.error("extractJpegMultiPictureFormat-args", "missing arguments", null) result.error("extractJpegMpfItem-args", "missing arguments", null)
return return
} }
if (canReadWithMetadataExtractor(mimeType)) { val pageIndex = id - 1
try { val mpEntries = MultiPage.getJpegMpfEntries(context, uri)
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> if (mpEntries != null && pageIndex < mpEntries.size) {
val metadata = Helper.safeRead(input) val mpEntry = mpEntries[pageIndex]
metadata.getDirectoriesOfType(MpEntryDirectory::class.java).first { it.id == id }?.let { dir -> MpEntry.getMimeType(mpEntry.format)?.let { embedMimeType ->
val mpEntry = dir.entry
MpEntry.getMimeType(dir.entry.format)?.let { embedMimeType ->
var dataOffset = mpEntry.dataOffset var dataOffset = mpEntry.dataOffset
if (dataOffset > 0) { if (dataOffset > 0) {
val baseOffset = MultiPage.getJpegMultiPictureFormatBaseOffset(context, uri, sizeBytes) val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri)
if (baseOffset != null) { if (baseOffset != null) {
dataOffset += baseOffset dataOffset += baseOffset
} }
@ -183,16 +180,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return return
} }
} }
}
} catch (e: Exception) { result.error("extractJpegMpfItem-empty", "failed to extract file index=$id from MPF at uri=$uri", null)
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)
}
}
result.error("extractJpegMultiPictureFormat-empty", "failed to extract file index=$id from MPF at uri=$uri", null)
} }
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) { private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {

View file

@ -933,10 +933,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
val pages: ArrayList<FieldMap>? = if (isMotionPhoto) { val pages: ArrayList<FieldMap>? = if (isMotionPhoto) {
MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes) MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes)
} else { } else {
when (mimeType) { when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.JPEG -> MultiPage.getJpegMpfPages(context, uri)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null else -> null
} }

View file

@ -10,7 +10,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions 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.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -40,10 +40,10 @@ class RegionFetcher internal constructor(
imageHeight: Int, imageHeight: Int,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
if (MimeTypes.isHeic(mimeType) && pageId != null) { if (pageId != null && MultiPageImage.isSupported(mimeType)) {
val id = Pair(uri, pageId) val id = Pair(uri, pageId)
fetch( fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) }, uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) },
mimeType = MimeTypes.JPEG, mimeType = MimeTypes.JPEG,
pageId = null, pageId = null,
sampleSize = sampleSize, 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) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(multiTrackGlideOptions) .apply(multiTrackGlideOptions)
.load(MultiTrackImage(context, sourceUri, pageId)) .load(MultiPageImage(context, sourceUri, mimeType, pageId))
.submit() .submit()
try { try {
val bitmap = target.get() val bitmap = target.get()

View file

@ -12,7 +12,7 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey 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.SvgImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail 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.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.SVG 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.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
@ -47,8 +46,8 @@ class ThumbnailFetcher internal constructor(
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val svgFetch = mimeType == SVG private val svgFetch = mimeType == SVG
private val tiffFetch = mimeType == MimeTypes.TIFF private val tiffFetch = mimeType == MimeTypes.TIFF
private val multiTrackFetch = isHeic(mimeType) && pageId != null private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
private val customFetch = svgFetch || tiffFetch || multiTrackFetch private val customFetch = svgFetch || tiffFetch || multiPageFetch
suspend fun fetch() { suspend fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
@ -135,7 +134,7 @@ class ThumbnailFetcher internal constructor(
val model: Any = when { val model: Any = when {
svgFetch -> SvgImage(context, uri) svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId) tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId) multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId)
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType) else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
} }
Glide.with(context) Glide.with(context)

View file

@ -9,7 +9,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions 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.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation 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.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter 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.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -131,8 +130,8 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
rotationDegrees: Int, rotationDegrees: Int,
isFlipped: Boolean, isFlipped: Boolean,
) { ) {
val model: Any = if (isHeic(mimeType) && pageId != null) { val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) {
MultiTrackImage(context, uri, pageId) MultiPageImage(context, uri, mimeType, pageId)
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId) TiffImage(context, uri, pageId)
} else { } else {

View file

@ -17,32 +17,38 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.MultiTrackMedia import deckers.thibault.aves.metadata.MultiTrackMedia
import deckers.thibault.aves.utils.MimeTypes
@GlideModule @GlideModule
class MultiTrackImageGlideModule : LibraryGlideModule() { class MultiPageImageGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { 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<MultiTrackImage, Bitmap> { internal class MultiPageThumbnailLoader : ModelLoader<MultiPageImage, Bitmap> {
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> { override fun buildLoadData(model: MultiPageImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height)) 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<MultiTrackImage, Bitmap> { internal class Factory : ModelLoaderFactory<MultiPageImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader() override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiPageImage, Bitmap> = MultiPageThumbnailLoader()
override fun teardown() {} override fun teardown() {}
} }
} }
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> { internal class MultiPageImageFetcher(val model: MultiPageImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) { override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
callback.onLoadFailed(Exception("unsupported Android version")) callback.onLoadFailed(Exception("unsupported Android version"))
@ -51,9 +57,17 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int
val context = model.context val context = model.context
val uri = model.uri 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) { if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap")) callback.onLoadFailed(Exception("null bitmap"))
} else { } else {

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaExtractor import android.media.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
import android.net.Uri 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.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.metadata.metadataextractor.Helper 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.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.indexOfBytes import deckers.thibault.aves.utils.indexOfBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.DataInputStream import java.io.DataInputStream
@ -48,13 +53,13 @@ object MultiPage {
val tracks = ArrayList<FieldMap>() val tracks = ArrayList<FieldMap>()
val extractor = MediaExtractor() val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null) extractor.setDataSource(context, uri, null)
for (i in 0 until extractor.trackCount) { for (pageIndex in 0 until extractor.trackCount) {
try { try {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(pageIndex)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val track: FieldMap = hashMapOf( val track: FieldMap = hashMapOf(
KEY_PAGE to i, KEY_PAGE to pageIndex,
KEY_MIME_TYPE to trackMime, KEY_MIME_TYPE to trackMime,
) )
@ -73,13 +78,115 @@ object MultiPage {
tracks.add(track) tracks.add(track)
} }
} catch (e: Exception) { } 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() extractor.release()
return tracks 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<MpEntry>? {
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<FieldMap> {
val pages = ArrayList<FieldMap>()
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<String, Any?>(
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<FieldMap> { fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) { fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key)) if (this.containsKey(key)) save(this.getInteger(key))
@ -89,7 +196,7 @@ object MultiPage {
if (this.containsKey(key)) save(this.getLong(key)) if (this.containsKey(key)) save(this.getLong(key))
} }
val tracks = ArrayList<FieldMap>() val pages = ArrayList<FieldMap>()
val extractor = MediaExtractor() val extractor = MediaExtractor()
var pfd: ParcelFileDescriptor? = null var pfd: ParcelFileDescriptor? = null
try { try {
@ -99,10 +206,10 @@ object MultiPage {
pfd?.fileDescriptor?.let { fd -> pfd?.fileDescriptor?.let { fd ->
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes) extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
// set the original image as the first and default track // set the original image as the first and default track
var trackCount = 0 var pageIndex = 0
tracks.add( pages.add(
hashMapOf( hashMapOf(
KEY_PAGE to trackCount++, KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to mimeType, KEY_MIME_TYPE to mimeType,
KEY_IS_DEFAULT to true, KEY_IS_DEFAULT to true,
) )
@ -115,18 +222,18 @@ object MultiPage {
val format = extractor.getTrackFormat(trackIndex) val format = extractor.getTrackFormat(trackIndex)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) { if (MimeTypes.isVideo(mime)) {
val track: FieldMap = hashMapOf( val page: FieldMap = hashMapOf(
KEY_PAGE to trackCount++, KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to MimeTypes.MP4, KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false, KEY_IS_DEFAULT to false,
) )
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 } format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
tracks.add(track) pages.add(page)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -141,7 +248,7 @@ object MultiPage {
extractor.release() extractor.release()
pfd?.close() pfd?.close()
} }
return tracks return pages
} }
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
@ -204,40 +311,10 @@ object MultiPage {
return offsetFromEnd 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<FieldMap> { fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap { fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf( return hashMapOf(
KEY_PAGE to page, KEY_PAGE to pageIndex,
KEY_MIME_TYPE to MimeTypes.TIFF, KEY_MIME_TYPE to MimeTypes.TIFF,
KEY_WIDTH to options.outWidth, KEY_WIDTH to options.outWidth,
KEY_HEIGHT to options.outHeight, KEY_HEIGHT to options.outHeight,
@ -248,8 +325,8 @@ object MultiPage {
getTiffPageInfo(context, uri, 0)?.let { first -> getTiffPageInfo(context, uri, 0)?.let { first ->
pages.add(toMap(0, first)) pages.add(toMap(0, first))
val pageCount = first.outDirectoryCount val pageCount = first.outDirectoryCount
for (i in 1 until pageCount) { for (pageIndex in 1 until pageCount) {
getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) } getTiffPageInfo(context, uri, pageIndex)?.let { pages.add(toMap(pageIndex, it)) }
} }
} }
return pages return pages

View file

@ -19,25 +19,36 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.FutureTarget
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat 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.SvgImage
import deckers.thibault.aves.decoder.TiffImage 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.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4 import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP 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.updateLocation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp 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.extendedXmpDocString
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.* import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.utils.* 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.transferFrom
import deckers.thibault.aves.utils.FileUtils.transferTo 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.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditIptc import deckers.thibault.aves.utils.MimeTypes.canEditIptc
import deckers.thibault.aves.utils.MimeTypes.canEditXmp 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.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pixy.meta.meta.Metadata import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataType 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.nio.channels.Channels
import java.util.* import java.util.Date
import java.util.TimeZone
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
abstract class ImageProvider { abstract class ImageProvider {
@ -291,8 +308,8 @@ abstract class ImageProvider {
targetHeightPx = sourceEntry.height * targetHeightPx / 100 targetHeightPx = sourceEntry.height * targetHeightPx / 100
} }
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) {
MultiTrackImage(activity, sourceUri, pageId) MultiPageImage(activity, sourceUri, sourceMimeType, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId) TiffImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.SVG) { } else if (sourceMimeType == MimeTypes.SVG) {

View file

@ -38,7 +38,7 @@ class PlatformAppService implements AppService {
'com.sony.playmemories.mobile': {'Imaging Edge Mobile'}, 'com.sony.playmemories.mobile': {'Imaging Edge Mobile'},
'nekox.messenger': {'NekoX'}, 'nekox.messenger': {'NekoX'},
'org.telegram.messenger': {'Telegram Images', 'Telegram Video'}, '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 @override

View file

@ -12,7 +12,7 @@ abstract class EmbeddedDataService {
Future<Map> extractMotionPhotoVideo(AvesEntry entry); Future<Map> extractMotionPhotoVideo(AvesEntry entry);
Future<Map> extractJpegMultiPictureFormat(AvesEntry entry, int index); Future<Map> extractJpegMpfItem(AvesEntry entry, int index);
Future<Map> extractVideoEmbeddedPicture(AvesEntry entry); Future<Map> extractVideoEmbeddedPicture(AvesEntry entry);
@ -87,9 +87,9 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
} }
@override @override
Future<Map> extractJpegMultiPictureFormat(AvesEntry entry, int id) async { Future<Map> extractJpegMpfItem(AvesEntry entry, int id) async {
try { try {
final result = await _platform.invokeMethod('extractJpegMultiPictureFormat', <String, dynamic>{ final result = await _platform.invokeMethod('extractJpegMpfItem', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,

View file

@ -45,7 +45,7 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin {
case EmbeddedDataSource.motionPhotoVideo: case EmbeddedDataSource.motionPhotoVideo:
fields = await embeddedDataService.extractMotionPhotoVideo(entry); fields = await embeddedDataService.extractMotionPhotoVideo(entry);
case EmbeddedDataSource.mpf: case EmbeddedDataSource.mpf:
fields = await embeddedDataService.extractJpegMultiPictureFormat(entry, notification.mpfId!); fields = await embeddedDataService.extractJpegMpfItem(entry, notification.mpfId!);
case EmbeddedDataSource.videoCover: case EmbeddedDataSource.videoCover:
fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
case EmbeddedDataSource.xmp: case EmbeddedDataSource.xmp: