diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c1fbd7a..b763c2868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Collection: support for Fairphone burst pattern +- Collection: allow using tags/make/model when bulk renaming - Settings: hidden items can be toggled ### Changed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index b7e03b319..91170c56b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -29,6 +29,7 @@ import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.metadata.ExifGeoTiffTags +import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble @@ -110,7 +111,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) } "getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) } - "getFields" -> ioScope.launch { safe(call, result, ::getFields) } + "getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) } "getGeoTiffInfo" -> ioScope.launch { safe(call, result, ::getGeoTiffInfo) } "getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) } "getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) } @@ -119,6 +120,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) } "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) } "getDate" -> ioScope.launch { safe(call, result, ::getDate) } + "getFields" -> ioScope.launch { safe(call, result, ::getFields) } else -> result.notImplemented() } } @@ -815,7 +817,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } } - private fun getFields(call: MethodCall, result: MethodChannel.Result) { + private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() @@ -1250,6 +1252,71 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.success(dateMillis) } + private fun getFields(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val fields = call.argument>("fields") + if (mimeType == null || uri == null || fields == null) { + result.error("getFields-args", "missing arguments", null) + return + } + + val metadataMap = HashMap() + if (fields.isEmpty() || isVideo(mimeType)) { + result.success(metadataMap) + return + } + + var foundExif = false + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = Helper.safeRead(input) + for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) { + foundExif = true + val allTags = ExifInterfaceHelper.allTags + fields.forEach { tag -> + allTags[tag]?.let { mapper -> + val tagType = mapper.type + dir.getDescription(tagType)?.let { value -> metadataMap[tag] = value } + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } catch (e: AssertionError) { + Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e) + } + } + + if (!foundExif && canReadWithExifInterface(mimeType)) { + // fallback to read EXIF via ExifInterface + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val exif = ExifInterface(input) + fields.forEach { tag -> + if (exif.hasAttribute(tag)) { + val value = exif.getAttribute(tag) + if (value != null) { + metadataMap[tag] = value + } + } + } + } + } catch (e: Exception) { + // ExifInterface initialization can fail with a RuntimeException + // caused by an internal MediaMetadataRetriever failure + Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e) + } + } + + result.success(metadataMap) + } + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/metadata_fetch" diff --git a/lib/convert/metadata/fields.dart b/lib/convert/metadata/fields.dart index c19f0f91b..a0509b563 100644 --- a/lib/convert/metadata/fields.dart +++ b/lib/convert/metadata/fields.dart @@ -43,6 +43,8 @@ extension ExtraMetadataFieldConvert on MetadataField { case MetadataField.exifGpsTrackRef: case MetadataField.exifGpsVersionId: case MetadataField.exifImageDescription: + case MetadataField.exifMake: + case MetadataField.exifModel: case MetadataField.exifUserComment: return MetadataType.exif; case MetadataField.mp4GpsCoordinates: @@ -145,6 +147,10 @@ extension ExtraMetadataFieldConvert on MetadataField { return 'GPSVersionID'; case MetadataField.exifImageDescription: return 'ImageDescription'; + case MetadataField.exifMake: + return 'Make'; + case MetadataField.exifModel: + return 'Model'; case MetadataField.exifUserComment: return 'UserComment'; default: diff --git a/lib/model/naming_pattern.dart b/lib/model/naming_pattern.dart index e5eee02d4..851d58011 100644 --- a/lib/model/naming_pattern.dart +++ b/lib/model/naming_pattern.dart @@ -1,4 +1,8 @@ +import 'package:aves/convert/metadata/fields.dart'; import 'package:aves/model/entry/entry.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; @@ -38,6 +42,12 @@ class NamingPattern { if (processorOptions != null) { processors.add(DateNamingProcessor(processorOptions.trim())); } + case TagsNamingProcessor.key: + processors.add(TagsNamingProcessor(processorOptions?.trim() ?? '')); + case MetadataFieldNamingProcessor.key: + if (processorOptions != null) { + processors.add(MetadataFieldNamingProcessor(processorOptions.trim())); + } case NameNamingProcessor.key: processors.add(const NameNamingProcessor()); case CounterNamingProcessor.key: @@ -95,21 +105,33 @@ class NamingPattern { switch (processorKey) { case DateNamingProcessor.key: return '<$processorKey, yyyyMMdd-HHmmss>'; + case TagsNamingProcessor.key: + return '<$processorKey, ->'; case CounterNamingProcessor.key: case NameNamingProcessor.key: default: + if (processorKey.startsWith(MetadataFieldNamingProcessor.key)) { + final field = MetadataFieldNamingProcessor.fieldFromKey(processorKey); + return '<${MetadataFieldNamingProcessor.key}, $field>'; + } return '<$processorKey>'; } } - String apply(AvesEntry entry, int index) => processors.map((v) => v.process(entry, index) ?? '').join().trimLeft(); + Future apply(AvesEntry entry, int index) async { + final fields = processors.expand((v) => v.getRequiredFields()).toSet(); + final fieldValues = await metadataFetchService.getFields(entry, fields); + return processors.map((v) => v.process(entry, index, fieldValues) ?? '').join().trim(); + } } @immutable abstract class NamingProcessor extends Equatable { const NamingProcessor(); - String? process(AvesEntry entry, int index); + String? process(AvesEntry entry, int index, Map fieldValues); + + Set getRequiredFields() => {}; } @immutable @@ -122,7 +144,7 @@ class LiteralNamingProcessor extends NamingProcessor { const LiteralNamingProcessor(this.text); @override - String? process(AvesEntry entry, int index) => text; + String? process(AvesEntry entry, int index, Map fieldValues) => text; } @immutable @@ -137,12 +159,60 @@ class DateNamingProcessor extends NamingProcessor { DateNamingProcessor(String pattern) : format = DateFormat(pattern); @override - String? process(AvesEntry entry, int index) { + String? process(AvesEntry entry, int index, Map fieldValues) { final date = entry.bestDate; return date != null ? format.format(date) : null; } } +@immutable +class TagsNamingProcessor extends NamingProcessor { + static const key = 'tags'; + static const defaultSeparator = ' '; + + final String separator; + + @override + List get props => [separator]; + + TagsNamingProcessor(String separator) : separator = separator.isEmpty ? defaultSeparator : separator; + + @override + String? process(AvesEntry entry, int index, Map fieldValues) { + return entry.tags.join(separator); + } +} + +@immutable +class MetadataFieldNamingProcessor extends NamingProcessor { + static const key = 'field'; + + static String keyWithField(MetadataField field) => '$key-${field.name}'; + + // loose, for user to see and later parse + static String fieldFromKey(String keyWithField) => keyWithField.substring(key.length + 1); + + late final MetadataField? field; + + @override + List get props => [field]; + + MetadataFieldNamingProcessor(String field) { + final lowerField = field.toLowerCase(); + this.field = MetadataField.values.firstWhereOrNull((v) => v.name.toLowerCase() == lowerField); + } + + @override + Set getRequiredFields() { + return {field}.whereNotNull().toSet(); + } + + @override + String? process(AvesEntry entry, int index, Map fieldValues) { + return fieldValues[field?.toPlatform]?.toString(); + } +} + @immutable class NameNamingProcessor extends NamingProcessor { static const key = 'name'; @@ -153,7 +223,7 @@ class NameNamingProcessor extends NamingProcessor { const NameNamingProcessor(); @override - String? process(AvesEntry entry, int index) => entry.filenameWithoutExtension; + String? process(AvesEntry entry, int index, Map fieldValues) => entry.filenameWithoutExtension; } @immutable @@ -174,5 +244,5 @@ class CounterNamingProcessor extends NamingProcessor { }); @override - String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0'); + String? process(AvesEntry entry, int index, Map fieldValues) => '${index + start}'.padLeft(padding, '0'); } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index a08deb667..d89bff766 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -22,7 +22,7 @@ abstract class MetadataFetchService { Future getCatalogMetadata(AvesEntry entry, {bool background = false}); - Future getFields(AvesEntry entry, Set fields); + Future getOverlayMetadata(AvesEntry entry, Set fields); Future getGeoTiffInfo(AvesEntry entry); @@ -39,6 +39,8 @@ abstract class MetadataFetchService { Future getContentResolverProp(AvesEntry entry, String prop); Future getDate(AvesEntry entry, MetadataField field); + + Future> getFields(AvesEntry entry, Set fields); } class PlatformMetadataFetchService implements MetadataFetchService { @@ -110,7 +112,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { } @override - Future getFields(AvesEntry entry, Set fields) async { + Future getOverlayMetadata(AvesEntry entry, Set fields) async { if (fields.isNotEmpty && !entry.isSvg) { try { // returns fields on demand, with various value types: @@ -119,7 +121,7 @@ class PlatformMetadataFetchService implements MetadataFetchService { // 'exposureTime' (string), // 'focalLength' (double), // 'iso' (int), - final result = await _platform.invokeMethod('getFields', { + final result = await _platform.invokeMethod('getOverlayMetadata', { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, @@ -284,4 +286,24 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return null; } + + @override + Future> getFields(AvesEntry entry, Set fields) async { + if (fields.isNotEmpty && !entry.isSvg) { + try { + final result = await _platform.invokeMethod('getFields', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'fields': fields.map((v) => v.toPlatform).toList(), + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (entry.isValid) { + await reportService.recordError(e, stack); + } + } + } + return {}; + } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index abf9008b0..a7b5de043 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -105,6 +105,7 @@ class AIcons { static const info = Icons.info_outlined; static const layers = Icons.layers_outlined; static const map = Icons.map_outlined; + static const more = Icons.more_horiz_outlined; static final move = MdiIcons.fileMoveOutline; static const mute = Icons.volume_off_outlined; static const unmute = Icons.volume_up_outlined; diff --git a/lib/view/src/metadata/fields.dart b/lib/view/src/metadata/fields.dart index 906146443..3c9bfe2e5 100644 --- a/lib/view/src/metadata/fields.dart +++ b/lib/view/src/metadata/fields.dart @@ -11,6 +11,10 @@ extension ExtraMetadataFieldView on MetadataField { return 'Exif digitized date'; case MetadataField.exifGpsDatestamp: return 'Exif GPS date'; + case MetadataField.exifMake: + return 'Exif make'; + case MetadataField.exifModel: + return 'Exif model'; case MetadataField.xmpXmpCreateDate: return 'XMP xmp:CreateDate'; default: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 4a1be7347..a71908c84 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -356,10 +356,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); if (pattern == null) return; - final entriesToNewName = Map.fromEntries(entries.mapIndexed((index, entry) { - final newName = pattern.apply(entry, index); + final namingFutures = entries.mapIndexed((index, entry) async { + final newName = await pattern.apply(entry, index); return MapEntry(entry, '$newName${entry.extension}'); - })).whereNotNullValue(); + }); + final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue(); await rename(context, entriesToNewName: entriesToNewName, persist: true); _browse(context); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 4d2dc495f..4d3ad4bcc 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -55,7 +55,7 @@ mixin EntryEditorMixin { final entry = entries.first; final initialTitle = entry.catalogMetadata?.xmpTitle ?? ''; - final fields = await metadataFetchService.getFields(entry, {MetadataSyntheticField.description}); + final fields = await metadataFetchService.getOverlayMetadata(entry, {MetadataSyntheticField.description}); final initialDescription = fields.description ?? ''; return showDialog>( diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart index 8f09dc16b..d1cc5d0db 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart @@ -6,8 +6,10 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; +import 'package:aves/view/src/metadata/fields.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; +import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; import 'package:aves/widgets/common/basic/popup/menu_row.dart'; import 'package:aves/widgets/common/basic/scaffold.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -91,10 +93,6 @@ class _RenameEntrySetPageState extends State { child: PopupMenuButton( itemBuilder: (context) { return [ - PopupMenuItem( - value: DateNamingProcessor.key, - child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)), - ), PopupMenuItem( value: NameNamingProcessor.key, child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)), @@ -103,6 +101,28 @@ class _RenameEntrySetPageState extends State { value: CounterNamingProcessor.key, child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)), ), + PopupMenuItem( + value: DateNamingProcessor.key, + child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)), + ), + PopupMenuItem( + value: TagsNamingProcessor.key, + child: MenuRow(text: l10n.tagPageTitle, icon: const Icon(AIcons.tag)), + ), + PopupMenuExpansionPanel( + value: MetadataFieldNamingProcessor.key, + icon: AIcons.more, + title: MaterialLocalizations.of(context).moreButtonTooltip, + items: [ + MetadataField.exifMake, + MetadataField.exifModel, + ] + .map((field) => PopupMenuItem( + value: MetadataFieldNamingProcessor.keyWithField(field), + child: MenuRow(text: field.title), + )) + .toList(), + ), ]; }, onSelected: (key) async { @@ -159,11 +179,17 @@ class _RenameEntrySetPageState extends State { ValueListenableBuilder( valueListenable: _namingPatternNotifier, builder: (context, pattern, child) { - return Text( - pattern.apply(entry, index), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, + return FutureBuilder( + future: pattern.apply(entry, index), + builder: (context, snapshot) { + final info = snapshot.data; + return Text( + info ?? '…', + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, ); }, ), diff --git a/lib/widgets/viewer/overlay/details/details.dart b/lib/widgets/viewer/overlay/details/details.dart index 9556ada7a..f5d47e91a 100644 --- a/lib/widgets/viewer/overlay/details/details.dart +++ b/lib/widgets/viewer/overlay/details/details.dart @@ -74,7 +74,7 @@ class _ViewerDetailOverlayState extends State { if (requestEntry == null) { _detailLoader = SynchronousFuture(const OverlayMetadata()); } else { - _detailLoader = metadataFetchService.getFields(requestEntry, { + _detailLoader = metadataFetchService.getOverlayMetadata(requestEntry, { if (settings.showOverlayShootingDetails) ...{ MetadataSyntheticField.aperture, MetadataSyntheticField.exposureTime, diff --git a/plugins/aves_model/lib/src/metadata/fields.dart b/plugins/aves_model/lib/src/metadata/fields.dart index c61759609..706687483 100644 --- a/plugins/aves_model/lib/src/metadata/fields.dart +++ b/plugins/aves_model/lib/src/metadata/fields.dart @@ -43,6 +43,8 @@ enum MetadataField { exifGpsTrackRef, exifGpsVersionId, exifImageDescription, + exifMake, + exifModel, exifUserComment, mp4GpsCoordinates, mp4RotationDegrees,