diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6456fa17..6a74ecdbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file.
- temporary files remaining in the cache directory forever
- detecting motion photos with more items in the XMP Container directory
+- parsing EXIF date written as epoch time
## [v1.9.7] - 2023-10-17
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
index 2c71d16ed..6be0b09d7 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
@@ -31,8 +31,14 @@ import deckers.thibault.aves.utils.LogUtils
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
+import java.text.ParseException
import java.text.SimpleDateFormat
-import java.util.*
+import java.util.Calendar
+import java.util.Date
+import java.util.GregorianCalendar
+import java.util.Locale
+import java.util.TimeZone
+import java.util.regex.Pattern
object Helper {
private val LOG_TAG = LogUtils.createTag()
@@ -150,12 +156,105 @@ object Helper {
fun Directory.getSafeDateMillis(tag: Int, subSecond: String?): Long? {
if (this.containsTag(tag)) {
- val date = this.getDate(tag, subSecond, TimeZone.getDefault())
+ val date = this.getDatePlus(tag, subSecond, TimeZone.getDefault())
if (date != null) return date.time
}
return null
}
+ // This seems to cover all known Exif and Xmp date strings
+ // Note that " : : : : " is a valid date string according to the Exif spec (which means 'unknown date'): http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/datetimeoriginal.html
+ private val datePatterns = arrayOf(
+ "yyyy:MM:dd HH:mm:ss",
+ "yyyy:MM:dd HH:mm",
+ "yyyy-MM-dd HH:mm:ss",
+ "yyyy-MM-dd HH:mm",
+ "yyyy.MM.dd HH:mm:ss",
+ "yyyy.MM.dd HH:mm",
+ "yyyy-MM-dd'T'HH:mm:ss",
+ "yyyy-MM-dd'T'HH:mm",
+ "yyyy-MM-dd",
+ "yyyy-MM",
+ "yyyyMMdd", // as used in IPTC data
+ "yyyy"
+ )
+ private val subsecondPattern = Pattern.compile("(\\d\\d:\\d\\d:\\d\\d)(\\.\\d+)")
+ private val timeZonePattern = Pattern.compile("(Z|[+-]\\d\\d:\\d\\d|[+-]\\d\\d\\d\\d)$")
+ private val calendar: Calendar = GregorianCalendar()
+ private const val PARSED_DATE_YEAR_MAX = 10000
+
+ // adapted from `metadata-extractor` v2.18.0 `Directory.getDate()`
+ // to also parse dates written as timestamps
+ private fun Directory.getDatePlus(tagType: Int, subSecond: String?, timeZone: TimeZone?): Date? {
+ var effectiveSubSecond = subSecond
+ var effectiveTimeZone = timeZone
+ val o = this.getObject(tagType)
+ if (o is Date) return o
+
+ var date: Date? = null
+ if (o is String || o is StringValue) {
+ var dateString = o.toString()
+
+ // if the date string has subsecond information, it supersedes the subsecond parameter
+ val subsecondMatcher = subsecondPattern.matcher(dateString)
+ if (subsecondMatcher.find()) {
+ effectiveSubSecond = subsecondMatcher.group(2)?.substring(1)
+ dateString = subsecondMatcher.replaceAll("$1")
+ }
+
+ // if the date string has time zone information, it supersedes the timeZone parameter
+ val timeZoneMatcher = timeZonePattern.matcher(dateString)
+ if (timeZoneMatcher.find()) {
+ effectiveTimeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), ""))
+ dateString = timeZoneMatcher.replaceAll("")
+ }
+ for (datePattern in datePatterns) {
+ try {
+ val parsed = SimpleDateFormat(datePattern, Locale.ROOT).apply {
+ this.timeZone = effectiveTimeZone ?: TimeZone.getTimeZone("GMT") // don't interpret zone time
+ }.parse(dateString)
+ if (parsed != null) {
+ calendar.time = parsed
+ if (calendar.get(Calendar.YEAR) < PARSED_DATE_YEAR_MAX) {
+ date = parsed
+ break
+ }
+ }
+ } catch (ex: ParseException) {
+ // simply try the next pattern
+ }
+ }
+ if (date == null) {
+ val dateLong = dateString.toLongOrNull()
+ if (dateLong != null) {
+ val epochTimeMillis = when (dateLong) {
+ in 0..99999999999 -> dateLong * 1000 // seconds
+ in 100000000000..99999999999999 -> dateLong // millis
+ in 100000000000000..9999999999999999 -> dateLong / 1000 // micros
+ else -> dateLong / 1000000 // nanos
+ }
+ date = Date(epochTimeMillis)
+ }
+ }
+ }
+ if (date == null) return null
+
+ if (effectiveSubSecond != null) {
+ try {
+ val millisecond = (".$effectiveSubSecond".toDouble() * 1000).toInt()
+ if (millisecond in 0..999) {
+ val calendar = Calendar.getInstance()
+ calendar.time = date
+ calendar[Calendar.MILLISECOND] = millisecond
+ return calendar.time
+ }
+ } catch (e: NumberFormatException) {
+ // ignore
+ }
+ }
+ return date
+ }
+
// time tag and sub-second tag are *not* in the same directory
fun ExifSubIFDDirectory.getDateModifiedMillis(save: (value: Long) -> Unit) {
val parent = parent