From c26e6bcbcf313df46acb3b94b1d31893df45c204 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 12 Apr 2022 10:58:32 +0900 Subject: [PATCH] info: action to convert motion photo to still image --- CHANGELOG.md | 5 +- android/app/src/main/AndroidManifest.xml | 2 + .../aves/channel/calls/MetadataEditHandler.kt | 38 ++++++- .../streams/StorageAccessStreamHandler.kt | 7 +- .../aves/model/provider/ImageProvider.kt | 93 ++++++++++++++-- .../thibault/aves/utils/StorageUtils.kt | 6 +- android/build.gradle | 2 +- lib/l10n/app_en.arb | 4 +- lib/model/actions/entry_info_actions.dart | 13 ++- lib/model/entry_metadata_edition.dart | 87 +++++++++++++-- .../metadata/metadata_edit_service.dart | 26 ++++- lib/theme/icons.dart | 2 + lib/utils/xmp_utils.dart | 50 ++++++--- .../action/entry_info_action_delegate.dart | 32 ++++++ lib/widgets/viewer/info/info_app_bar.dart | 9 +- test/utils/xmp_utils_test.dart | 102 ++++++++++++++---- untranslated.json | 34 ++++-- 17 files changed, 430 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b6c1267..8ee1b1c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ All notable changes to this project will be documented in this file. ### Added - Info: improved GeoTIFF section -- GeoTIFF: locating from GeoTIFF metadata (requires rescan, limited to some projections) -- GeoTIFF: overlay on map (limited to some projections) +- Cataloguing: locating from GeoTIFF metadata (requires rescan, limited to some projections) +- Info: action to overlay GeoTIFF on map (limited to some projections) +- Info: action to convert motion photo to still image - Italian translation (thanks glemco) ### Changed diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9a3c582c5..de4434e63 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,9 @@ So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage` --> + + ioScope.launch { safe(call, result, ::flip) } "editDate" -> ioScope.launch { safe(call, result, ::editDate) } "editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) } + "removeTrailerVideo" -> ioScope.launch { safe(call, result, ::removeTrailerVideo) } "removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) } else -> result.notImplemented() } @@ -101,7 +105,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { private fun editMetadata(call: MethodCall, result: MethodChannel.Result) { val metadata = call.argument("metadata") val entryMap = call.argument("entry") - if (entryMap == null || metadata == null) { + val autoCorrectTrailerOffset = call.argument("autoCorrectTrailerOffset") + if (entryMap == null || metadata == null || autoCorrectTrailerOffset == null) { result.error("editMetadata-args", "failed because of missing arguments", null) return } @@ -120,12 +125,39 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { return } - provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback { + provider.editMetadata(activity, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message) }) } + private fun removeTrailerVideo(call: MethodCall, result: MethodChannel.Result) { + val entryMap = call.argument("entry") + if (entryMap == null) { + result.error("removeTrailerVideo-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("removeTrailerVideo-args", "failed because entry fields are missing", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("removeTrailerVideo-provider", "failed to find provider for uri=$uri", null) + return + } + + provider.removeTrailerVideo(activity, path, uri, mimeType, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message) + }) + } + private fun removeTypes(call: MethodCall, result: MethodChannel.Result) { val types = call.argument>("types") val entryMap = call.argument("entry") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 097590816..33397a1b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.io.FileOutputStream // starting activity to give access with the native dialog // breaks the regular `MethodChannel` so we use a stream channel instead @@ -124,10 +123,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri -> ioScope.launch { try { - activity.contentResolver.openOutputStream(uri)?.use { output -> - output as FileOutputStream - // truncate is necessary when overwriting a longer file - output.channel.truncate(0) + // truncate is necessary when overwriting a longer file + activity.contentResolver.openOutputStream(uri, "wt")?.use { output -> output.write(bytes) } success(true) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 0c25fc37f..aa7801064 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -409,6 +409,7 @@ abstract class ImageProvider { uri: Uri, mimeType: String, callback: ImageOpCallback, + autoCorrectTrailerOffset: Boolean = true, trailerDiff: Int = 0, edit: (exif: ExifInterface) -> Unit, ): Boolean { @@ -464,7 +465,7 @@ abstract class ImageProvider { // copy the edited temporary file back to the original copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) - if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } } catch (e: IOException) { @@ -481,6 +482,7 @@ abstract class ImageProvider { uri: Uri, mimeType: String, callback: ImageOpCallback, + autoCorrectTrailerOffset: Boolean = true, trailerDiff: Int = 0, iptc: List?, ): Boolean { @@ -550,7 +552,7 @@ abstract class ImageProvider { // copy the edited temporary file back to the original copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) - if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } } catch (e: IOException) { @@ -569,6 +571,7 @@ abstract class ImageProvider { uri: Uri, mimeType: String, callback: ImageOpCallback, + autoCorrectTrailerOffset: Boolean = true, trailerDiff: Int = 0, coreXmp: String? = null, extendedXmp: String? = null, @@ -624,7 +627,7 @@ abstract class ImageProvider { // copy the edited temporary file back to the original copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) - if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false } } catch (e: IOException) { @@ -812,12 +815,20 @@ abstract class ImageProvider { uri: Uri, mimeType: String, modifier: FieldMap, + autoCorrectTrailerOffset: Boolean, callback: ImageOpCallback, ) { if (modifier.containsKey("exif")) { val fields = modifier["exif"] as Map<*, *>? if (fields != null && fields.isNotEmpty()) { - if (!editExif(context, path, uri, mimeType, callback) { exif -> + if (!editExif( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + autoCorrectTrailerOffset = autoCorrectTrailerOffset, + ) { exif -> var setLocation = false fields.forEach { kv -> val tag = kv.key as String? @@ -859,7 +870,8 @@ abstract class ImageProvider { } } exif.saveAttributes() - }) return + } + ) return } } @@ -871,6 +883,7 @@ abstract class ImageProvider { uri = uri, mimeType = mimeType, callback = callback, + autoCorrectTrailerOffset = autoCorrectTrailerOffset, iptc = iptc, ) ) return @@ -887,6 +900,7 @@ abstract class ImageProvider { uri = uri, mimeType = mimeType, callback = callback, + autoCorrectTrailerOffset = autoCorrectTrailerOffset, coreXmp = coreXmp, extendedXmp = extendedXmp, ) @@ -898,6 +912,58 @@ abstract class ImageProvider { scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } + fun removeTrailerVideo( + context: Context, + path: String, + uri: Uri, + mimeType: String, + callback: ImageOpCallback, + ) { + val originalFileSize = File(path).length() + val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() + if (videoSize == null) { + callback.onFailure(Exception("failed to get trailer video size")) + return + } + val bytesToCopy = originalFileSize - videoSize + + val editableFile = File.createTempFile("aves", null).apply { + deleteOnExit() + try { + outputStream().use { output -> + // reopen input to read from start + StorageUtils.openInputStream(context, uri)?.use { input -> + // partial copy + var bytesRemaining: Long = bytesToCopy + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = input.read(buffer) + while (bytes >= 0 && bytesRemaining > 0) { + val len = if (bytes > bytesRemaining) bytesRemaining.toInt() else bytes + output.write(buffer, 0, len) + bytesRemaining -= len + bytes = input.read(buffer) + } + } + } + } catch (e: Exception) { + Log.d(LOG_TAG, "failed to remove trailer video", e) + callback.onFailure(e) + return + } + } + + try { + // copy the edited temporary file back to the original + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + } catch (e: IOException) { + callback.onFailure(e) + return + } + + val newFields = HashMap() + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) + } + fun removeMetadataTypes( context: Context, path: String, @@ -952,12 +1018,17 @@ abstract class ImageProvider { targetUri: Uri, targetPath: String ) { - if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { - val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri") - DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) - } else { - val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri") - DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile) + sourceFile.inputStream().use { input -> + // truncate is necessary when overwriting a longer file + val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { + StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri") + } else { + val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri") + context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri") + } + targetStream.use { output -> + input.copyTo(output) + } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 333816f10..9e5b51ced 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -579,14 +579,14 @@ object StorageUtils { } } - fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? { + fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? { val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType) return try { - context.contentResolver.openOutputStream(effectiveUri) + context.contentResolver.openOutputStream(effectiveUri, mode) } catch (e: Exception) { // among various other exceptions, // opening a file marked pending and owned by another package throws an `IllegalStateException` - Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri", e) + Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e) null } } diff --git a/android/build.gradle b/android/build.gradle index d07057b80..ec015c9b9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // GMS & Firebase Crashlytics are not actually used by all flavors classpath 'com.google.gms:google-services:4.3.10' diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a04930ee5..712fb88d7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -87,7 +87,8 @@ "entryActionShare": "Share", "entryActionViewSource": "View source", "entryActionShowGeoTiffOnMap": "Show as map overlay", - "entryActionViewMotionPhotoVideo": "Open Motion Photo", + "entryActionConvertMotionPhotoToStillImage": "Convert to still image", + "entryActionViewMotionPhotoVideo": "Open video", "entryActionEdit": "Edit", "entryActionOpen": "Open with", "entryActionSetAs": "Set as", @@ -370,6 +371,7 @@ "removeEntryMetadataDialogMore": "More", "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?", + "convertMotionPhotoToStillImageWarningDialogMessage": "Are you sure?", "videoSpeedDialogLabel": "Playback speed", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 783dcabc5..bba0cf0de 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -13,19 +13,24 @@ enum EntryInfoAction { // GeoTIFF showGeoTiffOnMap, // motion photo + convertMotionPhotoToStillImage, viewMotionPhotoVideo, // debug debug, } class EntryInfoActions { - static const all = [ + static const common = [ EntryInfoAction.editDate, EntryInfoAction.editLocation, EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, + ]; + + static const formatSpecific = [ EntryInfoAction.showGeoTiffOnMap, + EntryInfoAction.convertMotionPhotoToStillImage, EntryInfoAction.viewMotionPhotoVideo, ]; } @@ -48,6 +53,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { case EntryInfoAction.showGeoTiffOnMap: return context.l10n.entryActionShowGeoTiffOnMap; // motion photo + case EntryInfoAction.convertMotionPhotoToStillImage: + return context.l10n.entryActionConvertMotionPhotoToStillImage; case EntryInfoAction.viewMotionPhotoVideo: return context.l10n.entryActionViewMotionPhotoVideo; // debug @@ -87,8 +94,10 @@ extension ExtraEntryInfoAction on EntryInfoAction { case EntryInfoAction.showGeoTiffOnMap: return AIcons.map; // motion photo + case EntryInfoAction.convertMotionPhotoToStillImage: + return AIcons.convertToStillImage; case EntryInfoAction.viewMotionPhotoVideo: - return AIcons.motionPhoto; + return AIcons.openVideo; // debug case EntryInfoAction.debug: return AIcons.debug; diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index 8a9856aa7..33ae00229 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -63,6 +63,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { editCreateDateXmp(descriptions, null); break; } + return true; }), }; final newFields = await metadataEditService.editMetadata(this, metadata); @@ -156,10 +157,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { - if (missingDate != null) { + final modified = editTagsXmp(descriptions, tags); + if (modified && missingDate != null) { editCreateDateXmp(descriptions, missingDate); } - editTagsXmp(descriptions, tags); + return modified; }); } @@ -185,10 +187,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { - if (missingDate != null) { + final modified = editRatingXmp(descriptions, rating); + if (modified && missingDate != null) { editCreateDateXmp(descriptions, missingDate); } - editRatingXmp(descriptions, rating); + return modified; }); } @@ -199,6 +202,36 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + // remove: + // - trailer video + // - XMP / Container:Directory + // - XMP / GCamera:MicroVideo* + // - XMP / GCamera:MotionPhoto* + Future> removeTrailerVideo() async { + final Set dataTypes = {}; + final Map metadata = {}; + + if (!canEditXmp) return dataTypes; + + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); + + final newFields = await metadataEditService.removeTrailerVideo(this); + + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + final modified = removeContainerXmp(descriptions); + if (modified && missingDate != null) { + editCreateDateXmp(descriptions, missingDate); + } + return modified; + }); + + newFields.addAll(await metadataEditService.editMetadata(this, metadata, autoCorrectTrailerOffset: false)); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + Future> removeMetadata(Set types) async { final newFields = await metadataEditService.removeTypes(this, types); return newFields.isEmpty @@ -232,8 +265,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } @visibleForTesting - static void editTagsXmp(List descriptions, Set tags) { - XMP.setStringBag( + static bool editTagsXmp(List descriptions, Set tags) { + return XMP.setStringBag( descriptions, XMP.dcSubject, tags, @@ -243,21 +276,55 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } @visibleForTesting - static void editRatingXmp(List descriptions, int? rating) { - XMP.setAttribute( + static bool editRatingXmp(List descriptions, int? rating) { + bool modified = false; + + modified |= XMP.setAttribute( descriptions, XMP.xmpRating, (rating ?? 0) == 0 ? null : '$rating', namespace: Namespaces.xmp, strat: XmpEditStrategy.always, ); - XMP.setAttribute( + + modified |= XMP.setAttribute( descriptions, XMP.msPhotoRating, XMP.toMsPhotoRating(rating), namespace: Namespaces.microsoftPhoto, strat: XmpEditStrategy.updateIfPresent, ); + + return modified; + } + + @visibleForTesting + static bool removeContainerXmp(List descriptions) { + bool modified = false; + + modified |= XMP.removeElements( + descriptions, + XMP.containerDirectory, + Namespaces.container, + ); + + modified |= [ + XMP.gCameraMicroVideo, + XMP.gCameraMicroVideoVersion, + XMP.gCameraMicroVideoOffset, + XMP.gCameraMicroVideoPresentationTimestampUs, + XMP.gCameraMotionPhoto, + XMP.gCameraMotionPhotoVersion, + XMP.gCameraMotionPhotoPresentationTimestampUs, + ].fold(modified, (prev, name) { + return prev |= XMP.removeElements( + descriptions, + name, + Namespaces.gCamera, + ); + }); + + return modified; } // convenience methods @@ -328,7 +395,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } } - Future> _editXmp(void Function(List descriptions) apply) async { + Future> _editXmp(bool Function(List descriptions) apply) async { final xmp = await metadataFetchService.getXmp(this); final xmpString = xmp?.xmpString; final extendedXmpString = xmp?.extendedXmpString; diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 1c40637cd..bdc7cd300 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -15,7 +15,9 @@ abstract class MetadataEditService { Future> editExifDate(AvesEntry entry, DateModifier modifier); - Future> editMetadata(AvesEntry entry, Map modifier); + Future> editMetadata(AvesEntry entry, Map modifier, {bool autoCorrectTrailerOffset = true}); + + Future> removeTrailerVideo(AvesEntry entry); Future> removeTypes(AvesEntry entry, Set types); } @@ -90,11 +92,31 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> editMetadata(AvesEntry entry, Map metadata) async { + Future> editMetadata( + AvesEntry entry, + Map metadata, { + bool autoCorrectTrailerOffset = true, + }) async { try { final result = await platform.invokeMethod('editMetadata', { 'entry': _toPlatformEntryMap(entry), 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), + 'autoCorrectTrailerOffset': autoCorrectTrailerOffset, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return {}; + } + + @override + Future> removeTrailerVideo(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('removeTrailerVideo', { + 'entry': _toPlatformEntryMap(entry), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 833611a76..8a32d8260 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -53,6 +53,7 @@ class AIcons { static const IconData clear = Icons.clear_outlined; static const IconData clipboard = Icons.content_copy_outlined; static const IconData convert = Icons.transform_outlined; + static const IconData convertToStillImage = MdiIcons.movieRemoveOutline; static const IconData copy = Icons.file_copy_outlined; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; @@ -80,6 +81,7 @@ class AIcons { static const IconData name = Icons.abc_outlined; static const IconData newTier = Icons.fiber_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined; + static const IconData openVideo = MdiIcons.moviePlayOutline; static const IconData pin = Icons.push_pin_outlined; static const IconData unpin = MdiIcons.pinOffOutline; static const IconData play = Icons.play_arrow; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index 88e153dd4..883afb33a 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -2,7 +2,9 @@ import 'package:intl/intl.dart'; import 'package:xml/xml.dart'; class Namespaces { + static const container = 'http://ns.google.com/photos/1.0/container/'; static const dc = 'http://purl.org/dc/elements/1.1/'; + static const gCamera = 'http://ns.google.com/photos/1.0/camera/'; static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; static const x = 'adobe:ns:meta/'; @@ -10,7 +12,9 @@ class Namespaces { static const xmpNote = 'http://ns.adobe.com/xmp/note/'; static final defaultPrefixes = { + container: 'Container', dc: 'dc', + gCamera: 'GCamera', microsoftPhoto: 'MicrosoftPhoto', rdf: 'rdf', x: 'x', @@ -30,6 +34,7 @@ class XMP { static const xXmpmeta = 'xmpmeta'; static const rdfRoot = 'RDF'; static const rdfDescription = 'Description'; + static const containerDirectory = 'Directory'; static const dcSubject = 'subject'; static const msPhotoRating = 'Rating'; static const xmpRating = 'Rating'; @@ -37,6 +42,13 @@ class XMP { // attributes static const xXmptk = 'xmptk'; static const rdfAbout = 'about'; + static const gCameraMicroVideo = 'MicroVideo'; + static const gCameraMicroVideoVersion = 'MicroVideoVersion'; + static const gCameraMicroVideoOffset = 'MicroVideoOffset'; + static const gCameraMicroVideoPresentationTimestampUs = 'MicroVideoPresentationTimestampUs'; + static const gCameraMotionPhoto = 'MotionPhoto'; + static const gCameraMotionPhotoVersion = 'MotionPhotoVersion'; + static const gCameraMotionPhotoPresentationTimestampUs = 'MotionPhotoPresentationTimestampUs'; static const xmpCreateDate = 'CreateDate'; static const xmpMetadataDate = 'MetadataDate'; static const xmpModifyDate = 'ModifyDate'; @@ -97,7 +109,7 @@ class XMP { static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); // remove elements and attributes - static bool _removeElements(List nodes, String name, String namespace) { + static bool removeElements(List nodes, String name, String namespace) { var removed = false; nodes.forEach((node) { final elements = node.findElements(name, namespace: namespace).toSet(); @@ -115,17 +127,18 @@ class XMP { } // remove attribute/element from all nodes, and set attribute with new value, if any, in the first node - static void setAttribute( + static bool setAttribute( List nodes, String name, String? value, { required String namespace, required XmpEditStrategy strat, }) { - final removed = _removeElements(nodes, name, namespace); + final removed = removeElements(nodes, name, namespace); - if (value == null) return; + if (value == null) return removed; + bool modified = removed; if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { final node = nodes.first; _addNamespaces(node, {namespace: prefixOf(namespace)}); @@ -133,7 +146,10 @@ class XMP { // use qualified name, otherwise the namespace prefix is not added final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name'; node.setAttribute(qualifiedName, value); + modified = true; } + + return modified; } // remove attribute/element from all nodes, and create element with new value, if any, in the first node @@ -144,7 +160,7 @@ class XMP { required String namespace, required XmpEditStrategy strat, }) { - final removed = _removeElements(nodes, name, namespace); + final removed = removeElements(nodes, name, namespace); if (value == null) return; @@ -162,7 +178,7 @@ class XMP { } // remove bag from all nodes, and create bag with new values, if any, in the first node - static void setStringBag( + static bool setStringBag( List nodes, String name, Set values, { @@ -170,10 +186,11 @@ class XMP { required XmpEditStrategy strat, }) { // remove existing - final removed = _removeElements(nodes, name, namespace); + final removed = removeElements(nodes, name, namespace); - if (values.isEmpty) return; + if (values.isEmpty) return removed; + bool modified = removed; if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { final node = nodes.first; _addNamespaces(node, {namespace: prefixOf(namespace)}); @@ -192,13 +209,16 @@ class XMP { }); }); node.children.last.children.add(bagBuilder.buildFragment()); + modified = true; } + + return modified; } static Future edit( String? xmpString, Future Function() toolkit, - void Function(List descriptions) apply, { + bool Function(List descriptions) apply, { DateTime? modifyDate, }) async { XmlDocument? xmpDoc; @@ -244,7 +264,7 @@ class XMP { // get element because doc fragment cannot be used to edit descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!); } - apply(descriptions); + final modified = apply(descriptions); // clean description nodes with no children descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear()); @@ -253,10 +273,12 @@ class XMP { rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v)); if (rdf.children.isNotEmpty) { - _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); - final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); - setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); - setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + if (modified) { + _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); + final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); + setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + } } else { // clear XMP if there are no attributes or elements worth preserving xmpDoc = null; diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index da976d791..818e0f14a 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -10,6 +10,8 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; @@ -41,6 +43,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.showGeoTiffOnMap: return entry.isGeotiff; // motion photo + case EntryInfoAction.convertMotionPhotoToStillImage: case EntryInfoAction.viewMotionPhotoVideo: return entry.isMotionPhoto; // debug @@ -66,6 +69,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.showGeoTiffOnMap: return true; // motion photo + case EntryInfoAction.convertMotionPhotoToStillImage: + return entry.canEdit; case EntryInfoAction.viewMotionPhotoVideo: return true; // debug @@ -98,6 +103,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await _showGeoTiffOnMap(context); break; // motion photo + case EntryInfoAction.convertMotionPhotoToStillImage: + await _convertMotionPhotoToStillImage(context); + break; case EntryInfoAction.viewMotionPhotoVideo: OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); break; @@ -148,6 +156,30 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.removeMetadata(types)); } + Future _convertMotionPhotoToStillImage(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + content: Text(context.l10n.convertMotionPhotoToStillImageWarningDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + await edit(context, entry.removeTrailerVideo); + } + Future _showGeoTiffOnMap(BuildContext context) async { final info = await metadataFetchService.getGeoTiffInfo(entry); if (info == null) return; diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index beb758122..761d99ae9 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -29,7 +29,8 @@ class InfoAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible); + final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible); + final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible); return SliverAppBar( leading: IconButton( @@ -55,7 +56,11 @@ class InfoAppBar extends StatelessWidget { MenuIconTheme( child: PopupMenuButton( itemBuilder: (context) => [ - ...menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), + ...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), + if (formatSpecificActions.isNotEmpty) ...[ + const PopupMenuDivider(), + ...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), + ], if (!kReleaseMode) ...[ const PopupMenuDivider(), _toMenuItem(context, EntryInfoAction.debug, enabled: true), diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart index a81497a6b..489ee1677 100644 --- a/test/utils/xmp_utils_test.dart +++ b/test/utils/xmp_utils_test.dart @@ -86,6 +86,50 @@ void main() { +'''; + const inMotionPhotoMicroVideo = ''' + + + + + +'''; + const inMotionPhotoContainer = ''' + + + + + + + + + + + + + + + + '''; test('Get string', () async { @@ -362,7 +406,6 @@ void main() { test('Remove rating from XMP with subjects only', () async { final modifyDate = DateTime.now(); - final xmpDate = XMP.toXmpDate(modifyDate); expect( _toExpect(await XMP.edit( @@ -371,23 +414,46 @@ void main() { (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), modifyDate: modifyDate, )), - _toExpect(''' - - - - - - the king - - - - - -''')); + _toExpect(inSubjects)); + }); + + test('Remove trailer media info from XMP with micro video', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inMotionPhotoMicroVideo, + () async => toolkit, + ExtraAvesEntryMetadataEdition.removeContainerXmp, + modifyDate: modifyDate, + )), + _toExpect(null)); + }); + + test('Remove trailer media info from XMP with container', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inMotionPhotoContainer, + () async => toolkit, + ExtraAvesEntryMetadataEdition.removeContainerXmp, + modifyDate: modifyDate, + )), + _toExpect(null)); + }); + + test('Remove trailer media info from XMP with no related metadata', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + ExtraAvesEntryMetadataEdition.removeContainerXmp, + modifyDate: modifyDate, + )), + _toExpect(inSubjects)); }); test('Remove rating from XMP with ratings (multiple descriptions)', () async { diff --git a/untranslated.json b/untranslated.json index 75f0eefe9..0a9c3c969 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,38 +1,55 @@ { "de": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "es": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "fr": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "id": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "it": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "ja": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "ko": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "pt": [ - "entryActionShowGeoTiffOnMap" + "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", + "convertMotionPhotoToStillImageWarningDialogMessage" ], "ru": [ "entryActionShowGeoTiffOnMap", + "entryActionConvertMotionPhotoToStillImage", "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", "themeBrightnessLight", @@ -47,6 +64,7 @@ "renameProcessorCounter", "renameProcessorName", "editEntryDateDialogCopyItem", + "convertMotionPhotoToStillImageWarningDialogMessage", "collectionRenameFailureFeedback", "collectionRenameSuccessFeedback", "settingsConfirmationDialogMoveUndatedItems",