Skip to content

Commit e9a44d6

Browse files
committed
move DateParser into separate file
1 parent 3453853 commit e9a44d6

3 files changed

Lines changed: 365 additions & 360 deletions

File tree

android/app/src/main/java/gallery/memories/mapper/SystemImage.kt

Lines changed: 8 additions & 359 deletions
Original file line numberDiff line numberDiff line change
@@ -11,363 +11,7 @@ import java.io.IOException
1111
import java.io.InputStream
1212
import java.math.BigInteger
1313
import java.security.MessageDigest
14-
import java.util.Calendar
15-
import java.time.*
16-
import java.time.format.DateTimeFormatter
17-
import java.time.format.DateTimeFormatterBuilder
18-
import java.time.format.ResolverStyle
19-
import java.time.temporal.ChronoField
20-
import java.util.Date
21-
import java.util.TimeZone
22-
import java.time.temporal.Temporal
23-
import java.time.temporal.TemporalAccessor
24-
import java.time.temporal.TemporalField
25-
import kotlin.math.floor
26-
import java.util.regex.Matcher
27-
import java.util.regex.Pattern
28-
29-
data class Pair<K, V>(val key: K, val value: V)
30-
31-
class DateParser {
32-
companion object {
33-
34-
/** utility class to merge multiple TemporalAccessors into one. It queries TemporalAccessors in order
35-
* until it finds one that supports the requested field (thus preserving priority if needed)
36-
*/
37-
class MergedTemporalAccessor(
38-
private val parts: List<TemporalAccessor>
39-
) : TemporalAccessor {
40-
41-
override fun isSupported(field: TemporalField): Boolean =
42-
parts.any { it.isSupported(field) }
43-
44-
override fun getLong(field: TemporalField): Long {
45-
val source = parts.firstOrNull { it.isSupported(field) } ?: throw UnsupportedOperationException("Field $field not supported")
46-
return source.getLong(field)
47-
}
48-
49-
}
50-
51-
val TAG = DateParser::class.java.simpleName
52-
53-
private val VIDEO_MIME_RE = Regex("^video/\\w+", RegexOption.IGNORE_CASE)
54-
55-
private val DATETIME_FIELDS = listOf(
56-
"SubSecDateTimeOriginal",
57-
ExifInterface.TAG_DATETIME_ORIGINAL,
58-
ExifInterface.TAG_DATETIME_DIGITIZED,
59-
ExifInterface.TAG_DATETIME,
60-
"SonyDateTime",
61-
)
62-
63-
private val DATE_FIELDS = listOf(
64-
"SubSecCreateDate",
65-
"CreationDate",
66-
"CreationDateValue",
67-
"CreateDate",
68-
"TrackCreateDate",
69-
"MediaCreateDate",
70-
"FileCreateDate",
71-
)
72-
73-
private val PAIRED_DATE_TIME_FIELDS = listOf(
74-
Pair(ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_TIMESTAMP),
75-
)
76-
77-
private val OFFSET_FIELDS = listOf(
78-
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
79-
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
80-
ExifInterface.TAG_OFFSET_TIME,
81-
"TimeZone",
82-
"LocationTZID"
83-
)
84-
85-
private val DATETIME_FORMATTERS: List<DateTimeFormatter> = listOf(
86-
DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"),
87-
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"),
88-
DateTimeFormatter.ISO_DATE_TIME,
89-
DateTimeFormatter.ISO_INSTANT,
90-
DateTimeFormatter.RFC_1123_DATE_TIME
91-
)
92-
93-
private val DATE_FORMATTERS: List<DateTimeFormatter> = listOf(
94-
DateTimeFormatter.ofPattern("yyyy:MM:dd[XXXXX][XXXX][XXX][XX][X]"),
95-
DateTimeFormatter.ofPattern("yyyy-MM-dd[XXXXX][XXXX][XXX][XX][X]"),
96-
DateTimeFormatter.ISO_DATE,
97-
DateTimeFormatter.ISO_ORDINAL_DATE,
98-
DateTimeFormatter.ISO_WEEK_DATE
99-
)
100-
101-
private val TIME_FORMATTERS: List<DateTimeFormatter> = listOf(
102-
DateTimeFormatter.ofPattern("HH:mm:ss[.SSS][.SS][.S][XXXXX][XXXX][XXX][XX][X]"),
103-
DateTimeFormatter.ISO_TIME
104-
)
105-
106-
private val ZONE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("[XXXXX][XXXX][XXX][XX][X]")
107-
108-
private val FILENAME_PATTERNS: List<DatePattern> = listOf(
109-
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)
110-
DatePattern(".*?(\\d{8}).*", DateTimeFormatter.BASIC_ISO_DATE), // WhatsApp Image/Video (e.g., IMG-20230520-WA0001.jpg)
111-
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)
112-
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)
113-
DatePattern(".*?(\\d{4}-\\d{2}-\\d{2}).*", DateTimeFormatter.ISO_DATE) // 5. ISO Date Only (e.g., Report_2023-05-20.pdf)
114-
)
115-
116-
fun inferEarliestDate(exif: ExifInterface?, mimeType: String?, dateTaken: Long?, filename: String, mtime: Long): ZonedDateTime {
117-
// Try to obtain explicit EXIF timezone from dedicated EXIF fields (if any)
118-
val exifZone: ZoneId? = exif?.let { e ->
119-
OFFSET_FIELDS.mapNotNull { e.getAttribute(it)}
120-
.map {
121-
try { parseZoneFromString(it) }
122-
catch (_: Exception) {
123-
Log.e(TAG, "Unable to parse zone from EXIF field containing: '$it'")
124-
null
125-
}
126-
}.firstOrNull { it != null }
127-
}
128-
129-
var candidates: MutableList<Pair<String, TemporalAccessor>> = mutableListOf()
130-
131-
// try to parse every field and add to the accessor list each successful one
132-
if (exif != null) {
133-
for (field in DATETIME_FIELDS) {
134-
try {
135-
exif.getAttribute(field)?.let {
136-
candidates += Pair("Exif $field", parseDateTimeFromString(it))
137-
}
138-
} catch (e: Exception) {
139-
Log.e(TAG, "Unable to parse date time from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}")
140-
}
141-
}
142-
143-
for (field in DATE_FIELDS) {
144-
try {
145-
exif.getAttribute(field)?.let {
146-
candidates += Pair("Exif $field", parseDateFromString(it))
147-
}
148-
} catch (e: Exception) {
149-
Log.e(TAG, "Unable to parse date from EXIF field containing: '${exif.getAttribute(field) ?: ""}': ${e.message}")
150-
}
151-
}
152-
153-
for ((key, v) in PAIRED_DATE_TIME_FIELDS) {
154-
try {
155-
val date = exif.getAttribute(key)
156-
val time = exif.getAttribute(v)
157-
158-
if (date != null && time != null) {
159-
candidates += Pair("Exif $key and $v", MergedTemporalAccessor(listOf(parseDateFromString(date), parseTimeFromString(time))))
160-
}
161-
} catch (e: Exception) {
162-
Log.e(TAG, "Unable to parse paired date time from EXIF fields containing: '${exif.getAttribute(key) ?: ""}' and '${exif.getAttribute(v) ?: ""}': ${e.message}")
163-
}
164-
}
165-
}
166-
167-
// add the fallback to filename, dateTaken and mtime
168-
try {
169-
candidates += Pair("Filename", parseDateTimeFromFilename(filename))
170-
} catch (e: Exception) {
171-
Log.e(TAG, "Unable to parse date time from filename: '${filename}': ${e.message}")
172-
}
173-
174-
if (dateTaken != null) {
175-
candidates += Pair("MediaStore dateTaken", Instant.ofEpochSecond(dateTaken))
176-
}
177-
178-
candidates += Pair("MediaStore mtime", Instant.ofEpochSecond(mtime))
179-
180-
// 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
181-
val bestAccessorPair: Pair<String, TemporalAccessor>? = candidates.minByOrNull {
182-
if (it.value.isSupported(ChronoField.INSTANT_SECONDS)) {
183-
val s = it.value.getLong(ChronoField.INSTANT_SECONDS)
184-
if (s>0L) s else Long.MAX_VALUE
185-
}
186-
else if (it.value.isSupported(ChronoField.EPOCH_DAY)) {
187-
val epochDay = it.value.getLong(ChronoField.EPOCH_DAY)
188-
189-
// use end of day for comparison when second of day is not available
190-
// this prioritizes same-day dates with a defined time
191-
val secondOfDay = if (it.value.isSupported(ChronoField.SECOND_OF_DAY)) it.value.getLong(ChronoField.SECOND_OF_DAY) else (86400L)
192-
val s = (epochDay * 86400L) + secondOfDay
193-
if (s>0L) s else Long.MAX_VALUE
194-
} else {
195-
Log.e(TAG, "Could not get or calculate INSTANT_SECONDS from accessor: '${it.key}'='${it.value}' does not support INSTANT_SECONDS or EPOCH_DAY")
196-
Long.MAX_VALUE
197-
}
198-
}
199-
200-
// try to cast the bestAccessor to OffsetDateTime, LocalDateTime, LocalDate or Instant and handle each one accordingly
201-
if (bestAccessorPair != null) {
202-
val zonedDateTime = resolveDateFromAccessor(bestAccessorPair.value, exifZone, mimeType)
203-
204-
// finally log the best field and return the instant and the zone
205-
if (zonedDateTime != null) {
206-
Log.v(TAG, "Date source: ${bestAccessorPair.key}, Correct inferred zone: ${exifZone != null}")
207-
return zonedDateTime
208-
}
209-
}
210-
211-
// fallback that should never happen since mtime is always available
212-
Log.v(TAG, "Date source: none")
213-
return ZonedDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC)
214-
}
215-
216-
fun getDayId(zonedDateTime: ZonedDateTime): Long {
217-
// shift the zone to UTC keeping the local clock untouched, then calculate the day id using seconds since UTC epoch
218-
val midnightUtc = zonedDateTime.withZoneSameLocal(ZoneOffset.UTC)
219-
return floor(midnightUtc.toEpochSecond() / 86400.0).toLong()
220-
}
221-
222-
fun resolveDateFromAccessor(accessor: TemporalAccessor, exifZone: ZoneId?, mimeType: String?): ZonedDateTime? {
223-
var zonedDateTime: ZonedDateTime? = null
224-
225-
try {
226-
// supports both ZoneID and Zone Offset
227-
zonedDateTime = ZonedDateTime.from(accessor)
228-
} catch (_: Exception) {}
229-
230-
if (zonedDateTime == null) {
231-
try {
232-
// supports combining LocalDate and LocalTime
233-
val localDateTime = LocalDateTime.from(accessor)
234-
235-
if (exifZone != null && mimeType?.matches(VIDEO_MIME_RE) == true) {
236-
// videos: treat as UTC then convert to exifZone (shift local clock to keep the instant unchanged)
237-
zonedDateTime = localDateTime.atZone(ZoneOffset.UTC).withZoneSameInstant(exifZone)
238-
} else {
239-
// photos: treat as local time in exifZone (no clock shift), or assume UTC as fallback both for photos and videos
240-
zonedDateTime = localDateTime.atZone(exifZone ?: ZoneOffset.UTC)
241-
}
242-
} catch (_: Exception) {}
243-
}
244-
245-
if (zonedDateTime == null) {
246-
try {
247-
val localDate = LocalDate.from(accessor)
248-
zonedDateTime = localDate.atStartOfDay(exifZone ?: ZoneOffset.UTC)
249-
} catch (_: Exception) {}
250-
}
251-
252-
if (zonedDateTime == null) {
253-
try {
254-
val instant = Instant.from(accessor)
255-
zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC)
256-
} catch (_: Exception) {}
257-
}
258-
259-
return zonedDateTime
260-
}
261-
262-
private class DatePattern {
263-
val pattern: Pattern
264-
val dateFormatters: List<DateTimeFormatter>
265-
266-
constructor(regex: String, dateFormatters: List<DateTimeFormatter>) {
267-
this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE)
268-
this.dateFormatters = dateFormatters
269-
}
270-
271-
constructor(regex: String, dateFormatter: DateTimeFormatter) {
272-
this.pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE)
273-
this.dateFormatters = listOf(dateFormatter)
274-
}
275-
276-
fun match_parse(str: String): TemporalAccessor {
277-
val matcher = pattern.matcher(str)
278-
var accessors: MutableList<TemporalAccessor> = mutableListOf()
279-
if (matcher.find()) {
280-
for ((i, formatter) in dateFormatters.withIndex()) {
281-
try {
282-
val match = matcher.group(i+1) // group 0 is the entire sequence
283-
accessors += formatter.parse(match)
284-
} catch(e: Exception) {
285-
if (e is IllegalStateException) throw e
286-
else if (e is IndexOutOfBoundsException) throw IllegalArgumentException("DatePattern object has less capturing groups (${i}) then formatters (${dateFormatters.size})")
287-
else throw IllegalArgumentException("Could not parse a group of string '$str' with formatter '${formatter.toString()}'")
288-
}
289-
}
290-
}
291-
292-
if (accessors.isEmpty()) throw IllegalArgumentException("No date information found in string '$str'")
293-
294-
// merge the information from all accessors
295-
return MergedTemporalAccessor(accessors)
296-
}
297-
}
298-
299-
fun parseDateTimeFromString(str: String): TemporalAccessor {
300-
val cleanStr = str.trim().replace("\\0", "")
301-
if (cleanStr.isNotEmpty()) {
302-
for (formatter in DATETIME_FORMATTERS) {
303-
try {
304-
return formatter.parseBest(cleanStr,
305-
OffsetDateTime::from,
306-
LocalDateTime::from
307-
)
308-
} catch(_: Exception) {}
309-
}
310-
}
311-
312-
throw IllegalArgumentException("Unable to parse date time: '$str'")
313-
}
314-
315-
fun parseDateFromString(str: String): TemporalAccessor {
316-
val cleanStr = str.trim().replace("\\0", "")
317-
if (cleanStr.isNotEmpty()) {
318-
for (formatter in DATE_FORMATTERS) {
319-
try {
320-
return LocalDate.parse(cleanStr, formatter)
321-
} catch(_: Exception) {}
322-
}
323-
}
324-
325-
throw IllegalArgumentException("Unable to parse date: '$str'")
326-
}
327-
328-
fun parseTimeFromString(str: String): TemporalAccessor {
329-
val cleanStr = str.trim().replace("\\0", "")
330-
if (cleanStr.isNotEmpty()) {
331-
for (formatter in TIME_FORMATTERS) {
332-
try {
333-
return formatter.parseBest(cleanStr,
334-
OffsetTime::from,
335-
LocalTime::from
336-
)
337-
} catch(_: Exception) {}
338-
}
339-
}
340-
341-
throw IllegalArgumentException("Unable to parse time: '$str'")
342-
}
343-
344-
fun parseZoneFromString(str: String): ZoneId {
345-
val cleanStr = str.trim().replace("\\0", "")
346-
if (cleanStr.isNotEmpty()) {
347-
try {
348-
return ZoneId.of(cleanStr)
349-
} catch (_: Exception) {}
350-
351-
try {
352-
return ZoneId.from(ZONE_FORMATTER.parse(cleanStr))
353-
} catch (_: Exception) {}
354-
}
355-
356-
throw IllegalArgumentException("Unable to parse zone: '$str'")
357-
}
358-
359-
fun parseDateTimeFromFilename(str: String): TemporalAccessor {
360-
val cleanStr = str.trim().replace("\\0", "")
361-
for (dp in FILENAME_PATTERNS) {
362-
try {
363-
return dp.match_parse(cleanStr)
364-
} catch(_: Exception) {}
365-
}
366-
367-
throw IllegalArgumentException("Unable to parse date from filename: $str")
368-
}
369-
}
370-
}
14+
import gallery.memories.utility.DateParser
37115

37216
class SystemImage {
37317
var fileId = 0L
@@ -425,8 +69,13 @@ class SystemImage {
42569
}
42670

42771
/**
428-
* Cursor sequence over media store entries.
429-
* ctx is used to open InputStream for EXIF reading so we can support scoped storage.
72+
* Iterate over all images/videos in the given collection
73+
* @param ctx Context - application context
74+
* @param collection Uri - either IMAGE_URI or VIDEO_URI
75+
* @param selection String? - selection string
76+
* @param selectionArgs Array<String>? - selection arguments
77+
* @param sortOrder String? - sort order
78+
* @return Sequence<SystemImage>
43079
*/
43180
fun cursor(
43281
ctx: Context,

0 commit comments

Comments
 (0)