From 722700b41236f39c98cbbecc1670beee8037b26e Mon Sep 17 00:00:00 2001 From: difanta Date: Tue, 13 Jan 2026 17:42:50 +0100 Subject: [PATCH 1/3] Improved EXIF parsing for Android App Put the mobile app in line with the changes of #1588. Add null checks to solve problems for certain buckets not synchronizing. --- .../gallery/memories/mapper/SystemImage.kt | 347 ++++++++++++++---- 1 file changed, 272 insertions(+), 75 deletions(-) diff --git a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt index d68402380..ec3bcf393 100644 --- a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt +++ b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt @@ -2,26 +2,39 @@ package gallery.memories.mapper import android.content.ContentUris import android.content.Context -import android.icu.text.SimpleDateFormat -import android.icu.util.TimeZone import android.net.Uri import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import org.json.JSONObject import java.io.IOException +import java.io.InputStream import java.math.BigInteger import java.security.MessageDigest +import java.util.Calendar +import java.time.* +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.ResolverStyle +import java.time.temporal.ChronoField +import java.util.Date +import java.util.TimeZone +import java.time.temporal.TemporalAccessor +import kotlin.math.floor + +data class InstantZone(val instant: Instant, val zoneId: ZoneId?) class SystemImage { var fileId = 0L var baseName = "" var mimeType = "" - var dateTaken = 0L + var dateTaken = 0L // seconds + var dayId: Long = 0L + var exifInterface: ExifInterface? = null var height = 0L var width = 0L var size = 0L - var mtime = 0L + var mtime = 0L // seconds var dataPath = "" var bucketId = 0L var bucketName = "" @@ -41,14 +54,91 @@ class SystemImage { val IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val VIDEO_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + val DATE_FIELDS = listOf( + "SubSecDateTimeOriginal", + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_DATETIME, + "SonyDateTime", + + "SubSecCreateDate", + "CreationDate", + "CreationDateValue", + "CreateDate", + "TrackCreateDate", + "MediaCreateDate", + "FileCreateDate", + + "SubSecModifyDate", + "ModifyDate", + "TrackModifyDate", + "MediaModifyDate", + "FileModifyDate", + ) + + // Flexible formatter: optional seconds and optional offset + private val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern( + "yyyy-MM-dd['T'HH:mm[:ss][XXX]]" + ) + + // Precompiled regexes for performance + private val OFFSET_RE = Regex("([+-]\\d{2}:?\\d{2}|Z)$") + private val FRAC_RE = Regex("""\.\d+""") + private val TIME_SEC_RE = Regex("""\d{2}:\d{2}:\d{2}""") + private val TRAILING_Z_RE = Regex("Z$") + private val OFFSET_NO_COLON_RE = Regex("([+-])(\\d{2})(\\d{2})$") + private val DATE_CLEANUP_RE = Regex("""^(\d{4}):(\d{2}):(\d{2})""") + private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE) + /** - * Iterate over all images/videos in the given collection - * @param ctx Context - application context - * @param collection Uri - either IMAGE_URI or VIDEO_URI - * @param selection String? - selection string - * @param selectionArgs Array? - selection arguments - * @param sortOrder String? - sort order - * @return Sequence + * Normalize a raw exif date string: + * - Replace trailing Z with +00:00 + * - Replace comma decimal separator with dot + * - Normalize +HHMM / -HHMM to +HH:MM / -HH:MM + */ + private fun normalizeRaw(s: String): String { + var x = s.trim() + if (x.isEmpty()) return x + if (TRAILING_Z_RE.containsMatchIn(x)) { + x = x.replace(TRAILING_Z_RE, "+00:00") + } + // comma fractional -> dot + x = x.replace(',', '.') + // normalize +HHMM / -HHMM -> +HH:MM + if (OFFSET_NO_COLON_RE.containsMatchIn(x)) { + x = x.replace(OFFSET_NO_COLON_RE, "$1$2:$3") + } + return x + } + + /** + * Create ExifInterface from Uri if possible (prefers InputStream for scoped storage), + * falls back to dataPath (file path) if provided. + */ + private fun createExifInterfaceFromUri(ctx: Context, uri: Uri, dataPath: String?): ExifInterface? { + try { + // Try input stream first (works on scoped storage) + ctx.contentResolver.openInputStream(uri)?.use { input -> + return ExifInterface(input) + } + } catch (e: Exception) { + Log.v(TAG, "openInputStream failed for $uri: ${e.message}") + } + + // Fallback to file path (DATA) if available + if (!dataPath.isNullOrEmpty()) { + try { + return ExifInterface(dataPath) + } catch (e: Exception) { + Log.w(TAG, "ExifInterface(file) failed for $dataPath: ${e.message}") + } + } + return null + } + + /** + * Cursor sequence over media store entries. + * ctx is used to open InputStream for EXIF reading so we can support scoped storage. */ fun cursor( ctx: Context, @@ -91,6 +181,7 @@ class SystemImage { val dataColumn = projection.indexOf(MediaStore.Images.Media.DATA) val bucketIdColumn = projection.indexOf(MediaStore.Images.Media.BUCKET_ID) val bucketNameColumn = projection.indexOf(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + val durationColumn = if (collection == VIDEO_URI) projection.indexOf(MediaStore.Video.Media.DURATION) else -1 // Query content resolver ctx.contentResolver.query( @@ -100,22 +191,46 @@ class SystemImage { selectionArgs, sortOrder ).use { cursor -> - while (cursor!!.moveToNext()) { + if (cursor == null) { + Log.w(TAG, "ContentResolver.query returned null for $collection") + return@sequence + } + + while (cursor.moveToNext()) { val image = SystemImage() - // Common fields image.fileId = cursor.getLong(idColumn) - image.baseName = cursor.getString(nameColumn) - image.mimeType = cursor.getString(mimeColumn) + image.baseName = cursor.getString(nameColumn) ?: "" + image.mimeType = cursor.getString(mimeColumn) ?: "" image.height = cursor.getLong(heightColumn) image.width = cursor.getLong(widthColumn) image.size = cursor.getLong(sizeColumn) - image.dateTaken = cursor.getLong(dateTakenColumn) image.mtime = cursor.getLong(dateModifiedColumn) - image.dataPath = cursor.getString(dataColumn) + + image.dataPath = cursor.getString(dataColumn) ?: "" image.bucketId = cursor.getLong(bucketIdColumn) - image.bucketName = cursor.getString(bucketNameColumn) + image.bucketName = cursor.getString(bucketNameColumn) ?: "" image.mCollection = collection + image.exifInterface = createExifInterfaceFromUri(ctx, image.uri, image.dataPath) + + image.isVideo = collection == VIDEO_URI + if (image.isVideo && durationColumn >= 0) { + image.videoDuration = cursor.getLong(durationColumn) + } + + val dateTaken = if (!cursor.isNull(dateTakenColumn)) cursor.getLong(dateTakenColumn) / 1000 else null + + // Parse EXIF date using ExifInterface, otherwise fallback to MediaStore dateTaken or mtime + var instantZone = parseExifDate(image.exifInterface, image.mimeType, dateTaken, image.mtime) + + var dateTakenInstant = instantZone.instant + + image.dateTaken = dateTakenInstant.getEpochSecond() + + val dateTakenZdt = instantZone.zoneId.let { dateTakenInstant.atZone(it) } + val midnightUtc = dateTakenZdt.toLocalDate().atStartOfDay(ZoneOffset.UTC) + + image.dayId = floor(midnightUtc.toEpochSecond() / 86400.0).toLong() // Swap width/height if orientation is 90 or 270 val orientation = cursor.getInt(orientationColumn) @@ -123,17 +238,141 @@ class SystemImage { image.width = image.height.also { image.height = image.width } } - // Video specific fields - image.isVideo = collection == VIDEO_URI - if (image.isVideo) { - val durationColumn = projection.indexOf(MediaStore.Video.Media.DURATION) - image.videoDuration = cursor.getLong(durationColumn) + yield(image) + } + } + } + + fun parseExifDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, mtime: Long): InstantZone { + val candidates = mutableMapOf() + + if (exif != null) { + for (field in DATE_FIELDS) { + val v = exif.getAttribute(field) + if (!v.isNullOrEmpty() && !v.startsWith("0000:00:00")) { + candidates[field] = v } + } - // Add to main list - yield(image) + // Add GPS date/time if available + val gpsDate = exif.getAttribute(ExifInterface.TAG_GPS_DATESTAMP) + val gpsTime = exif.getAttribute(ExifInterface.TAG_GPS_TIMESTAMP) + if (!gpsDate.isNullOrEmpty() && !gpsTime.isNullOrEmpty()) { + candidates["GPS"] = gpsDate.replace(':', '-') + "T" + gpsTime + } + } + + var bestAdjustedEpoch: Long? = null // epochSecond - precision + var bestInstant: Instant? = null + var bestZone: ZoneId? = null + + // Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any) + val exifZone: ZoneId? = exif?.let { e -> + try { + val tzStr = e.getAttribute("OffsetTimeOriginal") + ?: e.getAttribute("OffsetTime") + ?: e.getAttribute("OffsetTimeDigitized") + ?: e.getAttribute("TimeZone") + ?: e.getAttribute("LocationTZID") + if (tzStr != null) ZoneId.of(tzStr) else null + } catch (_: Exception) { + Log.w(TAG, "Failed to parse EXIF timezone") + null } } + + var bestField: String? = null + + for ((field, raw) in candidates) { + var str = normalizeRaw(raw) + + str = str.replaceFirst(DATE_CLEANUP_RE, "$1-$2-$3") + str = str.replaceFirst(' ', 'T') + + try { + val parsed: TemporalAccessor = try { + // parseBest tries OffsetDateTime first, then LocalDateTime, then LocalDate + DATE_TIME_FORMATTER.parseBest( + str, + { OffsetDateTime.from(it) }, + { LocalDateTime.from(it) }, + { LocalDate.from(it) } + ) + } catch (e: Exception) { + throw IllegalArgumentException("Failed to parse datetime: $str", e) + } + + var instant: Instant + var parsedZoneFromString: ZoneId? = null + + when (parsed) { + is OffsetDateTime -> { + // string had explicit offset + instant = parsed.toInstant() + parsedZoneFromString = parsed.offset + } + is LocalDateTime -> { + instant = when { + exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true -> { + // videos: treat as UTC then convert to exifZone (shift clock) + parsed.atZone(ZoneOffset.UTC).toInstant() + } + exifZone != null -> { + // photos: treat as local time in exifZone (no clock shift) + parsed.atZone(exifZone).toInstant() + } + else -> { + // fallback: assume UTC + parsed.atZone(ZoneOffset.UTC).toInstant() + } + } + } + is LocalDate -> { + // only a date, assume start of day in exifZone or UTC + instant = (exifZone ?: ZoneOffset.UTC).let { parsed.atStartOfDay(it).toInstant() } + } + else -> throw IllegalArgumentException("Unsupported datetime format: $str") + } + + // Filter out QuickTime bogus timestamp (1904-01-01) or timestamps way before 1800 + val ts = instant.getEpochSecond() + if (ts == -2082844800L || ts <= -5_364_662_400L) { + continue + } + + // determine precision: fractional seconds > seconds > minutes + val precision = when { + FRAC_RE.containsMatchIn(str) -> 3 + TIME_SEC_RE.containsMatchIn(str) -> 2 + else -> 1 + } + + val adjusted = ts - precision + + if (adjusted > 0 && (bestAdjustedEpoch == null || adjusted < bestAdjustedEpoch)) { + bestAdjustedEpoch = adjusted + bestInstant = instant + bestZone = (parsedZoneFromString ?: exifZone) + bestField = field + } + } catch (ex: Exception) { + Log.v(TAG, "parse failed for field=$field value=$str: ${ex.message}") + // continue to next candidate + } + } + + if (bestInstant == null) { + if (dateTaken != null && dateTaken > 0) { + Log.v(TAG, "Date source: MediaStore dateTaken") + return InstantZone(Instant.ofEpochSecond(dateTaken), ZoneOffset.UTC) + } else { + Log.v(TAG, "Date source: MediaStore mtime") + return InstantZone(Instant.ofEpochSecond(mtime), ZoneOffset.UTC) + } + } + + Log.v(TAG, "Date source: EXIF $bestField") + return InstantZone(bestInstant, bestZone) } /** @@ -164,7 +403,7 @@ class SystemImage { .put(Fields.Photo.WIDTH, width) .put(Fields.Photo.SIZE, size) .put(Fields.Photo.ETAG, mtime.toString()) - .put(Fields.Photo.EPOCH, epoch) + .put(Fields.Photo.EPOCH, dateTaken) if (isVideo) { obj.put(Fields.Photo.ISVIDEO, 1) @@ -174,46 +413,8 @@ class SystemImage { return obj } - /** The epoch timestamp of the image. */ - val epoch - get(): Long { - return dateTaken / 1000 - } - - val exifInterface - get() : ExifInterface? { - if (isVideo) return null - try { - return ExifInterface(dataPath) - } catch (e: Exception) { - Log.w(TAG, "Failed to read EXIF data: " + e.message) - return null - } - } - - /** The UTC dateTaken timestamp of the image. */ - fun utcDate(exif: ExifInterface?): Long { - // Get EXIF date using ExifInterface if image - if (exif != null) { - try { - val exifDate = exif.getAttribute(ExifInterface.TAG_DATETIME) - ?: throw IOException() - val sdf = SimpleDateFormat("yyyy:MM:dd HH:mm:ss") - sdf.timeZone = TimeZone.GMT_ZONE - sdf.parse(exifDate).let { - return it.time / 1000 - } - } catch (e: Exception) { - Log.w(TAG, "Failed to read EXIF datetime: " + e.message) - } - } - - // No way to get the actual local date, so just assume current timezone - return (dateTaken + TimeZone.getDefault().getOffset(dateTaken).toLong()) / 1000 - } - fun auid(): String { - return md5("$epoch$size") + return md5("$dateTaken$size") } fun buid(exif: ExifInterface?): String { @@ -224,11 +425,10 @@ class SystemImage { ?: throw IOException() sfx = "iuid=$iuid" } catch (e: Exception) { - Log.w(TAG, "Failed to read EXIF unique ID ($baseName): " + e.message) + Log.w(TAG, "Failed to read EXIF unique ID ($baseName): ${e.message}") } } - - return md5("$baseName$sfx"); + return md5("$baseName$sfx") } /** @@ -237,16 +437,13 @@ class SystemImage { */ val photo get(): Photo { - val exif = exifInterface - val dateCache = utcDate(exif) - return Photo( localId = fileId, auid = auid(), - buid = buid(exif), + buid = buid(exifInterface), mtime = mtime, - dateTaken = dateCache, - dayId = dateCache / 86400, + dateTaken = dateTaken, + dayId = dayId, baseName = baseName, bucketId = bucketId, bucketName = bucketName, @@ -259,4 +456,4 @@ class SystemImage { val md = MessageDigest.getInstance("MD5") return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') } -} \ No newline at end of file +} From 196ebf5b010a455af03190955ecaab1a8cfe376c Mon Sep 17 00:00:00 2001 From: difanta Date: Tue, 20 Jan 2026 18:23:22 +0100 Subject: [PATCH 2/3] cleanup code into a separate DateParser class; remove modified date from exif candidate fields; add filename date parsing; choose earliest among EXIF fields, dateTaken, mtime, filename without priority to any one; add tests for DateParser; --- android/app/build.gradle | 13 + .../gallery/memories/mapper/SystemImage.kt | 550 +++++++++++------- .../gallery/memories/mapper/DateParserTest.kt | 205 +++++++ 3 files changed, 569 insertions(+), 199 deletions(-) create mode 100644 android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 19f423e7e..b6768f0fc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,6 +34,19 @@ android { buildFeatures { viewBinding true } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "skipped", "failed" + showStandardStreams = true + exceptionFormat = "full" + } + } + unitTests.returnDefaultValues = true + } } dependencies { diff --git a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt index ec3bcf393..96b132a05 100644 --- a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt +++ b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt @@ -19,10 +19,355 @@ import java.time.format.ResolverStyle import java.time.temporal.ChronoField import java.util.Date import java.util.TimeZone +import java.time.temporal.Temporal import java.time.temporal.TemporalAccessor +import java.time.temporal.TemporalField import kotlin.math.floor +import java.util.regex.Matcher +import java.util.regex.Pattern -data class InstantZone(val instant: Instant, val zoneId: ZoneId?) +data class Pair(val key: K, val value: V) + +class DateParser { + companion object { + + /** utility class to merge multiple TemporalAccessors into one. It queries TemporalAccessors in order + * until it finds one that supports the requested field (thus preserving priority if needed) + */ + class MergedTemporalAccessor( + private val parts: List + ) : TemporalAccessor { + + override fun isSupported(field: TemporalField): Boolean = + parts.any { it.isSupported(field) } + + override fun getLong(field: TemporalField): Long { + val source = parts.firstOrNull { it.isSupported(field) } ?: throw UnsupportedOperationException("Field $field not supported") + return source.getLong(field) + } + + } + + val TAG = DateParser::class.java.simpleName + + private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE) + + private val DATETIME_FIELDS = listOf( + "SubSecDateTimeOriginal", + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_DATETIME, + "SonyDateTime", + ) + + private val DATE_FIELDS = listOf( + "SubSecCreateDate", + "CreationDate", + "CreationDateValue", + "CreateDate", + "TrackCreateDate", + "MediaCreateDate", + "FileCreateDate", + ) + + private val PAIRED_DATE_TIME_FIELDS = listOf( + Pair(ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_TIMESTAMP), + ) + + private val OFFSET_FIELDS = listOf( + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + "TimeZone", + "LocationTZID" + ) + + private val DATETIME_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_DATE_TIME, + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.RFC_1123_DATE_TIME + ) + + private val DATE_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("yyyy:MM:dd[XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ofPattern("yyyy-MM-dd[XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_DATE, + DateTimeFormatter.ISO_ORDINAL_DATE, + DateTimeFormatter.ISO_WEEK_DATE + ) + + private val TIME_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_TIME + ) + + private val ZONE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("[XXXXX][XXXX][XXX][XX][X]") + + private val FILENAME_PATTERNS: List = listOf( + DatePattern(".*?(\\d{8})_(\\d{6}).*", listOf(DateTimeFormatter.BASIC_ISO_DATE, DateTimeFormatter.ofPattern("HHmmss"))), // Standard Camera/Android/Pixel (e.g., IMG_20230520_143055.jpg or 20230520_143055.mp4) + DatePattern(".*?(\\d{8}).*", DateTimeFormatter.BASIC_ISO_DATE), // WhatsApp Image/Video (e.g., IMG-20230520-WA0001.jpg) + DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}\\.\\d{2}\\.\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH.mm.ss"))), // iOS / Screenshot standard (e.g., Screenshot 2023-05-20 at 14.30.55.png) + DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}-\\d{2}-\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH-mm-ss"))), // Generic Separators (e.g., 2023-05-20 14-30-55.jpg) + DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*", DateTimeFormatter.ISO_DATE) // 5. ISO Date Only (e.g., Report_2023-05-20.pdf) + ) + + fun inferEarliestDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, filename: String, mtime: Long): ZonedDateTime { + // Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any) + val exifZone: ZoneId? = exif?.let { e -> + OFFSET_FIELDS.mapNotNull { e.getAttribute(it)} + .map { + try { parseZoneFromString(it) } + catch (_: Exception) { + Log.e(TAG, "Unable to parse zone from EXIF field containing: '$it'") + null + } + }.firstOrNull { it != null } + } + + var candidates: MutableList> = mutableListOf() + + // try to parse every field and add to the accessor list each successful one + if (exif != null) { + for (field in DATETIME_FIELDS) { + try { + exif.getAttribute(field)?.let { + candidates += Pair("Exif $field", parseDateTimeFromString(it)) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date time from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") + } + } + + for (field in DATE_FIELDS) { + try { + exif.getAttribute(field)?.let { + candidates += Pair("Exif $field", parseDateFromString(it)) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") + } + } + + for ((key, v) in PAIRED_DATE_TIME_FIELDS) { + try { + val date = exif.getAttribute(key) + val time = exif.getAttribute(v) + + if (date != null && time != null) { + candidates += Pair("Exif $key and $v", MergedTemporalAccessor(listOf(parseDateFromString(date), parseTimeFromString(time)))) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse paired date time from EXIF fields containing: '${exif.getAttribute(key) ?: ""}' and '${exif.getAttribute(v) ?: ""}': ${e.message}") + } + } + } + + // add the fallback to filename, dateTaken and mtime + try { + candidates += Pair("Filename", parseDateTimeFromFilename(filename)) + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date time from filename: '${filename}': ${e.message}") + } + + if (dateTaken != null) { + candidates += Pair("MediaStore dateTaken", Instant.ofEpochSecond(dateTaken)) + } + + candidates += Pair("MediaStore mtime", Instant.ofEpochSecond(mtime)) + + // find out the earliest date (>0) among all candidates by querying INSTANT_SECONDS, or building it from EPOCH_DAY and SECOND_OF_DAY if possible + val bestAccessorPair: Pair? = candidates.minByOrNull { + if (it.value.isSupported(ChronoField.INSTANT_SECONDS)) { + val s = it.value.getLong(ChronoField.INSTANT_SECONDS) + if (s>0L) s else Long.MAX_VALUE + } + else if (it.value.isSupported(ChronoField.EPOCH_DAY)) { + val epochDay = it.value.getLong(ChronoField.EPOCH_DAY) + + // use end of day for comparison when second of day is not available + // this prioritizes same-day dates with a defined time + val secondOfDay = if (it.value.isSupported(ChronoField.SECOND_OF_DAY)) it.value.getLong(ChronoField.SECOND_OF_DAY) else (86400L) + val s = (epochDay * 86400L) + secondOfDay + if (s>0L) s else Long.MAX_VALUE + } else { + Log.e(TAG, "Could not get or calculate INSTANT_SECONDS from accessor: '${it.key}'='${it.value}' does not support INSTANT_SECONDS or EPOCH_DAY") + Long.MAX_VALUE + } + } + + // try to cast the bestAccessor to OffsetDateTime, LocalDateTime, LocalDate or Instant and handle each one accordingly + if (bestAccessorPair != null) { + val zonedDateTime = resolveDateFromAccessor(bestAccessorPair.value, exifZone, mimeType) + + // finally log the best field and return the instant and the zone + if (zonedDateTime != null) { + Log.v(TAG, "Date source: ${bestAccessorPair.key}, Correct inferred zone: ${exifZone != null}") + return zonedDateTime + } + } + + // fallback that should never happen since mtime is always available + Log.v(TAG, "Date source: none") + return ZonedDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC) + } + + fun getDayId(zonedDateTime: ZonedDateTime): Long { + // shift the zone to UTC keeping the local clock untouched, then calculate the day id using seconds since UTC epoch + val midnightUtc = zonedDateTime.withZoneSameLocal(ZoneOffset.UTC) + return floor(midnightUtc.toEpochSecond() / 86400.0).toLong() + } + + fun resolveDateFromAccessor(accessor: TemporalAccessor, exifZone: ZoneId?, mimeType: String?): ZonedDateTime? { + var zonedDateTime: ZonedDateTime? = null + + try { + // supports both ZoneID and Zone Offset + zonedDateTime = ZonedDateTime.from(accessor) + } catch (_: Exception) {} + + if (zonedDateTime == null) { + try { + // supports combining LocalDate and LocalTime + val localDateTime = LocalDateTime.from(accessor) + + if (exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true) { + // videos: treat as UTC then convert to exifZone (shift local clock to keep the instant unchanged) + zonedDateTime = localDateTime.atZone(ZoneOffset.UTC).withZoneSameInstant(exifZone) + } else { + // photos: treat as local time in exifZone (no clock shift), or assume UTC as fallback both for photos and videos + zonedDateTime = localDateTime.atZone(exifZone ?: ZoneOffset.UTC) + } + } catch (_: Exception) {} + } + + if (zonedDateTime == null) { + try { + val localDate = LocalDate.from(accessor) + zonedDateTime = localDate.atStartOfDay(exifZone ?: ZoneOffset.UTC) + } catch (_: Exception) {} + } + + if (zonedDateTime == null) { + try { + val instant = Instant.from(accessor) + zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + } catch (_: Exception) {} + } + + return zonedDateTime + } + + private class DatePattern { + val pattern: Pattern + val dateFormatters: List + + constructor(regex: String, dateFormatters: List) { + this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) + this.dateFormatters = dateFormatters + } + + constructor(regex: String, dateFormatter: DateTimeFormatter) { + this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) + this.dateFormatters = listOf(dateFormatter) + } + + fun match_parse(str: String): TemporalAccessor { + val matcher = pattern.matcher(str) + var accessors: MutableList = mutableListOf() + if (matcher.find()) { + for ((i, formatter) in dateFormatters.withIndex()) { + try { + val match = matcher.group(i+1) // group 0 is the entire sequence + accessors += formatter.parse(match) + } catch(e: Exception) { + if (e is IllegalStateException) throw e + else if (e is IndexOutOfBoundsException) throw IllegalArgumentException("DatePattern object has less capturing groups (${i}) then formatters (${dateFormatters.size})") + else throw IllegalArgumentException("Could not parse a group of string '$str' with formatter '${formatter.toString()}'") + } + } + } + + if (accessors.isEmpty()) throw IllegalArgumentException("No date information found in string '$str'") + + // merge the information from all accessors + return MergedTemporalAccessor(accessors) + } + } + + fun parseDateTimeFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in DATETIME_FORMATTERS) { + try { + return formatter.parseBest(cleanStr, + OffsetDateTime::from, + LocalDateTime::from + ) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse date time: '$str'") + } + + fun parseDateFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in DATE_FORMATTERS) { + try { + return LocalDate.parse(cleanStr, formatter) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse date: '$str'") + } + + fun parseTimeFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in TIME_FORMATTERS) { + try { + return formatter.parseBest(cleanStr, + OffsetTime::from, + LocalTime::from + ) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse time: '$str'") + } + + fun parseZoneFromString(str: String): ZoneId { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + try { + return ZoneId.of(cleanStr) + } catch (_: Exception) {} + + try { + return ZoneId.from(ZONE_FORMATTER.parse(cleanStr)) + } catch (_: Exception) {} + } + + throw IllegalArgumentException("Unable to parse zone: '$str'") + } + + fun parseDateTimeFromFilename(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + for (dp in FILENAME_PATTERNS) { + try { + return dp.match_parse(cleanStr) + } catch(_: Exception) {} + } + + throw IllegalArgumentException("Unable to parse date from filename: $str") + } + } +} class SystemImage { var fileId = 0L @@ -54,63 +399,6 @@ class SystemImage { val IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val VIDEO_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - val DATE_FIELDS = listOf( - "SubSecDateTimeOriginal", - ExifInterface.TAG_DATETIME_ORIGINAL, - ExifInterface.TAG_DATETIME_DIGITIZED, - ExifInterface.TAG_DATETIME, - "SonyDateTime", - - "SubSecCreateDate", - "CreationDate", - "CreationDateValue", - "CreateDate", - "TrackCreateDate", - "MediaCreateDate", - "FileCreateDate", - - "SubSecModifyDate", - "ModifyDate", - "TrackModifyDate", - "MediaModifyDate", - "FileModifyDate", - ) - - // Flexible formatter: optional seconds and optional offset - private val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern( - "yyyy-MM-dd['T'HH:mm[:ss][XXX]]" - ) - - // Precompiled regexes for performance - private val OFFSET_RE = Regex("([+-]\\d{2}:?\\d{2}|Z)$") - private val FRAC_RE = Regex("""\.\d+""") - private val TIME_SEC_RE = Regex("""\d{2}:\d{2}:\d{2}""") - private val TRAILING_Z_RE = Regex("Z$") - private val OFFSET_NO_COLON_RE = Regex("([+-])(\\d{2})(\\d{2})$") - private val DATE_CLEANUP_RE = Regex("""^(\d{4}):(\d{2}):(\d{2})""") - private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE) - - /** - * Normalize a raw exif date string: - * - Replace trailing Z with +00:00 - * - Replace comma decimal separator with dot - * - Normalize +HHMM / -HHMM to +HH:MM / -HH:MM - */ - private fun normalizeRaw(s: String): String { - var x = s.trim() - if (x.isEmpty()) return x - if (TRAILING_Z_RE.containsMatchIn(x)) { - x = x.replace(TRAILING_Z_RE, "+00:00") - } - // comma fractional -> dot - x = x.replace(',', '.') - // normalize +HHMM / -HHMM -> +HH:MM - if (OFFSET_NO_COLON_RE.containsMatchIn(x)) { - x = x.replace(OFFSET_NO_COLON_RE, "$1$2:$3") - } - return x - } - /** * Create ExifInterface from Uri if possible (prefers InputStream for scoped storage), * falls back to dataPath (file path) if provided. @@ -220,17 +508,13 @@ class SystemImage { val dateTaken = if (!cursor.isNull(dateTakenColumn)) cursor.getLong(dateTakenColumn) / 1000 else null - // Parse EXIF date using ExifInterface, otherwise fallback to MediaStore dateTaken or mtime - var instantZone = parseExifDate(image.exifInterface, image.mimeType, dateTaken, image.mtime) + // Infer the earliest date from any source + var zonedDateTime = DateParser.inferEarliestDate(image.exifInterface, image.mimeType, dateTaken, image.baseName, image.mtime) - var dateTakenInstant = instantZone.instant + // store the date taken in seconds since epoch (UTC) + image.dateTaken = zonedDateTime.toEpochSecond() - image.dateTaken = dateTakenInstant.getEpochSecond() - - val dateTakenZdt = instantZone.zoneId.let { dateTakenInstant.atZone(it) } - val midnightUtc = dateTakenZdt.toLocalDate().atStartOfDay(ZoneOffset.UTC) - - image.dayId = floor(midnightUtc.toEpochSecond() / 86400.0).toLong() + image.dayId = DateParser.getDayId(zonedDateTime) // Swap width/height if orientation is 90 or 270 val orientation = cursor.getInt(orientationColumn) @@ -243,138 +527,6 @@ class SystemImage { } } - fun parseExifDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, mtime: Long): InstantZone { - val candidates = mutableMapOf() - - if (exif != null) { - for (field in DATE_FIELDS) { - val v = exif.getAttribute(field) - if (!v.isNullOrEmpty() && !v.startsWith("0000:00:00")) { - candidates[field] = v - } - } - - // Add GPS date/time if available - val gpsDate = exif.getAttribute(ExifInterface.TAG_GPS_DATESTAMP) - val gpsTime = exif.getAttribute(ExifInterface.TAG_GPS_TIMESTAMP) - if (!gpsDate.isNullOrEmpty() && !gpsTime.isNullOrEmpty()) { - candidates["GPS"] = gpsDate.replace(':', '-') + "T" + gpsTime - } - } - - var bestAdjustedEpoch: Long? = null // epochSecond - precision - var bestInstant: Instant? = null - var bestZone: ZoneId? = null - - // Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any) - val exifZone: ZoneId? = exif?.let { e -> - try { - val tzStr = e.getAttribute("OffsetTimeOriginal") - ?: e.getAttribute("OffsetTime") - ?: e.getAttribute("OffsetTimeDigitized") - ?: e.getAttribute("TimeZone") - ?: e.getAttribute("LocationTZID") - if (tzStr != null) ZoneId.of(tzStr) else null - } catch (_: Exception) { - Log.w(TAG, "Failed to parse EXIF timezone") - null - } - } - - var bestField: String? = null - - for ((field, raw) in candidates) { - var str = normalizeRaw(raw) - - str = str.replaceFirst(DATE_CLEANUP_RE, "$1-$2-$3") - str = str.replaceFirst(' ', 'T') - - try { - val parsed: TemporalAccessor = try { - // parseBest tries OffsetDateTime first, then LocalDateTime, then LocalDate - DATE_TIME_FORMATTER.parseBest( - str, - { OffsetDateTime.from(it) }, - { LocalDateTime.from(it) }, - { LocalDate.from(it) } - ) - } catch (e: Exception) { - throw IllegalArgumentException("Failed to parse datetime: $str", e) - } - - var instant: Instant - var parsedZoneFromString: ZoneId? = null - - when (parsed) { - is OffsetDateTime -> { - // string had explicit offset - instant = parsed.toInstant() - parsedZoneFromString = parsed.offset - } - is LocalDateTime -> { - instant = when { - exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true -> { - // videos: treat as UTC then convert to exifZone (shift clock) - parsed.atZone(ZoneOffset.UTC).toInstant() - } - exifZone != null -> { - // photos: treat as local time in exifZone (no clock shift) - parsed.atZone(exifZone).toInstant() - } - else -> { - // fallback: assume UTC - parsed.atZone(ZoneOffset.UTC).toInstant() - } - } - } - is LocalDate -> { - // only a date, assume start of day in exifZone or UTC - instant = (exifZone ?: ZoneOffset.UTC).let { parsed.atStartOfDay(it).toInstant() } - } - else -> throw IllegalArgumentException("Unsupported datetime format: $str") - } - - // Filter out QuickTime bogus timestamp (1904-01-01) or timestamps way before 1800 - val ts = instant.getEpochSecond() - if (ts == -2082844800L || ts <= -5_364_662_400L) { - continue - } - - // determine precision: fractional seconds > seconds > minutes - val precision = when { - FRAC_RE.containsMatchIn(str) -> 3 - TIME_SEC_RE.containsMatchIn(str) -> 2 - else -> 1 - } - - val adjusted = ts - precision - - if (adjusted > 0 && (bestAdjustedEpoch == null || adjusted < bestAdjustedEpoch)) { - bestAdjustedEpoch = adjusted - bestInstant = instant - bestZone = (parsedZoneFromString ?: exifZone) - bestField = field - } - } catch (ex: Exception) { - Log.v(TAG, "parse failed for field=$field value=$str: ${ex.message}") - // continue to next candidate - } - } - - if (bestInstant == null) { - if (dateTaken != null && dateTaken > 0) { - Log.v(TAG, "Date source: MediaStore dateTaken") - return InstantZone(Instant.ofEpochSecond(dateTaken), ZoneOffset.UTC) - } else { - Log.v(TAG, "Date source: MediaStore mtime") - return InstantZone(Instant.ofEpochSecond(mtime), ZoneOffset.UTC) - } - } - - Log.v(TAG, "Date source: EXIF $bestField") - return InstantZone(bestInstant, bestZone) - } - /** * Get image or video by a list of IDs * @param ctx Context - application context diff --git a/android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt b/android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt new file mode 100644 index 000000000..7a539a9e8 --- /dev/null +++ b/android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt @@ -0,0 +1,205 @@ +package gallery.memories.mapper + +import androidx.exifinterface.media.ExifInterface +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import android.util.Log +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.* + +class DateParserTest { + + @BeforeEach + fun setUp() { + mockkStatic(Log::class) + + val logCaptor = { tag: String, msg: String -> + println("[$tag]: $msg") + 0 + } + + every { Log.v(any(), any()) } answers { logCaptor(arg(0), arg(1)) } + every { Log.d(any(), any()) } answers { logCaptor(arg(0), arg(1)) } + every { Log.i(any(), any()) } answers { logCaptor(arg(0), arg(1)) } + every { Log.w(any(), any()) } answers { logCaptor(arg(0), arg(1)) } + + // Handling Log.e which often has an optional Throwable + every { Log.e(any(), any()) } answers { logCaptor(arg(0), arg(1)) } + every { Log.e(any(), any(), any()) } answers { + println("[${arg(0)}] ERROR: ${arg(1)} - ${arg(2).message}") + 0 + } + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `inferEarliestDate should use TAG_DATETIME_ORIGINAL with TAG_OFFSET_TIME_ORIGINAL from Exif`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL) } returns "+05:00" + every { exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) } returns "2023:01:01 12:00:00" + + val result = DateParser.inferEarliestDate( + exif = exif, + mimeType = "image/jpeg", + dateTaken = Instant.now().getEpochSecond(), + filename = "IMG.jpg", + mtime = Instant.now().getEpochSecond() + ) + + val expectedEpoch = ZonedDateTime.parse("2023-01-01T07:00:00Z").toEpochSecond() + assertEquals(expectedEpoch, result.toEpochSecond()) + + val dayId = DateParser.getDayId(result) + assertEquals(19358L, dayId) + } + + @Test + fun `inferEarliestDate should use Filename - datetime`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) } returns "2025:01:01 12:00:00" + + val filename = "IMG_20230520_143055.jpg" + + val result = DateParser.inferEarliestDate( + exif = null, + mimeType = "image/jpeg", + dateTaken = 0L, + filename = filename, + mtime = Instant.now().getEpochSecond() + ) + + val expected = ZonedDateTime.parse("2023-05-20T14:30:55Z") + assertEquals(expected.toEpochSecond(), result.toEpochSecond()) + + val dayId = DateParser.getDayId(result) + // 2023-05-20 + assertEquals(19497L, dayId) + } + + @Test + fun `inferEarliestDate should use Filename - date`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) } returns "2025:01:01 12:00:00" + + val filename = "IMG_20230520.jpg" + + val result = DateParser.inferEarliestDate( + exif = null, + mimeType = "image/jpeg", + dateTaken = 0L, + filename = filename, + mtime = Instant.now().getEpochSecond() + ) + + val expected = ZonedDateTime.parse("2023-05-20T00:00:00Z") + assertEquals(expected.toEpochSecond(), result.toEpochSecond()) + + val dayId = DateParser.getDayId(result) + // 2023-05-20 + assertEquals(19497L, dayId) + } + + @Test + fun `inferEarliestDate should use dateTaken`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + + val dateTaken = 1672531200L // 2023-01-01 00:00:00 UTC + + val result = DateParser.inferEarliestDate( + exif = exif, + mimeType = "image/jpeg", + dateTaken = dateTaken, + filename = "random_name.jpg", + mtime = Instant.now().getEpochSecond() + ) + + assertEquals(dateTaken, result.toEpochSecond()) + + val dayId = DateParser.getDayId(result) + assertEquals(19358L, dayId) + } + + @Test + fun `inferEarliestDate should use GPS paired datetime`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_GPS_DATESTAMP) } returns "2023:01:01" + every { exif.getAttribute(ExifInterface.TAG_GPS_TIMESTAMP) } returns "12:00:00" + + val result = DateParser.inferEarliestDate( + exif = exif, + mimeType = "image/jpeg", + dateTaken = Instant.now().getEpochSecond(), + filename = "IMG.jpg", + mtime = Instant.now().getEpochSecond() + ) + + // GPS time is UTC. + val expected = ZonedDateTime.parse("2023-01-01T12:00:00Z") + assertEquals(expected.toEpochSecond(), result.toEpochSecond()) + } + + @Test + fun `inferEarliestDate should treat video as UTC and shift to Exif Zone`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) } returns "2023:01:01 12:00:00" + every { exif.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL) } returns "+02:00" + + val result = DateParser.inferEarliestDate( + exif = exif, + mimeType = "video/mp4", + dateTaken = Instant.now().getEpochSecond(), + filename = "VID.mp4", + mtime = Instant.now().getEpochSecond() + ) + + // 2023-01-01 12:00:00 treated as UTC. + val expected = ZonedDateTime.parse("2023-01-01T12:00:00Z") + + assertEquals(expected.toEpochSecond(), result.toEpochSecond()) + assertEquals(ZoneId.of("+02:00"), result.zone) + + // Day ID should be calculated from UTC + val dayId = DateParser.getDayId(result) + assertEquals(19358L, dayId) // 2023-01-01 + // (12:00 UTC is still 2023-01-01) + } + + @Test + fun `inferEarliestDate should prioritize fields with defined time of day, among fields with the same date`() { + val exif = mockk() + every { exif.getAttribute(any()) } returns null + every { exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) } returns "2023-05-20 12:00:00" + + val filename = "IMG_20230520.jpg" + + val result = DateParser.inferEarliestDate( + exif = exif, + mimeType = "image/jpeg", + dateTaken = 0L, + filename = filename, + mtime = Instant.now().getEpochSecond() + ) + + val expected = ZonedDateTime.parse("2023-05-20T12:00:00Z") + assertEquals(expected.toEpochSecond(), result.toEpochSecond()) + + val dayId = DateParser.getDayId(result) + // 2023-05-20 + assertEquals(19497L, dayId) + } +} From 3cc970ec936e822132a208bd7272d995a4e5b24b Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 1 Feb 2026 17:08:26 +0100 Subject: [PATCH 3/3] move DateParser into separate file --- android/app/build.gradle | 7 +- .../gallery/memories/mapper/SystemImage.kt | 367 +----------------- .../gallery/memories/utility/DateParser.kt | 356 +++++++++++++++++ .../{mapper => utility}/DateParserTest.kt | 2 +- 4 files changed, 371 insertions(+), 361 deletions(-) create mode 100644 android/app/src/main/java/gallery/memories/utility/DateParser.kt rename android/app/src/test/java/gallery/memories/{mapper => utility}/DateParserTest.kt (99%) diff --git a/android/app/build.gradle b/android/app/build.gradle index b6768f0fc..ede14fef6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,7 +41,7 @@ android { testLogging { events "skipped", "failed" - showStandardStreams = true + showStandardStreams = false // set to true to debug tests exceptionFormat = "full" } } @@ -72,4 +72,9 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:5.3.2" implementation "io.github.g00fy2:versioncompare:1.5.0" + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.14.2' + testImplementation "io.mockk:mockk:1.14.9" + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.14.2' } \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt index 96b132a05..ec71e1b1e 100644 --- a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt +++ b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt @@ -11,363 +11,7 @@ import java.io.IOException import java.io.InputStream import java.math.BigInteger import java.security.MessageDigest -import java.util.Calendar -import java.time.* -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeFormatterBuilder -import java.time.format.ResolverStyle -import java.time.temporal.ChronoField -import java.util.Date -import java.util.TimeZone -import java.time.temporal.Temporal -import java.time.temporal.TemporalAccessor -import java.time.temporal.TemporalField -import kotlin.math.floor -import java.util.regex.Matcher -import java.util.regex.Pattern - -data class Pair(val key: K, val value: V) - -class DateParser { - companion object { - - /** utility class to merge multiple TemporalAccessors into one. It queries TemporalAccessors in order - * until it finds one that supports the requested field (thus preserving priority if needed) - */ - class MergedTemporalAccessor( - private val parts: List - ) : TemporalAccessor { - - override fun isSupported(field: TemporalField): Boolean = - parts.any { it.isSupported(field) } - - override fun getLong(field: TemporalField): Long { - val source = parts.firstOrNull { it.isSupported(field) } ?: throw UnsupportedOperationException("Field $field not supported") - return source.getLong(field) - } - - } - - val TAG = DateParser::class.java.simpleName - - private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE) - - private val DATETIME_FIELDS = listOf( - "SubSecDateTimeOriginal", - ExifInterface.TAG_DATETIME_ORIGINAL, - ExifInterface.TAG_DATETIME_DIGITIZED, - ExifInterface.TAG_DATETIME, - "SonyDateTime", - ) - - private val DATE_FIELDS = listOf( - "SubSecCreateDate", - "CreationDate", - "CreationDateValue", - "CreateDate", - "TrackCreateDate", - "MediaCreateDate", - "FileCreateDate", - ) - - private val PAIRED_DATE_TIME_FIELDS = listOf( - Pair(ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_TIMESTAMP), - ) - - private val OFFSET_FIELDS = listOf( - ExifInterface.TAG_OFFSET_TIME_ORIGINAL, - ExifInterface.TAG_OFFSET_TIME_DIGITIZED, - ExifInterface.TAG_OFFSET_TIME, - "TimeZone", - "LocationTZID" - ) - - private val DATETIME_FORMATTERS: List = listOf( - DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), - DateTimeFormatter.ISO_DATE_TIME, - DateTimeFormatter.ISO_INSTANT, - DateTimeFormatter.RFC_1123_DATE_TIME - ) - - private val DATE_FORMATTERS: List = listOf( - DateTimeFormatter.ofPattern("yyyy:MM:dd[XXXXX][XXXX][XXX][XX][X]"), - DateTimeFormatter.ofPattern("yyyy-MM-dd[XXXXX][XXXX][XXX][XX][X]"), - DateTimeFormatter.ISO_DATE, - DateTimeFormatter.ISO_ORDINAL_DATE, - DateTimeFormatter.ISO_WEEK_DATE - ) - - private val TIME_FORMATTERS: List = listOf( - DateTimeFormatter.ofPattern("HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), - DateTimeFormatter.ISO_TIME - ) - - private val ZONE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("[XXXXX][XXXX][XXX][XX][X]") - - private val FILENAME_PATTERNS: List = listOf( - DatePattern(".*?(\\d{8})_(\\d{6}).*", listOf(DateTimeFormatter.BASIC_ISO_DATE, DateTimeFormatter.ofPattern("HHmmss"))), // Standard Camera/Android/Pixel (e.g., IMG_20230520_143055.jpg or 20230520_143055.mp4) - DatePattern(".*?(\\d{8}).*", DateTimeFormatter.BASIC_ISO_DATE), // WhatsApp Image/Video (e.g., IMG-20230520-WA0001.jpg) - DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}\\.\\d{2}\\.\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH.mm.ss"))), // iOS / Screenshot standard (e.g., Screenshot 2023-05-20 at 14.30.55.png) - DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}-\\d{2}-\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH-mm-ss"))), // Generic Separators (e.g., 2023-05-20 14-30-55.jpg) - DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*", DateTimeFormatter.ISO_DATE) // 5. ISO Date Only (e.g., Report_2023-05-20.pdf) - ) - - fun inferEarliestDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, filename: String, mtime: Long): ZonedDateTime { - // Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any) - val exifZone: ZoneId? = exif?.let { e -> - OFFSET_FIELDS.mapNotNull { e.getAttribute(it)} - .map { - try { parseZoneFromString(it) } - catch (_: Exception) { - Log.e(TAG, "Unable to parse zone from EXIF field containing: '$it'") - null - } - }.firstOrNull { it != null } - } - - var candidates: MutableList> = mutableListOf() - - // try to parse every field and add to the accessor list each successful one - if (exif != null) { - for (field in DATETIME_FIELDS) { - try { - exif.getAttribute(field)?.let { - candidates += Pair("Exif $field", parseDateTimeFromString(it)) - } - } catch (e: Exception) { - Log.e(TAG, "Unable to parse date time from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") - } - } - - for (field in DATE_FIELDS) { - try { - exif.getAttribute(field)?.let { - candidates += Pair("Exif $field", parseDateFromString(it)) - } - } catch (e: Exception) { - Log.e(TAG, "Unable to parse date from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") - } - } - - for ((key, v) in PAIRED_DATE_TIME_FIELDS) { - try { - val date = exif.getAttribute(key) - val time = exif.getAttribute(v) - - if (date != null && time != null) { - candidates += Pair("Exif $key and $v", MergedTemporalAccessor(listOf(parseDateFromString(date), parseTimeFromString(time)))) - } - } catch (e: Exception) { - Log.e(TAG, "Unable to parse paired date time from EXIF fields containing: '${exif.getAttribute(key) ?: ""}' and '${exif.getAttribute(v) ?: ""}': ${e.message}") - } - } - } - - // add the fallback to filename, dateTaken and mtime - try { - candidates += Pair("Filename", parseDateTimeFromFilename(filename)) - } catch (e: Exception) { - Log.e(TAG, "Unable to parse date time from filename: '${filename}': ${e.message}") - } - - if (dateTaken != null) { - candidates += Pair("MediaStore dateTaken", Instant.ofEpochSecond(dateTaken)) - } - - candidates += Pair("MediaStore mtime", Instant.ofEpochSecond(mtime)) - - // find out the earliest date (>0) among all candidates by querying INSTANT_SECONDS, or building it from EPOCH_DAY and SECOND_OF_DAY if possible - val bestAccessorPair: Pair? = candidates.minByOrNull { - if (it.value.isSupported(ChronoField.INSTANT_SECONDS)) { - val s = it.value.getLong(ChronoField.INSTANT_SECONDS) - if (s>0L) s else Long.MAX_VALUE - } - else if (it.value.isSupported(ChronoField.EPOCH_DAY)) { - val epochDay = it.value.getLong(ChronoField.EPOCH_DAY) - - // use end of day for comparison when second of day is not available - // this prioritizes same-day dates with a defined time - val secondOfDay = if (it.value.isSupported(ChronoField.SECOND_OF_DAY)) it.value.getLong(ChronoField.SECOND_OF_DAY) else (86400L) - val s = (epochDay * 86400L) + secondOfDay - if (s>0L) s else Long.MAX_VALUE - } else { - Log.e(TAG, "Could not get or calculate INSTANT_SECONDS from accessor: '${it.key}'='${it.value}' does not support INSTANT_SECONDS or EPOCH_DAY") - Long.MAX_VALUE - } - } - - // try to cast the bestAccessor to OffsetDateTime, LocalDateTime, LocalDate or Instant and handle each one accordingly - if (bestAccessorPair != null) { - val zonedDateTime = resolveDateFromAccessor(bestAccessorPair.value, exifZone, mimeType) - - // finally log the best field and return the instant and the zone - if (zonedDateTime != null) { - Log.v(TAG, "Date source: ${bestAccessorPair.key}, Correct inferred zone: ${exifZone != null}") - return zonedDateTime - } - } - - // fallback that should never happen since mtime is always available - Log.v(TAG, "Date source: none") - return ZonedDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC) - } - - fun getDayId(zonedDateTime: ZonedDateTime): Long { - // shift the zone to UTC keeping the local clock untouched, then calculate the day id using seconds since UTC epoch - val midnightUtc = zonedDateTime.withZoneSameLocal(ZoneOffset.UTC) - return floor(midnightUtc.toEpochSecond() / 86400.0).toLong() - } - - fun resolveDateFromAccessor(accessor: TemporalAccessor, exifZone: ZoneId?, mimeType: String?): ZonedDateTime? { - var zonedDateTime: ZonedDateTime? = null - - try { - // supports both ZoneID and Zone Offset - zonedDateTime = ZonedDateTime.from(accessor) - } catch (_: Exception) {} - - if (zonedDateTime == null) { - try { - // supports combining LocalDate and LocalTime - val localDateTime = LocalDateTime.from(accessor) - - if (exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true) { - // videos: treat as UTC then convert to exifZone (shift local clock to keep the instant unchanged) - zonedDateTime = localDateTime.atZone(ZoneOffset.UTC).withZoneSameInstant(exifZone) - } else { - // photos: treat as local time in exifZone (no clock shift), or assume UTC as fallback both for photos and videos - zonedDateTime = localDateTime.atZone(exifZone ?: ZoneOffset.UTC) - } - } catch (_: Exception) {} - } - - if (zonedDateTime == null) { - try { - val localDate = LocalDate.from(accessor) - zonedDateTime = localDate.atStartOfDay(exifZone ?: ZoneOffset.UTC) - } catch (_: Exception) {} - } - - if (zonedDateTime == null) { - try { - val instant = Instant.from(accessor) - zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) - } catch (_: Exception) {} - } - - return zonedDateTime - } - - private class DatePattern { - val pattern: Pattern - val dateFormatters: List - - constructor(regex: String, dateFormatters: List) { - this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) - this.dateFormatters = dateFormatters - } - - constructor(regex: String, dateFormatter: DateTimeFormatter) { - this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) - this.dateFormatters = listOf(dateFormatter) - } - - fun match_parse(str: String): TemporalAccessor { - val matcher = pattern.matcher(str) - var accessors: MutableList = mutableListOf() - if (matcher.find()) { - for ((i, formatter) in dateFormatters.withIndex()) { - try { - val match = matcher.group(i+1) // group 0 is the entire sequence - accessors += formatter.parse(match) - } catch(e: Exception) { - if (e is IllegalStateException) throw e - else if (e is IndexOutOfBoundsException) throw IllegalArgumentException("DatePattern object has less capturing groups (${i}) then formatters (${dateFormatters.size})") - else throw IllegalArgumentException("Could not parse a group of string '$str' with formatter '${formatter.toString()}'") - } - } - } - - if (accessors.isEmpty()) throw IllegalArgumentException("No date information found in string '$str'") - - // merge the information from all accessors - return MergedTemporalAccessor(accessors) - } - } - - fun parseDateTimeFromString(str: String): TemporalAccessor { - val cleanStr = str.trim().replace("\\0", "") - if (cleanStr.isNotEmpty()) { - for (formatter in DATETIME_FORMATTERS) { - try { - return formatter.parseBest(cleanStr, - OffsetDateTime::from, - LocalDateTime::from - ) - } catch(_: Exception) {} - } - } - - throw IllegalArgumentException("Unable to parse date time: '$str'") - } - - fun parseDateFromString(str: String): TemporalAccessor { - val cleanStr = str.trim().replace("\\0", "") - if (cleanStr.isNotEmpty()) { - for (formatter in DATE_FORMATTERS) { - try { - return LocalDate.parse(cleanStr, formatter) - } catch(_: Exception) {} - } - } - - throw IllegalArgumentException("Unable to parse date: '$str'") - } - - fun parseTimeFromString(str: String): TemporalAccessor { - val cleanStr = str.trim().replace("\\0", "") - if (cleanStr.isNotEmpty()) { - for (formatter in TIME_FORMATTERS) { - try { - return formatter.parseBest(cleanStr, - OffsetTime::from, - LocalTime::from - ) - } catch(_: Exception) {} - } - } - - throw IllegalArgumentException("Unable to parse time: '$str'") - } - - fun parseZoneFromString(str: String): ZoneId { - val cleanStr = str.trim().replace("\\0", "") - if (cleanStr.isNotEmpty()) { - try { - return ZoneId.of(cleanStr) - } catch (_: Exception) {} - - try { - return ZoneId.from(ZONE_FORMATTER.parse(cleanStr)) - } catch (_: Exception) {} - } - - throw IllegalArgumentException("Unable to parse zone: '$str'") - } - - fun parseDateTimeFromFilename(str: String): TemporalAccessor { - val cleanStr = str.trim().replace("\\0", "") - for (dp in FILENAME_PATTERNS) { - try { - return dp.match_parse(cleanStr) - } catch(_: Exception) {} - } - - throw IllegalArgumentException("Unable to parse date from filename: $str") - } - } -} +import gallery.memories.utility.DateParser class SystemImage { var fileId = 0L @@ -425,8 +69,13 @@ class SystemImage { } /** - * Cursor sequence over media store entries. - * ctx is used to open InputStream for EXIF reading so we can support scoped storage. + * Iterate over all images/videos in the given collection + * @param ctx Context - application context + * @param collection Uri - either IMAGE_URI or VIDEO_URI + * @param selection String? - selection string + * @param selectionArgs Array? - selection arguments + * @param sortOrder String? - sort order + * @return Sequence */ fun cursor( ctx: Context, diff --git a/android/app/src/main/java/gallery/memories/utility/DateParser.kt b/android/app/src/main/java/gallery/memories/utility/DateParser.kt new file mode 100644 index 000000000..f9b251f1b --- /dev/null +++ b/android/app/src/main/java/gallery/memories/utility/DateParser.kt @@ -0,0 +1,356 @@ +package gallery.memories.utility + +import android.util.Log +import androidx.exifinterface.media.ExifInterface +import java.time.* +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoField +import java.util.Date +import java.util.TimeZone +import java.time.temporal.TemporalAccessor +import java.time.temporal.TemporalField +import kotlin.math.floor +import java.util.regex.Pattern + +data class Pair(val key: K, val value: V) + +class DateParser { + companion object { + + /** utility class to merge multiple TemporalAccessors into one. It queries TemporalAccessors in order + * until it finds one that supports the requested field (thus preserving priority if needed) + */ + class MergedTemporalAccessor( + private val parts: List + ) : TemporalAccessor { + + override fun isSupported(field: TemporalField): Boolean = + parts.any { it.isSupported(field) } + + override fun getLong(field: TemporalField): Long { + val source = parts.firstOrNull { it.isSupported(field) } ?: throw UnsupportedOperationException("Field $field not supported") + return source.getLong(field) + } + + } + + val TAG = DateParser::class.java.simpleName + + private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE) + + private val DATETIME_FIELDS = listOf( + "SubSecDateTimeOriginal", + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_DATETIME, + "SonyDateTime", + ) + + private val DATE_FIELDS = listOf( + "SubSecCreateDate", + "CreationDate", + "CreationDateValue", + "CreateDate", + "TrackCreateDate", + "MediaCreateDate", + "FileCreateDate", + ) + + private val PAIRED_DATE_TIME_FIELDS = listOf( + Pair(ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_TIMESTAMP), + ) + + private val OFFSET_FIELDS = listOf( + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + "TimeZone", + "LocationTZID" + ) + + private val DATETIME_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_DATE_TIME, + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.RFC_1123_DATE_TIME + ) + + private val DATE_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("yyyy:MM:dd[XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ofPattern("yyyy-MM-dd[XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_DATE, + DateTimeFormatter.ISO_ORDINAL_DATE, + DateTimeFormatter.ISO_WEEK_DATE + ) + + private val TIME_FORMATTERS: List = listOf( + DateTimeFormatter.ofPattern("HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"), + DateTimeFormatter.ISO_TIME + ) + + private val ZONE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("[XXXXX][XXXX][XXX][XX][X]") + + private val FILENAME_PATTERNS: List = listOf( + RegexDateTimeFormatter(".*?(\\d{8})_(\\d{6}).*", listOf(DateTimeFormatter.BASIC_ISO_DATE, DateTimeFormatter.ofPattern("HHmmss"))), // Standard Camera/Android/Pixel (e.g., IMG_20230520_143055.jpg or 20230520_143055.mp4) + RegexDateTimeFormatter(".*?(\\d{8}).*", DateTimeFormatter.BASIC_ISO_DATE), // WhatsApp Image/Video (e.g., IMG-20230520-WA0001.jpg) + RegexDateTimeFormatter(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}\\.\\d{2}\\.\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH.mm.ss"))), // iOS / Screenshot standard (e.g., Screenshot 2023-05-20 at 14.30.55.png) + RegexDateTimeFormatter(".*?(\\d{4}-\\d{2}-\\d{2}).*?(\\d{2}-\\d{2}-\\d{2}).*", listOf(DateTimeFormatter.ISO_DATE, DateTimeFormatter.ofPattern("HH-mm-ss"))), // Generic Separators (e.g., 2023-05-20 14-30-55.jpg) + RegexDateTimeFormatter(".*?(\\d{4}-\\d{2}-\\d{2}).*", DateTimeFormatter.ISO_DATE) // 5. ISO Date Only (e.g., Report_2023-05-20.pdf) + ) + + fun inferEarliestDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, filename: String, mtime: Long): ZonedDateTime { + // Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any) + val exifZone: ZoneId? = exif?.let { e -> + OFFSET_FIELDS.mapNotNull { e.getAttribute(it)} + .map { + try { parseZoneFromString(it) } + catch (_: Exception) { + Log.e(TAG, "Unable to parse zone from EXIF field containing: '$it'") + null + } + }.firstOrNull { it != null } + } + + var candidates: MutableList> = mutableListOf() + + // try to parse every field and add to the accessor list each successful one + if (exif != null) { + for (field in DATETIME_FIELDS) { + try { + exif.getAttribute(field)?.let { + candidates += Pair("Exif $field", parseDateTimeFromString(it)) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date time from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") + } + } + + for (field in DATE_FIELDS) { + try { + exif.getAttribute(field)?.let { + candidates += Pair("Exif $field", parseDateFromString(it)) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}") + } + } + + for ((key, v) in PAIRED_DATE_TIME_FIELDS) { + try { + val date = exif.getAttribute(key) + val time = exif.getAttribute(v) + + if (date != null && time != null) { + candidates += Pair("Exif $key and $v", MergedTemporalAccessor(listOf(parseDateFromString(date), parseTimeFromString(time)))) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to parse paired date time from EXIF fields containing: '${exif.getAttribute(key) ?: ""}' and '${exif.getAttribute(v) ?: ""}': ${e.message}") + } + } + } + + // add the fallback to filename, dateTaken and mtime + try { + candidates += Pair("Filename", parseDateTimeFromFilename(filename)) + } catch (e: Exception) { + Log.e(TAG, "Unable to parse date time from filename: '${filename}': ${e.message}") + } + + if (dateTaken != null) { + candidates += Pair("MediaStore dateTaken", Instant.ofEpochSecond(dateTaken)) + } + + candidates += Pair("MediaStore mtime", Instant.ofEpochSecond(mtime)) + + // find out the earliest date (>0) among all candidates by querying INSTANT_SECONDS, or building it from EPOCH_DAY and SECOND_OF_DAY if possible + val bestAccessorPair: Pair? = candidates.minByOrNull { + if (it.value.isSupported(ChronoField.INSTANT_SECONDS)) { + val s = it.value.getLong(ChronoField.INSTANT_SECONDS) + if (s>0L) s else Long.MAX_VALUE + } + else if (it.value.isSupported(ChronoField.EPOCH_DAY)) { + val epochDay = it.value.getLong(ChronoField.EPOCH_DAY) + + // use end of day for comparison when second of day is not available + // this prioritizes same-day dates with a defined time + val secondOfDay = if (it.value.isSupported(ChronoField.SECOND_OF_DAY)) it.value.getLong(ChronoField.SECOND_OF_DAY) else (86400L) + val s = (epochDay * 86400L) + secondOfDay + if (s>0L) s else Long.MAX_VALUE + } else { + Log.e(TAG, "Could not get or calculate INSTANT_SECONDS from accessor: '${it.key}'='${it.value}' does not support INSTANT_SECONDS or EPOCH_DAY") + Long.MAX_VALUE + } + } + + // try to cast the bestAccessor to OffsetDateTime, LocalDateTime, LocalDate or Instant and handle each one accordingly + if (bestAccessorPair != null) { + val zonedDateTime = resolveDateFromAccessor(bestAccessorPair.value, exifZone, mimeType) + + // finally log the best field and return the instant and the zone + if (zonedDateTime != null) { + Log.v(TAG, "Date source: ${bestAccessorPair.key}, Correct inferred zone: ${exifZone != null}") + return zonedDateTime + } + } + + // fallback that should never happen since mtime is always available + Log.v(TAG, "Date source: none") + return ZonedDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC) + } + + fun getDayId(zonedDateTime: ZonedDateTime): Long { + // shift the zone to UTC keeping the local clock untouched, then calculate the day id using seconds since UTC epoch + val midnightUtc = zonedDateTime.withZoneSameLocal(ZoneOffset.UTC) + return floor(midnightUtc.toEpochSecond() / 86400.0).toLong() + } + + fun resolveDateFromAccessor(accessor: TemporalAccessor, exifZone: ZoneId?, mimeType: String?): ZonedDateTime? { + var zonedDateTime: ZonedDateTime? = null + + try { + // supports both ZoneID and Zone Offset + zonedDateTime = ZonedDateTime.from(accessor) + } catch (_: Exception) {} + + if (zonedDateTime == null) { + try { + // supports combining LocalDate and LocalTime + val localDateTime = LocalDateTime.from(accessor) + + if (exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true) { + // videos: treat as UTC then convert to exifZone (shift local clock to keep the instant unchanged) + zonedDateTime = localDateTime.atZone(ZoneOffset.UTC).withZoneSameInstant(exifZone) + } else { + // photos: treat as local time in exifZone (no clock shift), or assume UTC as fallback both for photos and videos + zonedDateTime = localDateTime.atZone(exifZone ?: ZoneOffset.UTC) + } + } catch (_: Exception) {} + } + + if (zonedDateTime == null) { + try { + val localDate = LocalDate.from(accessor) + zonedDateTime = localDate.atStartOfDay(exifZone ?: ZoneOffset.UTC) + } catch (_: Exception) {} + } + + if (zonedDateTime == null) { + try { + val instant = Instant.from(accessor) + zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + } catch (_: Exception) {} + } + + return zonedDateTime + } + + private class RegexDateTimeFormatter { + val pattern: Pattern + val dateFormatters: List + + constructor(regex: String, dateFormatters: List) { + this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) + this.dateFormatters = dateFormatters + } + + constructor(regex: String, dateFormatter: DateTimeFormatter) { + this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) + this.dateFormatters = listOf(dateFormatter) + } + + fun match_parse(str: String): TemporalAccessor { + val matcher = pattern.matcher(str) + var accessors: MutableList = mutableListOf() + if (matcher.find()) { + for ((i, formatter) in dateFormatters.withIndex()) { + try { + val match = matcher.group(i+1) // group 0 is the entire sequence + accessors += formatter.parse(match) + } catch(e: Exception) { + if (e is IllegalStateException) throw e + else if (e is IndexOutOfBoundsException) throw IllegalArgumentException("RegexDateTimeFormatter object has less capturing groups (${i}) then formatters (${dateFormatters.size})") + else throw IllegalArgumentException("Could not parse a group of string '$str' with formatter '${formatter.toString()}'") + } + } + } + + if (accessors.isEmpty()) throw IllegalArgumentException("No date information found in string '$str'") + + // merge the information from all accessors + return MergedTemporalAccessor(accessors) + } + } + + fun parseDateTimeFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in DATETIME_FORMATTERS) { + try { + return formatter.parseBest(cleanStr, + OffsetDateTime::from, + LocalDateTime::from + ) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse date time: '$str'") + } + + fun parseDateFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in DATE_FORMATTERS) { + try { + return LocalDate.parse(cleanStr, formatter) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse date: '$str'") + } + + fun parseTimeFromString(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + for (formatter in TIME_FORMATTERS) { + try { + return formatter.parseBest(cleanStr, + OffsetTime::from, + LocalTime::from + ) + } catch(_: Exception) {} + } + } + + throw IllegalArgumentException("Unable to parse time: '$str'") + } + + fun parseZoneFromString(str: String): ZoneId { + val cleanStr = str.trim().replace("\\0", "") + if (cleanStr.isNotEmpty()) { + try { + return ZoneId.of(cleanStr) + } catch (_: Exception) {} + + try { + return ZoneId.from(ZONE_FORMATTER.parse(cleanStr)) + } catch (_: Exception) {} + } + + throw IllegalArgumentException("Unable to parse zone: '$str'") + } + + fun parseDateTimeFromFilename(str: String): TemporalAccessor { + val cleanStr = str.trim().replace("\\0", "") + for (dp in FILENAME_PATTERNS) { + try { + return dp.match_parse(cleanStr) + } catch(_: Exception) {} + } + + throw IllegalArgumentException("Unable to parse date from filename: $str") + } + } +} \ No newline at end of file diff --git a/android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt b/android/app/src/test/java/gallery/memories/utility/DateParserTest.kt similarity index 99% rename from android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt rename to android/app/src/test/java/gallery/memories/utility/DateParserTest.kt index 7a539a9e8..d8c5d4f5b 100644 --- a/android/app/src/test/java/gallery/memories/mapper/DateParserTest.kt +++ b/android/app/src/test/java/gallery/memories/utility/DateParserTest.kt @@ -1,4 +1,4 @@ -package gallery.memories.mapper +package gallery.memories.utility import androidx.exifinterface.media.ExifInterface import io.mockk.every