@@ -11,363 +11,7 @@ import java.io.IOException
1111import java.io.InputStream
1212import java.math.BigInteger
1313import 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
37216class 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