info: action to convert motion photo to still image

This commit is contained in:
Thibault Deckers 2022-04-12 10:58:32 +09:00
parent 79843d8a9a
commit c26e6bcbcf
17 changed files with 430 additions and 82 deletions

View file

@ -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

View file

@ -8,7 +8,9 @@
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
-->
<!-- TODO TLAD [tiramisu] need notification permission? -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- TODO TLAD [tiramisu] READ_MEDIA_IMAGE, READ_MEDIA_VIDEO instead of READ_EXTERNAL_STORAGE? -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

View file

@ -10,7 +10,10 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -21,6 +24,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"flip" -> 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<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null || metadata == null) {
val autoCorrectTrailerOffset = call.argument<Boolean>("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<FieldMap>("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<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry")

View file

@ -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)

View file

@ -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<FieldMap>?,
): 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<String, Any?>()
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)
}
}
}

View file

@ -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
}
}

View file

@ -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'

View file

@ -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",

View file

@ -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;

View file

@ -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<Set<EntryDataType>> removeTrailerVideo() async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> 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<Set<EntryDataType>> removeMetadata(Set<MetadataType> 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<XmlNode> descriptions, Set<String> tags) {
XMP.setStringBag(
static bool editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
return XMP.setStringBag(
descriptions,
XMP.dcSubject,
tags,
@ -243,21 +276,55 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
@visibleForTesting
static void editRatingXmp(List<XmlNode> descriptions, int? rating) {
XMP.setAttribute(
static bool editRatingXmp(List<XmlNode> 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<XmlNode> 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<bool>(modified, (prev, name) {
return prev |= XMP.removeElements(
descriptions,
name,
Namespaces.gCamera,
);
});
return modified;
}
// convenience methods
@ -328,7 +395,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
}
Future<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {
Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
final xmp = await metadataFetchService.getXmp(this);
final xmpString = xmp?.xmpString;
final extendedXmpString = xmp?.extendedXmpString;

View file

@ -15,7 +15,9 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier, {bool autoCorrectTrailerOffset = true});
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -90,11 +92,31 @@ class PlatformMetadataEditService implements MetadataEditService {
}
@override
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> metadata) async {
Future<Map<String, dynamic>> editMetadata(
AvesEntry entry,
Map<MetadataType, dynamic> metadata, {
bool autoCorrectTrailerOffset = true,
}) async {
try {
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('removeTrailerVideo', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {

View file

@ -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;

View file

@ -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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
// remove elements and attributes
static bool _removeElements(List<XmlNode> nodes, String name, String namespace) {
static bool removeElements(List<XmlNode> 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<XmlNode> 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<XmlNode> nodes,
String name,
Set<String> 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<String?> edit(
String? xmpString,
Future<String> Function() toolkit,
void Function(List<XmlNode> descriptions) apply, {
bool Function(List<XmlNode> 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;

View file

@ -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<void> _convertMotionPhotoToStillImage(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<void> _showGeoTiffOnMap(BuildContext context) async {
final info = await metadataFetchService.getGeoTiffInfo(entry);
if (info == null) return;

View file

@ -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<EntryInfoAction>(
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),

View file

@ -86,6 +86,50 @@ void main() {
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
const inMotionPhotoMicroVideo = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.0-jc003">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
GCamera:MicroVideo="1"
GCamera:MicroVideoVersion="1"
GCamera:MicroVideoOffset="1228513"
GCamera:MicroVideoPresentationTimestampUs="233246"/>
</rdf:RDF>
</x:xmpmeta>
''';
const inMotionPhotoContainer = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.0-jc003">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
xmlns:Container="http://ns.google.com/photos/1.0/container/"
xmlns:Item="http://ns.google.com/photos/1.0/container/item/"
GCamera:MotionPhoto="1"
GCamera:MotionPhotoVersion="1"
GCamera:MotionPhotoPresentationTimestampUs="2306056">
<Container:Directory>
<rdf:Seq>
<rdf:li rdf:parseType="Resource">
<Container:Item
Item:Mime="image/jpeg"
Item:Semantic="Primary"
Item:Length="0"
Item:Padding="59"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<Container:Item
Item:Mime="video/mp4"
Item:Semantic="MotionPhoto"
Item:Length="3491777"
Item:Padding="0"/>
</rdf:li>
</rdf:Seq>
</Container:Directory>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:subject>
<rdf:Bag>
<rdf:li>the king</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
_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 {

View file

@ -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",