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.caverock:androidsvg-aar:1.4'
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
|
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
|
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'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.2.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.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
|
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
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.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
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) }
|
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
|
"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) }
|
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
|
||||||
else -> result.notImplemented()
|
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) {
|
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
val dirs = hashMapOf(
|
val dirs = hashMapOf(
|
||||||
"cacheDir" to context.cacheDir,
|
"cacheDir" to context.cacheDir,
|
||||||
|
|
|
@ -15,11 +15,10 @@ class DeviceHandler : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
||||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
||||||
// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
return
|
||||||
// return
|
}
|
||||||
// }
|
|
||||||
result.success(Build.VERSION.SDK_INT)
|
result.success(Build.VERSION.SDK_INT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -211,13 +211,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
)
|
)
|
||||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback<FieldMap> {
|
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) {
|
override fun onSuccess(fields: FieldMap) {
|
||||||
resultFields.putAll(res)
|
resultFields.putAll(fields)
|
||||||
result.success(resultFields)
|
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 {
|
} else {
|
||||||
|
|
|
@ -58,9 +58,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback<FieldMap> {
|
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = result.success(res)
|
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)
|
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)
|
destinationDir = ensureTrailingSeparator(destinationDir)
|
||||||
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback<FieldMap> {
|
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = result.success(res)
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback<FieldMap> {
|
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = result.success(res)
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
|
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 uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
|
if (uri == null || path == null || mimeType == null) {
|
||||||
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
|
|
||||||
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -231,9 +230,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback<FieldMap> {
|
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = result.success(res)
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
|
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 uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
|
if (uri == null || path == null || mimeType == null) {
|
||||||
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
|
|
||||||
result.error("editDate-args", "failed because entry fields are missing", null)
|
result.error("editDate-args", "failed because entry fields are missing", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -262,9 +260,9 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.editDate(activity, path, uri, mimeType, sizeBytes, dateMillis, shiftMinutes, fields, object : ImageOpCallback<Boolean> {
|
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: Boolean) = result.success(res)
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", throwable.message)
|
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)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback<FieldMap> {
|
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = success(res)
|
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
||||||
})
|
})
|
||||||
endOfStream()
|
endOfStream()
|
||||||
|
@ -168,8 +168,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback<FieldMap> {
|
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback {
|
||||||
override fun onSuccess(res: FieldMap) = success(res)
|
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||||
})
|
})
|
||||||
endOfStream()
|
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 com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||||
import deckers.thibault.aves.model.FieldMap
|
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
|
|
||||||
internal class ContentImageProvider : ImageProvider() {
|
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
|
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||||
var extractorMimeType: String? = null
|
var extractorMimeType: String? = null
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -2,12 +2,11 @@ package deckers.thibault.aves.model.provider
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.model.FieldMap
|
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
internal class FileImageProvider : ImageProvider() {
|
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) {
|
if (sourceMimeType == null) {
|
||||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||||
return
|
return
|
||||||
|
|
|
@ -20,27 +20,29 @@ import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
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.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.utils.*
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
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.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.*
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
abstract class ImageProvider {
|
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())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +50,7 @@ abstract class ImageProvider {
|
||||||
throw UnsupportedOperationException()
|
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())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +59,7 @@ abstract class ImageProvider {
|
||||||
imageExportMimeType: String,
|
imageExportMimeType: String,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback<FieldMap>,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||||
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
||||||
|
@ -205,7 +207,7 @@ abstract class ImageProvider {
|
||||||
exifFields: FieldMap,
|
exifFields: FieldMap,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
callback: ImageOpCallback<FieldMap>,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
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 oldFile = File(oldPath)
|
||||||
val newFile = File(oldFile.parent, newFilename)
|
val newFile = File(oldFile.parent, newFilename)
|
||||||
if (oldFile == newFile) {
|
if (oldFile == newFile) {
|
||||||
|
@ -331,22 +333,30 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// support for writing EXIF
|
// 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 {
|
private fun canEditExif(mimeType: String): Boolean {
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
|
MimeTypes.DNG,
|
||||||
|
MimeTypes.JPEG,
|
||||||
|
MimeTypes.PNG,
|
||||||
|
MimeTypes.WEBP -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> editExif(
|
// support for writing XMP
|
||||||
|
private fun canEditXmp(mimeType: String): Boolean {
|
||||||
|
return isSupportedByPixyMeta(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editExif(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
sizeBytes: Long,
|
callback: ImageOpCallback,
|
||||||
callback: ImageOpCallback<T>,
|
trailerDiff: Int = 0,
|
||||||
editExif: (exif: ExifInterface) -> Unit,
|
edit: (exif: ExifInterface) -> Unit,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!canEditExif(mimeType)) {
|
if (!canEditExif(mimeType)) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
@ -359,24 +369,25 @@ abstract class ImageProvider {
|
||||||
return false
|
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
|
var videoBytes: ByteArray? = null
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
if (videoSizeBytes != null) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSizeBytes)
|
videoBytes = ByteArray(videoSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSizeBytes)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSizeBytes)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSizeBytes)
|
input.read(videoBytes, 0, videoSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// 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 ->
|
ByteArrayInputStream(imageBytes).use { imageInput ->
|
||||||
imageInput.copyTo(output)
|
imageInput.copyTo(output)
|
||||||
}
|
}
|
||||||
|
@ -395,16 +406,19 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val exif = ExifInterface(editableFile)
|
edit(ExifInterface(editableFile))
|
||||||
|
|
||||||
editExif(exif)
|
|
||||||
|
|
||||||
if (videoBytes != null) {
|
if (videoBytes != null) {
|
||||||
// append motion photo video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(videoBytes!!)
|
editableFile.appendBytes(videoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
||||||
|
|
||||||
|
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return false
|
return false
|
||||||
|
@ -413,10 +427,128 @@ abstract class ImageProvider {
|
||||||
return true
|
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 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)`
|
// 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
|
// in that case we explicitly set it to `normal` first
|
||||||
// because ExifInterface fails to rotate an image with undefined orientation
|
// because ExifInterface fails to rotate an image with undefined orientation
|
||||||
|
@ -434,21 +566,9 @@ abstract class ImageProvider {
|
||||||
newFields["rotationDegrees"] = exif.rotationDegrees
|
newFields["rotationDegrees"] = exif.rotationDegrees
|
||||||
newFields["isFlipped"] = exif.isFlipped
|
newFields["isFlipped"] = exif.isFlipped
|
||||||
}
|
}
|
||||||
if (!success) return
|
|
||||||
|
|
||||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
if (success) {
|
||||||
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
|
scanPostExifEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -457,18 +577,17 @@ abstract class ImageProvider {
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
sizeBytes: Long,
|
|
||||||
dateMillis: Long?,
|
dateMillis: Long?,
|
||||||
shiftMinutes: Long?,
|
shiftMinutes: Long?,
|
||||||
fields: List<String>,
|
fields: List<String>,
|
||||||
callback: ImageOpCallback<Boolean>,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
if (dateMillis != null && dateMillis < 0) {
|
if (dateMillis != null && dateMillis < 0) {
|
||||||
callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative"))
|
callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val success = editExif(context, path, uri, mimeType, sizeBytes, callback) { exif ->
|
val success = editExif(context, path, uri, mimeType, callback) { exif ->
|
||||||
when {
|
when {
|
||||||
dateMillis != null -> {
|
dateMillis != null -> {
|
||||||
// set
|
// set
|
||||||
|
@ -541,8 +660,9 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
exif.saveAttributes()
|
exif.saveAttributes()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
callback.onSuccess(true)
|
scanPostExifEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -605,8 +725,8 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageOpCallback<T> {
|
interface ImageOpCallback {
|
||||||
fun onSuccess(res: T)
|
fun onSuccess(fields: FieldMap)
|
||||||
fun onFailure(throwable: Throwable)
|
fun onFailure(throwable: Throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
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 id = uri.tryParseId()
|
||||||
val onSuccess = fun(entry: FieldMap) {
|
val onSuccess = fun(entry: FieldMap) {
|
||||||
entry["uri"] = uri.toString()
|
entry["uri"] = uri.toString()
|
||||||
|
@ -255,7 +255,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback<FieldMap>,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
|
|
|
@ -23,7 +23,7 @@ object MimeTypes {
|
||||||
// raw raster
|
// raw raster
|
||||||
private const val ARW = "image/x-sony-arw"
|
private const val ARW = "image/x-sony-arw"
|
||||||
private const val CR2 = "image/x-canon-cr2"
|
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 NEF = "image/x-nikon-nef"
|
||||||
private const val NRW = "image/x-nikon-nrw"
|
private const val NRW = "image/x-nikon-nrw"
|
||||||
private const val ORF = "image/x-olympus-orf"
|
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)
|
// 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 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
|
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||||
// but we need to rotate the decoded bitmap for the other formats
|
// but we need to rotate the decoded bitmap for the other formats
|
||||||
// maybe related to ExifInterface version used by Glide:
|
// 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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.5.21'
|
ext.kotlin_version = '1.5.30'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.10'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
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/favourites.dart';
|
||||||
import 'package:aves/model/metadata/address.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/multipage.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/video/metadata.dart';
|
import 'package:aves/model/video/metadata.dart';
|
||||||
|
@ -29,7 +30,7 @@ class AvesEntry {
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int sourceRotationDegrees;
|
int sourceRotationDegrees;
|
||||||
final int? sizeBytes;
|
int? sizeBytes;
|
||||||
String? _sourceTitle;
|
String? _sourceTitle;
|
||||||
|
|
||||||
// `dateModifiedSecs` can be missing in viewer mode
|
// `dateModifiedSecs` can be missing in viewer mode
|
||||||
|
@ -560,6 +561,8 @@ class AvesEntry {
|
||||||
final durationMillis = newFields['durationMillis'];
|
final durationMillis = newFields['durationMillis'];
|
||||||
if (durationMillis is int) this.durationMillis = durationMillis;
|
if (durationMillis is int) this.durationMillis = durationMillis;
|
||||||
|
|
||||||
|
final sizeBytes = newFields['sizeBytes'];
|
||||||
|
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
||||||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||||
final rotationDegrees = newFields['rotationDegrees'];
|
final rotationDegrees = newFields['rotationDegrees'];
|
||||||
|
@ -599,6 +602,15 @@ class AvesEntry {
|
||||||
return true;
|
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() {
|
Future<bool> delete() {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
imageFileService.delete([this]).listen(
|
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/filters.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/tag.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/enums.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 {
|
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
|
||||||
if (newName == entry.filenameWithoutExtension) return true;
|
if (newName == entry.filenameWithoutExtension) return true;
|
||||||
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
|
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
|
||||||
|
|
|
@ -4,6 +4,7 @@ class XMP {
|
||||||
|
|
||||||
// cf https://exiftool.org/TagNames/XMP.html
|
// cf https://exiftool.org/TagNames/XMP.html
|
||||||
static const Map<String, String> namespaces = {
|
static const Map<String, String> namespaces = {
|
||||||
|
'acdsee': 'ACDSee',
|
||||||
'adsml-at': 'AdsML',
|
'adsml-at': 'AdsML',
|
||||||
'aux': 'Exif Aux',
|
'aux': 'Exif Aux',
|
||||||
'avm': 'Astronomy Visualization',
|
'avm': 'Astronomy Visualization',
|
||||||
|
|
|
@ -136,6 +136,20 @@ class AndroidDebugService {
|
||||||
return {};
|
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 {
|
static Future<Map> getTiffStructure(AvesEntry entry) async {
|
||||||
if (entry.mimeType != MimeTypes.tiff) return {};
|
if (entry.mimeType != MimeTypes.tiff) return {};
|
||||||
|
|
||||||
|
|
|
@ -97,7 +97,7 @@ abstract class ImageFileService {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
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 {
|
class PlatformImageFileService implements ImageFileService {
|
||||||
|
@ -414,7 +414,7 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> editDate(AvesEntry entry, DateModifier modifier) async {
|
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
@ -422,11 +422,11 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
'shiftMinutes': modifier.shiftMinutes,
|
'shiftMinutes': modifier.shiftMinutes,
|
||||||
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
|
'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) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
return false;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _toExifInterfaceTag(MetadataField field) {
|
String _toExifInterfaceTag(MetadataField field) {
|
||||||
|
|
|
@ -22,7 +22,7 @@ class MetadataTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataTabState extends State<MetadataTab> {
|
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
|
// MediaStore timestamp keys
|
||||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||||
|
@ -42,6 +42,7 @@ class _MetadataTabState extends State<MetadataTab> {
|
||||||
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
||||||
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
||||||
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
||||||
|
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
|
||||||
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
@ -107,6 +108,10 @@ class _MetadataTabState extends State<MetadataTab> {
|
||||||
future: _metadataExtractorLoader,
|
future: _metadataExtractorLoader,
|
||||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
|
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)
|
if (entry.mimeType == MimeTypes.tiff)
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _tiffStructureLoader,
|
future: _tiffStructureLoader,
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:aves/model/actions/entry_info_actions.dart';
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.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/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.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:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin {
|
class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
@ -100,7 +98,7 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
|
||||||
if (!await checkStoragePermission(context, {entry})) return;
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
// TODO TLAD [meta edit] handle viewer mode
|
// 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) {
|
if (success) {
|
||||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in a new issue