fixed motion photo offset becoming invalid after editing exif
This commit is contained in:
parent
1c4db4d8e7
commit
da66a90716
20 changed files with 333 additions and 108 deletions
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()}")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}');
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue