diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0e88994..8130831b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. ### Changed - Viewer: quick action defaults +- cataloguing includes date sub-second data if present ### Removed 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 7185661ea..2d4b722c2 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 @@ -47,6 +47,9 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateDigitizedMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateModifiedMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getDateOriginalMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt @@ -444,11 +447,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // EXIF for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getDateOriginalMillis { metadataMap[KEY_DATE_MILLIS] = it } + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + // fetch date modified from SubIFD directory first, as the sub-second tag is here + dir.getDateModifiedMillis { metadataMap[KEY_DATE_MILLIS] = it } + } } for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + // fallback to fetch date modified from IFD0 directory, without the sub-second tag + // in case there was no SubIFD directory + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { metadataMap[KEY_DATE_MILLIS] = it } } dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) { val orientation = it @@ -572,9 +581,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val exif = ExifInterface(input) - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL, ExifInterface.TAG_SUBSEC_TIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME, ExifInterface.TAG_SUBSEC_TIME) { metadataMap[KEY_DATE_MILLIS] = it } } exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { if (exif.isFlipped) flags = flags or MASK_IS_FLIPPED @@ -913,9 +922,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) val tag = when (field) { - ExifInterface.TAG_DATETIME -> ExifDirectoryBase.TAG_DATETIME - ExifInterface.TAG_DATETIME_DIGITIZED -> ExifDirectoryBase.TAG_DATETIME_DIGITIZED - ExifInterface.TAG_DATETIME_ORIGINAL -> ExifDirectoryBase.TAG_DATETIME_ORIGINAL + ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL ExifInterface.TAG_GPS_DATESTAMP -> GpsDirectory.TAG_DATE_STAMP else -> { result.error("getDate-field", "unsupported ExifInterface field=$field", null) @@ -924,11 +933,24 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { } when (tag) { - ExifDirectoryBase.TAG_DATETIME, - ExifDirectoryBase.TAG_DATETIME_DIGITIZED, - ExifDirectoryBase.TAG_DATETIME_ORIGINAL -> { - for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) { - dir.getSafeDateMillis(tag) { dateMillis = it } + ExifIFD0Directory.TAG_DATETIME -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateModifiedMillis { dateMillis = it } + } + if (dateMillis == null) { + for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { dateMillis = it } + } + } + } + ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateDigitizedMillis { dateMillis = it } + } + } + ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL -> { + for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { + dir.getDateOriginalMillis { dateMillis = it } } } GpsDirectory.TAG_DATE_STAMP -> { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 2fc085469..7b6317506 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -363,13 +363,17 @@ object ExifInterfaceHelper { } } - fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) { + fun ExifInterface.getSafeDateMillis(tag: String, subSecTag: String?, save: (value: Long) -> Unit) { if (this.hasAttribute(tag)) { val dateString = this.getAttribute(tag) if (dateString != null) { try { DATETIME_FORMAT.parse(dateString)?.let { date -> - save(date.time) + var dateMillis = date.time + if (subSecTag != null && this.hasAttribute(subSecTag)) { + dateMillis += Metadata.parseSubSecond(this.getAttribute(subSecTag)) + } + save(dateMillis) } } catch (e: ParseException) { Log.w(LOG_TAG, "failed to parse date=$dateString", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 8e86a124e..805b7e3df 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -65,6 +65,20 @@ object Metadata { } } + fun parseSubSecond(subSecond: String?): Int { + if (subSecond != null) { + try { + val millis = (".$subSecond".toDouble() * 1000).toInt() + if (millis in 0..999) { + return millis + } + } catch (e: NumberFormatException) { + // ignore + } + } + return 0 + } + // not sure which standards are used for the different video formats, // but looks like some form of ISO 8601 `basic format`: // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? @@ -96,18 +110,7 @@ object Metadata { null } ?: return 0 - var dateMillis = date.time - if (subSecond != null) { - try { - val millis = (".$subSecond".toDouble() * 1000).toInt() - if (millis in 0..999) { - dateMillis += millis.toLong() - } - } catch (e: NumberFormatException) { - // ignore - } - } - return dateMillis + return date.time + parseSubSecond(subSecond) } // opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 03090ea4d..8a244d3d0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -6,7 +6,9 @@ import com.drew.lang.Rational import com.drew.lang.SequentialByteArrayReader import com.drew.metadata.Directory import com.drew.metadata.exif.ExifDirectoryBase +import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifReader +import com.drew.metadata.exif.ExifSubIFDDirectory import com.drew.metadata.iptc.IptcReader import com.drew.metadata.png.PngDirectory import deckers.thibault.aves.utils.LogUtils @@ -53,11 +55,34 @@ object MetadataExtractorHelper { if (this.containsTag(tag)) save(this.getRational(tag)) } - fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { + fun Directory.getSafeDateMillis(tag: Int, subSecond: String?): Long? { if (this.containsTag(tag)) { - val date = this.getDate(tag, null, TimeZone.getDefault()) - if (date != null) save(date.time) + val date = this.getDate(tag, subSecond, TimeZone.getDefault()) + if (date != null) return date.time } + return null + } + + // time tag and sub-second tag are *not* in the same directory + fun ExifSubIFDDirectory.getDateModifiedMillis(save: (value: Long) -> Unit) { + val parent = parent + if (parent is ExifIFD0Directory) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME) + val dateMillis = parent.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, subSecond) + if (dateMillis != null) save(dateMillis) + } + } + + fun ExifSubIFDDirectory.getDateDigitizedMillis(save: (value: Long) -> Unit) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME_DIGITIZED) + val dateMillis = this.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED, subSecond) + if (dateMillis != null) save(dateMillis) + } + + fun ExifSubIFDDirectory.getDateOriginalMillis(save: (value: Long) -> Unit) { + val subSecond = getString(ExifSubIFDDirectory.TAG_SUBSECOND_TIME_ORIGINAL) + val dateMillis = this.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, subSecond) + if (dateMillis != null) save(dateMillis) } // geotiff diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index b4ffd9b43..6eb90a59f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -185,7 +185,7 @@ class SourceEntry { dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it } dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) { height = it } dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { sourceRotationDegrees = getRotationDegreesForExifCode(it) } - dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it } + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME, null)?.let { sourceDateTakenMillis = it } } // dimensions reported in EXIF do not always match the image @@ -218,7 +218,7 @@ class SourceEntry { exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } exif.getSafeInt(ExifInterface.TAG_IMAGE_LENGTH, acceptZero = false) { height = it } exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { sourceRotationDegrees = exif.rotationDegrees } - exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it } + exif.getSafeDateMillis(ExifInterface.TAG_DATETIME, ExifInterface.TAG_SUBSEC_TIME) { sourceDateTakenMillis = it } } } catch (e: Exception) { // ExifInterface initialization can fail with a RuntimeException 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 3484999f7..0c25fc37f 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 @@ -756,7 +756,13 @@ abstract class ImageProvider { ExifInterface.TAG_DATETIME_DIGITIZED, ).forEach { field -> if (fields.contains(field)) { - exif.getSafeDateMillis(field) { date -> + val subSecTag = when (field) { + ExifInterface.TAG_DATETIME -> ExifInterface.TAG_SUBSEC_TIME + ExifInterface.TAG_DATETIME_DIGITIZED -> ExifInterface.TAG_SUBSEC_TIME_DIGITIZED + ExifInterface.TAG_DATETIME_ORIGINAL -> ExifInterface.TAG_SUBSEC_TIME_ORIGINAL + else -> null + } + exif.getSafeDateMillis(field, subSecTag) { date -> exif.setAttribute(field, ExifInterfaceHelper.DATETIME_FORMAT.format(date + shiftMillis)) } } diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 77f0cb831..3536b49b5 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -149,7 +149,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); } else { final count = movedOps.length; - final appMode = context.read>().value; + final appMode = context.read?>()?.value; SnackBarAction? action; if (count > 0 && appMode == AppMode.main && !toBin) {