diff --git a/android/app/build.gradle b/android/app/build.gradle index 19f423e7e..ede14fef6 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 = false // set to true to debug tests + exceptionFormat = "full" + } + } + unitTests.returnDefaultValues = true + } } dependencies { @@ -59,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 d68402380..ec71e1b1e 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,28 @@ 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 gallery.memories.utility.DateParser 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,6 +43,31 @@ class SystemImage { val IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val VIDEO_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + /** + * 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 + } + /** * Iterate over all images/videos in the given collection * @param ctx Context - application context @@ -91,6 +118,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 +128,42 @@ 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 + + // Infer the earliest date from any source + var zonedDateTime = DateParser.inferEarliestDate(image.exifInterface, image.mimeType, dateTaken, image.baseName, image.mtime) + + // store the date taken in seconds since epoch (UTC) + image.dateTaken = zonedDateTime.toEpochSecond() + + image.dayId = DateParser.getDayId(zonedDateTime) // Swap width/height if orientation is 90 or 270 val orientation = cursor.getInt(orientationColumn) @@ -123,14 +171,6 @@ 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) - } - - // Add to main list yield(image) } } @@ -164,7 +204,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 +214,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 +226,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 +238,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 +257,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 +} 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/utility/DateParserTest.kt b/android/app/src/test/java/gallery/memories/utility/DateParserTest.kt new file mode 100644 index 000000000..d8c5d4f5b --- /dev/null +++ b/android/app/src/test/java/gallery/memories/utility/DateParserTest.kt @@ -0,0 +1,205 @@ +package gallery.memories.utility + +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) + } +}