diff --git a/android/app/build.gradle b/android/app/build.gradle index 5f17de9c9..89428be2b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -120,7 +120,10 @@ dependencies { implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.16.0' + // https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/**********/build.log implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack + // https://jitpack.io/com/github/deckerst/pixymeta-android/**********/build.log + implementation 'com.github.deckerst:pixymeta-android:f90140ed2b' // forked, built by JitPack implementation 'com.github.bumptech.glide:glide:4.12.0' kapt 'androidx.annotation:annotation:1.2.0' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 70292b9ea..5681e56be 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -17,11 +17,13 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.Metadata +import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor +import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.UriUtils.tryParseId @@ -52,11 +54,33 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) } "getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) } + "getPixyMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPixyMetadata) } "getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) } else -> result.notImplemented() } } + private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getPixyMetadata-args", "failed because of missing arguments", null) + return + } + + val metadataMap = HashMap() + if (isSupportedByPixyMeta(mimeType)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + metadataMap.putAll(PixyMetaHelper.describe(input)) + } + } catch (e: Exception) { + result.error("getPixyMetadata-exception", e.message, e.stackTraceToString()) + } + } + result.success(metadataMap) + } + private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { val dirs = hashMapOf( "cacheDir" to context.cacheDir, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index a99906901..ac795ac44 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -15,11 +15,10 @@ class DeviceHandler : MethodCallHandler { } private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { - // TODO TLAD uncomment when the future is here -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { -// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) -// return -// } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) + return + } result.success(Build.VERSION.SDK_INT) } 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 eb158b257..8e61c11bf 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 @@ -211,13 +211,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { ) if (isImage(mimeType) || isVideo(mimeType)) { GlobalScope.launch(Dispatchers.IO) { - ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback { - override fun onSuccess(res: FieldMap) { - resultFields.putAll(res) + ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback { + override fun onSuccess(fields: FieldMap) { + resultFields.putAll(fields) result.success(resultFields) } - override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", "${throwable.message}\n${throwable.stackTraceToString()}") }) } } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 18228cf81..93e47c822 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -58,9 +58,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = result.success(res) - override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message) + provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", "${throwable.message}\n${throwable.stackTraceToString()}") }) } @@ -160,9 +160,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { } destinationDir = ensureTrailingSeparator(destinationDir) - provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = result.success(res) - override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message) + provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", "${throwable.message}\n${throwable.stackTraceToString()}") }) } @@ -188,9 +188,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = result.success(res) - override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message) + provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", "${throwable.message}\n${throwable.stackTraceToString()}") }) } @@ -219,8 +219,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? - val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong() - if (uri == null || path == null || mimeType == null || sizeBytes == null) { + if (uri == null || path == null || mimeType == null) { result.error("changeOrientation-args", "failed because entry fields are missing", null) return } @@ -231,9 +230,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = result.success(res) - override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) + provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", "${throwable.message}\n${throwable.stackTraceToString()}") }) } @@ -250,8 +249,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? - val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong() - if (uri == null || path == null || mimeType == null || sizeBytes == null) { + if (uri == null || path == null || mimeType == null) { result.error("editDate-args", "failed because entry fields are missing", null) return } @@ -262,9 +260,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { return } - provider.editDate(activity, path, uri, mimeType, sizeBytes, dateMillis, shiftMinutes, fields, object : ImageOpCallback { - override fun onSuccess(res: Boolean) = result.success(res) - override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", throwable.message) + provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", "${throwable.message}\n${throwable.stackTraceToString()}") }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 9a6ad38b8..7170a3105 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -138,8 +138,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = success(res) + provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) }) endOfStream() @@ -168,8 +168,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { - override fun onSuccess(res: FieldMap) = success(res) + provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) endOfStream() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt new file mode 100644 index 000000000..2a4d44790 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt @@ -0,0 +1,57 @@ +package deckers.thibault.aves.metadata + +import pixy.meta.meta.Metadata +import pixy.meta.meta.MetadataEntry +import pixy.meta.meta.MetadataType +import pixy.meta.meta.jpeg.JPGMeta +import pixy.meta.meta.xmp.XMP +import pixy.meta.string.XMLUtils +import java.io.InputStream +import java.io.OutputStream +import java.util.* + +object PixyMetaHelper { + fun describe(input: InputStream): HashMap { + val metadataMap = HashMap() + + fun fetch(parents: String, entries: Iterable) { + for (entry in entries) { + metadataMap["$parents ${entry.key}"] = entry.value + if (entry.isMetadataEntryGroup) { + fetch("$parents ${entry.key} /", entry.metadataEntries) + } + } + } + + val metadataByType = Metadata.readMetadata(input) + for ((type, metadata) in metadataByType.entries) { + if (type == MetadataType.XMP) { + val xmp = metadataByType[MetadataType.XMP] as XMP? + if (xmp != null) { + metadataMap["XMP"] = xmp.xmpDocString() + if (xmp.hasExtendedXmp()) { + metadataMap["XMP extended"] = xmp.extendedXmpDocString() + } + } + } else { + fetch("$type /", metadata) + } + } + + return metadataMap + } + + fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP? + + fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) { + if (extendedXmpString != null) { + JPGMeta.insertXMP(input, output, xmpString, extendedXmpString) + } else { + Metadata.insertXMP(input, output, xmpString) + } + } + + fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument) + + fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 0cfc8ebdf..1f1829db5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -9,14 +9,13 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString -import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils internal class ContentImageProvider : ImageProvider() { - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { // source MIME type may be incorrect, so we get a second opinion if possible var extractorMimeType: String? = null try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index 8fe774ef4..eb0eb3138 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -2,12 +2,11 @@ package deckers.thibault.aves.model.provider import android.content.Context import android.net.Uri -import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import java.io.File internal class FileImageProvider : ImageProvider() { - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { if (sourceMimeType == null) { callback.onFailure(Exception("MIME type is null for uri=$uri")) return 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 0adc2351b..d818b5605 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 @@ -20,27 +20,29 @@ import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis 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.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.UriUtils.tryParseId -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileNotFoundException -import java.io.IOException +import java.io.* import java.util.* import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine abstract class ImageProvider { - open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException()) } @@ -48,7 +50,7 @@ abstract class ImageProvider { throw UnsupportedOperationException() } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException()) } @@ -57,7 +59,7 @@ abstract class ImageProvider { imageExportMimeType: String, destinationDir: String, entries: List, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) { throw Exception("unsupported export MIME type=$imageExportMimeType") @@ -205,7 +207,7 @@ abstract class ImageProvider { exifFields: FieldMap, bytes: ByteArray, destinationDir: String, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) if (destinationDirDocFile == null) { @@ -300,7 +302,7 @@ abstract class ImageProvider { } } - suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { + suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { val oldFile = File(oldPath) val newFile = File(oldFile.parent, newFilename) if (oldFile == newFile) { @@ -331,22 +333,30 @@ abstract class ImageProvider { } // support for writing EXIF - // as of androidx.exifinterface:exifinterface:1.3.0 + // as of androidx.exifinterface:exifinterface:1.3.3 private fun canEditExif(mimeType: String): Boolean { return when (mimeType) { - MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true + MimeTypes.DNG, + MimeTypes.JPEG, + MimeTypes.PNG, + MimeTypes.WEBP -> true else -> false } } - private fun editExif( + // support for writing XMP + private fun canEditXmp(mimeType: String): Boolean { + return isSupportedByPixyMeta(mimeType) + } + + private fun editExif( context: Context, path: String, uri: Uri, mimeType: String, - sizeBytes: Long, - callback: ImageOpCallback, - editExif: (exif: ExifInterface) -> Unit, + callback: ImageOpCallback, + trailerDiff: Int = 0, + edit: (exif: ExifInterface) -> Unit, ): Boolean { if (!canEditExif(mimeType)) { callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) @@ -359,24 +369,25 @@ abstract class ImageProvider { return false } - val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt() + val originalFileSize = File(path).length() + val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } var videoBytes: ByteArray? = null val editableFile = File.createTempFile("aves", null).apply { deleteOnExit() try { outputStream().use { output -> - if (videoSizeBytes != null) { + if (videoSize != null) { // handle motion photo and embedded video separately - val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt() - videoBytes = ByteArray(videoSizeBytes) + val imageSize = (originalFileSize - videoSize).toInt() + videoBytes = ByteArray(videoSize) StorageUtils.openInputStream(context, uri)?.let { input -> - val imageBytes = ByteArray(imageSizeBytes) - input.read(imageBytes, 0, imageSizeBytes) - input.read(videoBytes, 0, videoSizeBytes) + val imageBytes = ByteArray(imageSize) + input.read(imageBytes, 0, imageSize) + input.read(videoBytes, 0, videoSize) // copy only the image to a temporary file for editing - // video will be appended after EXIF modification + // video will be appended after metadata modification ByteArrayInputStream(imageBytes).use { imageInput -> imageInput.copyTo(output) } @@ -395,16 +406,19 @@ abstract class ImageProvider { } try { - val exif = ExifInterface(editableFile) - - editExif(exif) + edit(ExifInterface(editableFile)) if (videoBytes != null) { - // append motion photo video, if any + // append trailer video, if any editableFile.appendBytes(videoBytes!!) } + // copy the edited temporary file back to the original DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + + if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + return false + } } catch (e: IOException) { callback.onFailure(e) return false @@ -413,10 +427,128 @@ abstract class ImageProvider { return true } - fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) { + private fun editXmp( + context: Context, + path: String, + uri: Uri, + mimeType: String, + callback: ImageOpCallback, + trailerDiff: Int = 0, + edit: (xmp: String) -> String, + ): Boolean { + if (!canEditXmp(mimeType)) { + callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) + return false + } + + val originalDocumentFile = getDocumentFile(context, path, uri) + if (originalDocumentFile == null) { + callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) + return false + } + + val originalFileSize = File(path).length() + val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } + val editableFile = File.createTempFile("aves", null).apply { + deleteOnExit() + try { + val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) } + if (xmp == null) { + callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) + return false + } + + outputStream().use { output -> + // reopen input to read from start + originalDocumentFile.openInputStream().use { input -> + val editedXmpString = edit(xmp.xmpDocString()) + val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null + PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) + } + } + } catch (e: Exception) { + callback.onFailure(e) + return false + } + } + + try { + // copy the edited temporary file back to the original + DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + + if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + return false + } + } catch (e: IOException) { + callback.onFailure(e) + return false + } + + return true + } + + // A few bytes are sometimes appended when writing to a document output stream. + // In that case, we need to adjust the trailer video offset accordingly and rewrite the file. + // return whether the file at `path` is fine + private fun checkTrailerOffset( + context: Context, + path: String, + uri: Uri, + mimeType: String, + trailerOffset: Int?, + editedFile: File, + callback: ImageOpCallback, + ): Boolean { + if (trailerOffset == null) return true + + val expectedLength = editedFile.length() + val actualLength = File(path).length() + val diff = (actualLength - expectedLength).toInt() + if (diff == 0) return true + + Log.w( + LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " + + "We need to edit XMP to adjust trailer video offset by $diff bytes." + ) + val newTrailerOffset = trailerOffset + diff + return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp -> + xmp.replace( + // GCamera motion photo + "${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"", + "${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"", + ).replace( + // Container motion photo + "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"", + "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", + ) + } + } + + private fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> + val projection = arrayOf( + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.SIZE, + ) + try { + val cursor = context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) } + cursor.close() + } + } catch (e: Exception) { + callback.onFailure(e) + return@scanFile + } + callback.onSuccess(newFields) + } + } + + fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { val newFields = HashMap() - val success = editExif(context, path, uri, mimeType, sizeBytes, callback) { exif -> + val success = editExif(context, path, uri, mimeType, callback) { exif -> // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` // in that case we explicitly set it to `normal` first // because ExifInterface fails to rotate an image with undefined orientation @@ -434,21 +566,9 @@ abstract class ImageProvider { newFields["rotationDegrees"] = exif.rotationDegrees newFields["isFlipped"] = exif.isFlipped } - if (!success) return - MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> - val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED) - try { - val cursor = context.contentResolver.query(uri, projection, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } - cursor.close() - } - } catch (e: Exception) { - callback.onFailure(e) - return@scanFile - } - callback.onSuccess(newFields) + if (success) { + scanPostExifEdit(context, path, uri, mimeType, newFields, callback) } } @@ -457,18 +577,17 @@ abstract class ImageProvider { path: String, uri: Uri, mimeType: String, - sizeBytes: Long, dateMillis: Long?, shiftMinutes: Long?, fields: List, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { if (dateMillis != null && dateMillis < 0) { callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative")) return } - val success = editExif(context, path, uri, mimeType, sizeBytes, callback) { exif -> + val success = editExif(context, path, uri, mimeType, callback) { exif -> when { dateMillis != null -> { // set @@ -541,8 +660,9 @@ abstract class ImageProvider { } exif.saveAttributes() } + if (success) { - callback.onSuccess(true) + scanPostExifEdit(context, path, uri, mimeType, HashMap(), callback) } } @@ -605,8 +725,8 @@ abstract class ImageProvider { } } - interface ImageOpCallback { - fun onSuccess(res: T) + interface ImageOpCallback { + fun onSuccess(fields: FieldMap) fun onFailure(throwable: Throwable) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index c8ab6342a..4c04b8907 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -38,7 +38,7 @@ class MediaStoreImageProvider : ImageProvider() { fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) } - override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { + override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { val id = uri.tryParseId() val onSuccess = fun(entry: FieldMap) { entry["uri"] = uri.toString() @@ -255,7 +255,7 @@ class MediaStoreImageProvider : ImageProvider() { copy: Boolean, destinationDir: String, entries: List, - callback: ImageOpCallback, + callback: ImageOpCallback, ) { val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) if (destinationDirDocFile == null) { 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 c0426c55c..7cf0e2efe 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 @@ -23,7 +23,7 @@ object MimeTypes { // raw raster private const val ARW = "image/x-sony-arw" private const val CR2 = "image/x-canon-cr2" - private const val DNG = "image/x-adobe-dng" + const val DNG = "image/x-adobe-dng" private const val NEF = "image/x-nikon-nef" private const val NRW = "image/x-nikon-nrw" private const val ORF = "image/x-olympus-orf" @@ -81,6 +81,11 @@ object MimeTypes { // no support for TIFF images, but it can actually open them (maybe other formats too) fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict + fun isSupportedByPixyMeta(mimeType: String) = when (mimeType) { + JPEG, TIFF, PNG, GIF, BMP -> true + else -> false + } + // Glide automatically applies EXIF orientation when decoding images of known formats // but we need to rotate the decoded bitmap for the other formats // maybe related to ExifInterface version used by Glide: diff --git a/android/build.gradle b/android/build.gradle index ab428ae27..8a8b8f28c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.5.21' + ext.kotlin_version = '1.5.30' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.0' + classpath 'com.android.tools.build:gradle:7.0.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.3.10' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 9b9d62e9d..f4242af4d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -5,6 +5,7 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/video/metadata.dart'; @@ -29,7 +30,7 @@ class AvesEntry { int width; int height; int sourceRotationDegrees; - final int? sizeBytes; + int? sizeBytes; String? _sourceTitle; // `dateModifiedSecs` can be missing in viewer mode @@ -560,6 +561,8 @@ class AvesEntry { final durationMillis = newFields['durationMillis']; if (durationMillis is int) this.durationMillis = durationMillis; + final sizeBytes = newFields['sizeBytes']; + if (sizeBytes is int) this.sizeBytes = sizeBytes; final dateModifiedSecs = newFields['dateModifiedSecs']; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; final rotationDegrees = newFields['rotationDegrees']; @@ -599,6 +602,15 @@ class AvesEntry { return true; } + Future editDate(DateModifier modifier, {required bool persist}) async { + final newFields = await imageFileService.editDate(this, modifier); + if (newFields.isEmpty) return false; + + await _applyNewFields(newFields, persist: persist); + await catalog(background: false, persist: persist, force: true); + return true; + } + Future delete() { final completer = Completer(); imageFileService.delete([this]).listen( diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index fb6ff46f3..d99d9e652 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -7,7 +7,6 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/enums.dart'; @@ -158,14 +157,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } - Future editEntryDate(AvesEntry entry, DateModifier modifier) async { - final success = await imageFileService.editDate(entry, modifier); - if (!success) return false; - - await entry.catalog(background: false, force: true); - return true; - } - Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { if (newName == entry.filenameWithoutExtension) return true; final newFields = await imageFileService.rename(entry, '$newName${entry.extension}'); diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 2dd85b45c..274f5b838 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -4,6 +4,7 @@ class XMP { // cf https://exiftool.org/TagNames/XMP.html static const Map namespaces = { + 'acdsee': 'ACDSee', 'adsml-at': 'AdsML', 'aux': 'Exif Aux', 'avm': 'Astronomy Visualization', diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index d106669a3..947358f82 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -136,6 +136,20 @@ class AndroidDebugService { return {}; } + static Future getPixyMetadata(AvesEntry entry) async { + try { + // returns map with all data available from the `PixyMeta` library + final result = await platform.invokeMethod('getPixyMetadata', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }); + if (result != null) return result as Map; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return {}; + } + static Future getTiffStructure(AvesEntry entry) async { if (entry.mimeType != MimeTypes.tiff) return {}; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 8c6853b9c..ea1cef5ec 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -97,7 +97,7 @@ abstract class ImageFileService { Future> flip(AvesEntry entry); - Future editDate(AvesEntry entry, DateModifier modifier); + Future> editDate(AvesEntry entry, DateModifier modifier); } class PlatformImageFileService implements ImageFileService { @@ -414,7 +414,7 @@ class PlatformImageFileService implements ImageFileService { } @override - Future editDate(AvesEntry entry, DateModifier modifier) async { + Future> editDate(AvesEntry entry, DateModifier modifier) async { try { final result = await platform.invokeMethod('editDate', { 'entry': _toPlatformEntryMap(entry), @@ -422,11 +422,11 @@ class PlatformImageFileService implements ImageFileService { 'shiftMinutes': modifier.shiftMinutes, 'fields': modifier.fields.map(_toExifInterfaceTag).toList(), }); - if (result != null) return result as bool; + if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } - return false; + return {}; } String _toExifInterfaceTag(MetadataField field) { diff --git a/lib/widgets/viewer/debug/metadata.dart b/lib/widgets/viewer/debug/metadata.dart index 198269ea1..abe90ed89 100644 --- a/lib/widgets/viewer/debug/metadata.dart +++ b/lib/widgets/viewer/debug/metadata.dart @@ -22,7 +22,7 @@ class MetadataTab extends StatefulWidget { } class _MetadataTabState extends State { - late Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader; + late Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader; // MediaStore timestamp keys static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; @@ -42,6 +42,7 @@ class _MetadataTabState extends State { _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); + _pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry); _tiffStructureLoader = AndroidDebugService.getTiffStructure(entry); setState(() {}); } @@ -107,6 +108,10 @@ class _MetadataTabState extends State { future: _metadataExtractorLoader, builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'), ), + FutureBuilder( + future: _pixyMetaLoader, + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'), + ), if (entry.mimeType == MimeTypes.tiff) FutureBuilder( future: _tiffStructureLoader, diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 175f86937..c79b061ec 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -14,7 +13,6 @@ import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; @@ -100,7 +98,7 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi if (!await checkStoragePermission(context, {entry})) return; // TODO TLAD [meta edit] handle viewer mode - final success = await context.read().editEntryDate(entry, modifier); + final success = await entry.editDate(modifier, persist: true); if (success) { showFeedback(context, context.l10n.genericSuccessFeedback); } else {