info: remove metadata

This commit is contained in:
Thibault Deckers 2021-09-11 17:48:50 +09:00
parent 4ae828710d
commit bb145a9603
15 changed files with 435 additions and 21 deletions

View file

@ -20,6 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
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 {
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
}

View file

@ -32,6 +32,16 @@ object Metadata {
const val DIR_MEDIA = "Media" // 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)
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90

View file

@ -1,5 +1,13 @@
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.MetadataEntry
import pixy.meta.meta.MetadataType
@ -54,4 +62,21 @@ object PixyMetaHelper {
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
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
}
}

View file

@ -14,19 +14,17 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
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.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.canEditExif
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.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
@ -50,7 +48,7 @@ abstract class ImageProvider {
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()
}
@ -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 success = editExif(context, path, uri, mimeType, callback) { exif ->
@ -538,7 +543,7 @@ abstract class ImageProvider {
}
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) {
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 {
fun onSuccess(fields: FieldMap)
fun onFailure(throwable: Throwable)

View file

@ -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)) { _, _ ->
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,

View file

@ -131,6 +131,8 @@
"entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {},
"entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {},
"filterFavouriteLabel": "Favourite",
"@filterFavouriteLabel": {},
@ -325,6 +327,11 @@
"editEntryDateDialogMinutes": "Minutes",
"@editEntryDateDialogMinutes": {},
"removeEntryMetadataDialogTitle": "Metadata Removal",
"@removeEntryMetadataDialogTitle": {},
"removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {},
"videoSpeedDialogLabel": "Playback speed",
"@videoSpeedDialogLabel": {},

View file

@ -67,6 +67,7 @@
"videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜와 시간 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음",
@ -149,6 +150,9 @@
"editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분",
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기",
"videoSpeedDialogLabel": "재생 배속",
"videoStreamSelectionDialogVideo": "동영상",

View file

@ -1,3 +1,4 @@
enum EntryInfoAction {
editDate,
removeMetadata,
}

View file

@ -6,6 +6,7 @@ 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/metadata/enums.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart';
@ -230,7 +231,6 @@ class AvesEntry {
bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.3
bool get canEditExif {
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,
// so it should be registered as width=1920, height=1080, orientation=90,
// but is incorrectly registered as width=1080, height=1920, orientation=0.
@ -613,6 +624,14 @@ class AvesEntry {
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() {
final completer = Completer<bool>();
mediaFileService.delete([this]).listen(

View file

@ -10,3 +10,67 @@ enum DateEditAction {
shift,
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';
}
}
}

View file

@ -12,6 +12,8 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
class PlatformMetadataEditService implements MetadataEditService {
@ -77,6 +79,20 @@ class PlatformMetadataEditService implements MetadataEditService {
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) {
switch (field) {
case MetadataField.exifDate:
@ -89,4 +105,25 @@ class PlatformMetadataEditService implements MetadataEditService {
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';
}
}
}

View file

@ -23,16 +23,18 @@ class HighlightTitle extends StatelessWidget {
static const disabledColor = Colors.grey;
static const shadows = [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
)
];
@override
Widget build(BuildContext context) {
final style = TextStyle(
shadows: const [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
)
],
shadows: shadows,
fontSize: fontSize,
letterSpacing: 1.0,
fontFeatures: const [FontFeature.enable('smcp')],

View file

@ -71,7 +71,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
child: IconButton(
icon: const Icon(AIcons.edit),
onPressed: _action == DateEditAction.set ? _editDate : null,
tooltip: context.l10n.changeTooltip,
tooltip: l10n.changeTooltip,
),
),
],
@ -92,7 +92,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
child: IconButton(
icon: const Icon(AIcons.edit),
onPressed: _action == DateEditAction.shift ? _editShift : null,
tooltip: context.l10n.changeTooltip,
tooltip: l10n.changeTooltip,
),
),
],
@ -114,7 +114,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
),
child: AvesDialog(
context: context,
title: context.l10n.editEntryDateDialogTitle,
title: l10n.editEntryDateDialogTitle,
scrollableContent: [
setTile,
shiftTile,
@ -156,7 +156,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
),
TextButton(
onPressed: () => _submit(context),
child: Text(context.l10n.applyButtonLabel),
child: Text(l10n.applyButtonLabel),
),
],
),

View 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);
}

View file

@ -1,6 +1,8 @@
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/metadata/enums.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';
@ -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/extensions/build_context.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/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;
@ -55,6 +59,11 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
enabled: entry.canEditExif,
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) {
@ -85,6 +94,9 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
case EntryInfoAction.editDate:
await _showDateEditDialog(context);
break;
case EntryInfoAction.removeMetadata:
await _showMetadataRemovalDialog(context);
break;
}
}
@ -105,4 +117,23 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi
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);
}
}
}