fixed motion photo offset becoming invalid after editing exif

This commit is contained in:
Thibault Deckers 2021-09-01 18:13:26 +09:00
parent 1c4db4d8e7
commit da66a90716
20 changed files with 333 additions and 108 deletions

View file

@ -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'

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getPixyMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
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,

View file

@ -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)
}

View file

@ -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<FieldMap> {
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 {

View file

@ -58,9 +58,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return
}
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback<FieldMap> {
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<FieldMap> {
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<FieldMap> {
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<FieldMap> {
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<Boolean> {
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()}")
})
}

View file

@ -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<FieldMap> {
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<FieldMap> {
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()

View file

@ -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<String, String> {
val metadataMap = HashMap<String, String>()
fun fetch(parents: String, entries: Iterable<MetadataEntry>) {
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)
}

View file

@ -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<FieldMap>) {
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 {

View file

@ -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<FieldMap>) {
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

View file

@ -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<FieldMap>) {
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<AvesEntry>, callback: ImageOpCallback<FieldMap>) {
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
@ -57,7 +59,7 @@ abstract class ImageProvider {
imageExportMimeType: String,
destinationDir: String,
entries: List<AvesEntry>,
callback: ImageOpCallback<FieldMap>,
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<FieldMap>,
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<FieldMap>) {
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 <T> 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<T>,
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<FieldMap>) {
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<String, Any?>, 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<String, Any?>()
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<String>,
callback: ImageOpCallback<Boolean>,
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<String, Any?>(), callback)
}
}
@ -605,8 +725,8 @@ abstract class ImageProvider {
}
}
interface ImageOpCallback<T> {
fun onSuccess(res: T)
interface ImageOpCallback {
fun onSuccess(fields: FieldMap)
fun onFailure(throwable: Throwable)
}

View file

@ -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<FieldMap>) {
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<AvesEntry>,
callback: ImageOpCallback<FieldMap>,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
if (destinationDirDocFile == null) {

View file

@ -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:

View file

@ -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'

View file

@ -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<bool> 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<bool> delete() {
final completer = Completer<bool>();
imageFileService.delete([this]).listen(

View file

@ -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<bool> 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<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
if (newName == entry.filenameWithoutExtension) return true;
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');

View file

@ -4,6 +4,7 @@ class XMP {
// cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> namespaces = {
'acdsee': 'ACDSee',
'adsml-at': 'AdsML',
'aux': 'Exif Aux',
'avm': 'Astronomy Visualization',

View file

@ -136,6 +136,20 @@ class AndroidDebugService {
return {};
}
static Future<Map> getPixyMetadata(AvesEntry entry) async {
try {
// returns map with all data available from the `PixyMeta` library
final result = await platform.invokeMethod('getPixyMetadata', <String, dynamic>{
'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<Map> getTiffStructure(AvesEntry entry) async {
if (entry.mimeType != MimeTypes.tiff) return {};

View file

@ -97,7 +97,7 @@ abstract class ImageFileService {
Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<bool> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
}
class PlatformImageFileService implements ImageFileService {
@ -414,7 +414,7 @@ class PlatformImageFileService implements ImageFileService {
}
@override
Future<bool> editDate(AvesEntry entry, DateModifier modifier) async {
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{
'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<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
return {};
}
String _toExifInterfaceTag(MetadataField field) {

View file

@ -22,7 +22,7 @@ class MetadataTab extends StatefulWidget {
}
class _MetadataTabState extends State<MetadataTab> {
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
late Future<Map> _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<MetadataTab> {
_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<MetadataTab> {
future: _metadataExtractorLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
),
FutureBuilder<Map>(
future: _pixyMetaLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'),
),
if (entry.mimeType == MimeTypes.tiff)
FutureBuilder<Map>(
future: _tiffStructureLoader,

View file

@ -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<CollectionSource>().editEntryDate(entry, modifier);
final success = await entry.editDate(modifier, persist: true);
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback);
} else {