info: remove metadata
This commit is contained in:
parent
4ae828710d
commit
bb145a9603
15 changed files with 435 additions and 21 deletions
|
@ -20,6 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
|
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,6 +97,34 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val types = call.argument<List<String>>("types")
|
||||||
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
if (entryMap == null || types == null) {
|
||||||
|
result.error("removeTypes-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
|
val path = entryMap["path"] as String?
|
||||||
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
result.error("removeTypes-args", "failed because entry fields are missing", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getProvider(uri)
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("removeTypes-provider", "failed to find provider for uri=$uri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.removeMetadataTypes(activity, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
|
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,16 @@ object Metadata {
|
||||||
const val DIR_MEDIA = "Media" // custom
|
const val DIR_MEDIA = "Media" // custom
|
||||||
const val DIR_COVER_ART = "Cover" // custom
|
const val DIR_COVER_ART = "Cover" // custom
|
||||||
|
|
||||||
|
// types of metadata
|
||||||
|
const val TYPE_EXIF = "exif"
|
||||||
|
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||||
|
const val TYPE_IPTC = "iptc"
|
||||||
|
const val TYPE_JFIF = "jfif"
|
||||||
|
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||||
|
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||||
|
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||||
|
const val TYPE_XMP = "xmp"
|
||||||
|
|
||||||
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||||
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
||||||
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
|
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||||
import pixy.meta.meta.Metadata
|
import pixy.meta.meta.Metadata
|
||||||
import pixy.meta.meta.MetadataEntry
|
import pixy.meta.meta.MetadataEntry
|
||||||
import pixy.meta.meta.MetadataType
|
import pixy.meta.meta.MetadataType
|
||||||
|
@ -54,4 +62,21 @@ object PixyMetaHelper {
|
||||||
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
|
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
|
||||||
|
|
||||||
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
|
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
|
||||||
|
|
||||||
|
fun removeMetadata(input: InputStream, output: OutputStream, metadataTypes: Set<String>) {
|
||||||
|
val types = metadataTypes.map(::toMetadataType).toTypedArray()
|
||||||
|
Metadata.removeMetadata(input, output, *types)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||||
|
TYPE_EXIF -> MetadataType.EXIF
|
||||||
|
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||||
|
TYPE_IPTC -> MetadataType.IPTC
|
||||||
|
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||||
|
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||||
|
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||||
|
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||||
|
TYPE_XMP -> MetadataType.XMP
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -14,19 +14,17 @@ import com.bumptech.glide.request.RequestOptions
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.*
|
||||||
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.PixyMetaHelper
|
|
||||||
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
|
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
|
||||||
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||||
import deckers.thibault.aves.metadata.XMP
|
|
||||||
import deckers.thibault.aves.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.canEditExif
|
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||||
|
@ -50,7 +48,7 @@ abstract class ImageProvider {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||||
throw UnsupportedOperationException()
|
throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -515,7 +513,14 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
|
fun editOrientation(
|
||||||
|
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, callback) { exif ->
|
val success = editExif(context, path, uri, mimeType, callback) { exif ->
|
||||||
|
@ -538,7 +543,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
scanPostExifEdit(context, path, uri, mimeType, newFields, callback)
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -632,10 +637,62 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
scanPostExifEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
scanPostMetadataEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeMetadataTypes(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
types: Set<String>,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
) {
|
||||||
|
if (!canRemoveMetadata(mimeType)) {
|
||||||
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalDocumentFile = getDocumentFile(context, path, uri)
|
||||||
|
if (originalDocumentFile == null) {
|
||||||
|
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalFileSize = File(path).length()
|
||||||
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||||
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
|
deleteOnExit()
|
||||||
|
try {
|
||||||
|
outputStream().use { output ->
|
||||||
|
// reopen input to read from start
|
||||||
|
originalDocumentFile.openInputStream().use { input ->
|
||||||
|
PixyMetaHelper.removeMetadata(input, output, types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// copy the edited temporary file back to the original
|
||||||
|
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
||||||
|
|
||||||
|
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
}
|
||||||
|
|
||||||
interface ImageOpCallback {
|
interface ImageOpCallback {
|
||||||
fun onSuccess(fields: FieldMap)
|
fun onSuccess(fields: FieldMap)
|
||||||
fun onFailure(throwable: Throwable)
|
fun onFailure(throwable: Throwable)
|
||||||
|
|
|
@ -378,7 +378,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||||
val projection = arrayOf(
|
val projection = arrayOf(
|
||||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||||
|
|
|
@ -131,6 +131,8 @@
|
||||||
|
|
||||||
"entryInfoActionEditDate": "Edit date & time",
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
"@entryInfoActionEditDate": {},
|
"@entryInfoActionEditDate": {},
|
||||||
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
|
"@entryInfoActionRemoveMetadata": {},
|
||||||
|
|
||||||
"filterFavouriteLabel": "Favourite",
|
"filterFavouriteLabel": "Favourite",
|
||||||
"@filterFavouriteLabel": {},
|
"@filterFavouriteLabel": {},
|
||||||
|
@ -325,6 +327,11 @@
|
||||||
"editEntryDateDialogMinutes": "Minutes",
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
"@editEntryDateDialogMinutes": {},
|
"@editEntryDateDialogMinutes": {},
|
||||||
|
|
||||||
|
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
||||||
|
"@removeEntryMetadataDialogTitle": {},
|
||||||
|
"removeEntryMetadataDialogMore": "More",
|
||||||
|
"@removeEntryMetadataDialogMore": {},
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "Playback speed",
|
"videoSpeedDialogLabel": "Playback speed",
|
||||||
"@videoSpeedDialogLabel": {},
|
"@videoSpeedDialogLabel": {},
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
"videoActionSettings": "설정",
|
"videoActionSettings": "설정",
|
||||||
|
|
||||||
"entryInfoActionEditDate": "날짜와 시간 수정",
|
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||||
|
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||||
|
|
||||||
"filterFavouriteLabel": "즐겨찾기",
|
"filterFavouriteLabel": "즐겨찾기",
|
||||||
"filterLocationEmptyLabel": "장소 없음",
|
"filterLocationEmptyLabel": "장소 없음",
|
||||||
|
@ -149,6 +150,9 @@
|
||||||
"editEntryDateDialogHours": "시간",
|
"editEntryDateDialogHours": "시간",
|
||||||
"editEntryDateDialogMinutes": "분",
|
"editEntryDateDialogMinutes": "분",
|
||||||
|
|
||||||
|
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||||
|
"removeEntryMetadataDialogMore": "더 보기",
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "재생 배속",
|
"videoSpeedDialogLabel": "재생 배속",
|
||||||
|
|
||||||
"videoStreamSelectionDialogVideo": "동영상",
|
"videoStreamSelectionDialogVideo": "동영상",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
editDate,
|
editDate,
|
||||||
|
removeMetadata,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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/metadata/date_modifier.dart';
|
||||||
|
import 'package:aves/model/metadata/enums.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';
|
||||||
|
@ -230,7 +231,6 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// support for writing EXIF
|
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||||
bool get canEditExif {
|
bool get canEditExif {
|
||||||
switch (mimeType.toLowerCase()) {
|
switch (mimeType.toLowerCase()) {
|
||||||
|
@ -244,6 +244,17 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
bool get canRemoveMetadata {
|
||||||
|
switch (mimeType.toLowerCase()) {
|
||||||
|
case MimeTypes.jpeg:
|
||||||
|
case MimeTypes.tiff:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||||
// so it should be registered as width=1920, height=1080, orientation=90,
|
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||||
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||||
|
@ -613,6 +624,14 @@ class AvesEntry {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> removeMetadata(Set<MetadataType> types, {required bool persist}) async {
|
||||||
|
final newFields = await metadataEditService.removeTypes(this, types);
|
||||||
|
if (newFields.isEmpty) return false;
|
||||||
|
|
||||||
|
await _applyNewFields(newFields, persist: persist);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> delete() {
|
Future<bool> delete() {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
mediaFileService.delete([this]).listen(
|
mediaFileService.delete([this]).listen(
|
||||||
|
|
|
@ -10,3 +10,67 @@ enum DateEditAction {
|
||||||
shift,
|
shift,
|
||||||
clear,
|
clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MetadataType {
|
||||||
|
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||||
|
exif,
|
||||||
|
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||||
|
iccProfile,
|
||||||
|
// IPTC: https://en.wikipedia.org/wiki/IPTC_Information_Interchange_Model
|
||||||
|
iptc,
|
||||||
|
// JPEG APP0 / JFIF: https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format
|
||||||
|
jfif,
|
||||||
|
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||||
|
jpegAdobe,
|
||||||
|
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||||
|
jpegDucky,
|
||||||
|
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||||
|
photoshopIrb,
|
||||||
|
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
|
||||||
|
xmp,
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetadataTypes {
|
||||||
|
static const main = {
|
||||||
|
MetadataType.exif,
|
||||||
|
MetadataType.xmp,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const common = {
|
||||||
|
MetadataType.exif,
|
||||||
|
MetadataType.xmp,
|
||||||
|
MetadataType.iccProfile,
|
||||||
|
MetadataType.iptc,
|
||||||
|
MetadataType.photoshopIrb,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const jpeg = {
|
||||||
|
MetadataType.jfif,
|
||||||
|
MetadataType.jpegAdobe,
|
||||||
|
MetadataType.jpegDucky,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExtraMetadataType on MetadataType {
|
||||||
|
// match `ExifInterface` directory names
|
||||||
|
String getText() {
|
||||||
|
switch (this) {
|
||||||
|
case MetadataType.exif:
|
||||||
|
return 'Exif';
|
||||||
|
case MetadataType.iccProfile:
|
||||||
|
return 'ICC Profile';
|
||||||
|
case MetadataType.iptc:
|
||||||
|
return 'IPTC';
|
||||||
|
case MetadataType.jfif:
|
||||||
|
return 'JFIF';
|
||||||
|
case MetadataType.jpegAdobe:
|
||||||
|
return 'Adobe JPEG';
|
||||||
|
case MetadataType.jpegDucky:
|
||||||
|
return 'Ducky';
|
||||||
|
case MetadataType.photoshopIrb:
|
||||||
|
return 'Photoshop';
|
||||||
|
case MetadataType.xmp:
|
||||||
|
return 'XMP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,8 @@ abstract class MetadataEditService {
|
||||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformMetadataEditService implements MetadataEditService {
|
class PlatformMetadataEditService implements MetadataEditService {
|
||||||
|
@ -77,6 +79,20 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('removeTypes', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
'types': types.map(_toPlatformMetadataType).toList(),
|
||||||
|
});
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
String _toExifInterfaceTag(MetadataField field) {
|
String _toExifInterfaceTag(MetadataField field) {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case MetadataField.exifDate:
|
case MetadataField.exifDate:
|
||||||
|
@ -89,4 +105,25 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return 'GPSDateStamp';
|
return 'GPSDateStamp';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _toPlatformMetadataType(MetadataType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MetadataType.exif:
|
||||||
|
return 'exif';
|
||||||
|
case MetadataType.iccProfile:
|
||||||
|
return 'icc_profile';
|
||||||
|
case MetadataType.iptc:
|
||||||
|
return 'iptc';
|
||||||
|
case MetadataType.jfif:
|
||||||
|
return 'jfif';
|
||||||
|
case MetadataType.jpegAdobe:
|
||||||
|
return 'jpeg_adobe';
|
||||||
|
case MetadataType.jpegDucky:
|
||||||
|
return 'jpeg_ducky';
|
||||||
|
case MetadataType.photoshopIrb:
|
||||||
|
return 'photoshop_irb';
|
||||||
|
case MetadataType.xmp:
|
||||||
|
return 'xmp';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,16 +23,18 @@ class HighlightTitle extends StatelessWidget {
|
||||||
|
|
||||||
static const disabledColor = Colors.grey;
|
static const disabledColor = Colors.grey;
|
||||||
|
|
||||||
@override
|
static const shadows = [
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final style = TextStyle(
|
|
||||||
shadows: const [
|
|
||||||
Shadow(
|
Shadow(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
offset: Offset(1, 1),
|
offset: Offset(1, 1),
|
||||||
blurRadius: 2,
|
blurRadius: 2,
|
||||||
)
|
)
|
||||||
],
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = TextStyle(
|
||||||
|
shadows: shadows,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
fontFeatures: const [FontFeature.enable('smcp')],
|
fontFeatures: const [FontFeature.enable('smcp')],
|
||||||
|
|
|
@ -71,7 +71,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(AIcons.edit),
|
icon: const Icon(AIcons.edit),
|
||||||
onPressed: _action == DateEditAction.set ? _editDate : null,
|
onPressed: _action == DateEditAction.set ? _editDate : null,
|
||||||
tooltip: context.l10n.changeTooltip,
|
tooltip: l10n.changeTooltip,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -92,7 +92,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(AIcons.edit),
|
icon: const Icon(AIcons.edit),
|
||||||
onPressed: _action == DateEditAction.shift ? _editShift : null,
|
onPressed: _action == DateEditAction.shift ? _editShift : null,
|
||||||
tooltip: context.l10n.changeTooltip,
|
tooltip: l10n.changeTooltip,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -114,7 +114,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
),
|
),
|
||||||
child: AvesDialog(
|
child: AvesDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: context.l10n.editEntryDateDialogTitle,
|
title: l10n.editEntryDateDialogTitle,
|
||||||
scrollableContent: [
|
scrollableContent: [
|
||||||
setTile,
|
setTile,
|
||||||
shiftTile,
|
shiftTile,
|
||||||
|
@ -156,7 +156,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _submit(context),
|
onPressed: () => _submit(context),
|
||||||
child: Text(context.l10n.applyButtonLabel),
|
child: Text(l10n.applyButtonLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
128
lib/widgets/dialogs/remove_entry_metadata_dialog.dart
Normal file
128
lib/widgets/dialogs/remove_entry_metadata_dialog.dart
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
import 'package:aves/utils/color_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'aves_dialog.dart';
|
||||||
|
|
||||||
|
class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||||
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
const RemoveEntryMetadataDialog({
|
||||||
|
Key? key,
|
||||||
|
required this.entry,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_RemoveEntryMetadataDialogState createState() => _RemoveEntryMetadataDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
|
late final List<MetadataType> _mainOptions, _moreOptions;
|
||||||
|
final Set<MetadataType> _types = {};
|
||||||
|
bool _showMore = false;
|
||||||
|
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final byMain = groupBy([
|
||||||
|
...MetadataTypes.common,
|
||||||
|
if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg,
|
||||||
|
], MetadataTypes.main.contains);
|
||||||
|
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
|
||||||
|
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
|
||||||
|
_validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return AvesDialog(
|
||||||
|
context: context,
|
||||||
|
title: l10n.removeEntryMetadataDialogTitle,
|
||||||
|
scrollableContent: [
|
||||||
|
..._mainOptions.map(_toTile),
|
||||||
|
if (_moreOptions.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 1),
|
||||||
|
child: ExpansionPanelList(
|
||||||
|
expansionCallback: (index, isExpanded) {
|
||||||
|
setState(() => _showMore = !isExpanded);
|
||||||
|
},
|
||||||
|
expandedHeaderPadding: EdgeInsets.zero,
|
||||||
|
elevation: 0,
|
||||||
|
children: [
|
||||||
|
ExpansionPanel(
|
||||||
|
headerBuilder: (context, isExpanded) => ListTile(
|
||||||
|
title: Text(l10n.removeEntryMetadataDialogMore),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: _moreOptions.map(_toTile).toList(),
|
||||||
|
),
|
||||||
|
isExpanded: _showMore,
|
||||||
|
canTapOnHeader: true,
|
||||||
|
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||||
|
),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _isValidNotifier,
|
||||||
|
builder: (context, isValid, child) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: isValid ? () => _submit(context) : null,
|
||||||
|
child: Text(context.l10n.applyButtonLabel),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _compareTypeText(MetadataType a, MetadataType b) => a.getText().compareTo(b.getText());
|
||||||
|
|
||||||
|
Widget _toTile(MetadataType type) {
|
||||||
|
final text = type.getText();
|
||||||
|
return SwitchListTile(
|
||||||
|
value: _types.contains(type),
|
||||||
|
onChanged: (selected) {
|
||||||
|
selected ? _types.add(type) : _types.remove(type);
|
||||||
|
_validate();
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
title: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: HighlightDecoration(
|
||||||
|
color: BrandColors.get(text) ?? stringToColor(text),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
shadows: HighlightTitle.shadows,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validate() => _isValidNotifier.value = _types.isNotEmpty;
|
||||||
|
|
||||||
|
void _submit(BuildContext context) => Navigator.pop(context, _types);
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
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/metadata/enums.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';
|
||||||
|
@ -9,10 +11,12 @@ import 'package:aves/widgets/common/app_bar_title.dart';
|
||||||
import 'package:aves/widgets/common/basic/menu.dart';
|
import 'package:aves/widgets/common/basic/menu.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_search.dart';
|
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;
|
||||||
|
@ -55,6 +59,11 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
|
||||||
enabled: entry.canEditExif,
|
enabled: entry.canEditExif,
|
||||||
child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)),
|
child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: EntryInfoAction.removeMetadata,
|
||||||
|
enabled: entry.canRemoveMetadata,
|
||||||
|
child: MenuRow(text: context.l10n.entryInfoActionRemoveMetadata, icon: const Icon(AIcons.clear)),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
|
@ -85,6 +94,9 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
await _showDateEditDialog(context);
|
await _showDateEditDialog(context);
|
||||||
break;
|
break;
|
||||||
|
case EntryInfoAction.removeMetadata:
|
||||||
|
await _showMetadataRemovalDialog(context);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,4 +117,23 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
|
||||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showMetadataRemovalDialog(BuildContext context) async {
|
||||||
|
final types = await showDialog<Set<MetadataType>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => RemoveEntryMetadataDialog(entry: entry),
|
||||||
|
);
|
||||||
|
if (types == null) return;
|
||||||
|
|
||||||
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
|
// TODO TLAD [meta edit] handle viewer mode
|
||||||
|
final success = await entry.removeMetadata(types, persist: true);
|
||||||
|
if (success) {
|
||||||
|
await context.read<CollectionSource>().refreshMetadata({entry});
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
} else {
|
||||||
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue