@@ -8,41 +8,68 @@ package at.bitfire.vcard4android.contactrow
88
99import android.content.ContentValues
1010import android.provider.ContactsContract.CommonDataKinds.Event
11+ import androidx.annotation.VisibleForTesting
1112import at.bitfire.vcard4android.Contact
1213import at.bitfire.vcard4android.LabeledProperty
1314import at.bitfire.vcard4android.Utils.trimToNull
15+ import at.bitfire.vcard4android.contactrow.EventHandler.fullDateFormat
16+ import at.bitfire.vcard4android.contactrow.EventHandler.fullDateTimeFormats
1417import at.bitfire.vcard4android.property.XAbDate
1518import ezvcard.property.Anniversary
1619import ezvcard.property.Birthday
1720import ezvcard.util.PartialDate
1821import java.time.LocalDate
22+ import java.time.OffsetDateTime
23+ import java.time.format.DateTimeFormatter
1924import java.time.format.DateTimeParseException
20- import java.util.logging.Level
25+ import java.time.temporal.Temporal
26+
27+ /* *
28+ * Maps contact events (like birthdays and anniversaries) to vCard properties.
29+ *
30+ * Android stores the events as date/date-time strings, so we have to parse these strings.
31+ * Unfortunately, the format is not specified in the ContactsContract ("as the user entered it"):
32+ * https://developer.android.com/reference/android/provider/ContactsContract.CommonDataKinds.Event?hl=en#START_DATE
33+ *
34+ * At least we know the formats used by AOSP Contacts:
35+ * https://android.googlesource.com/platform/packages/apps/Contacts/+/c326c157541978c180be4e3432327eceb1e66637/src/com/android/contacts/util/CommonDateUtils.java#25
36+ * so we support at least these formats.
37+ */
38+ object EventHandler : DataRowHandler() {
39+
40+ /* *
41+ * Date formats for full date with time (taken from Android's CommonDateUtils).
42+ */
43+ private val fullDateTimeFormats = listOf (
44+ DateTimeFormatter .ofPattern(" yyyy-MM-dd'T'HH:mm:ss.SSSXXX" ),
45+ // "yyyy-MM-dd'T'HH:mm:ssXXX"
46+ DateTimeFormatter .ISO_OFFSET_DATE_TIME ,
47+ )
48+
49+ /* *
50+ * Date format for full date without time (taken from Android's CommonDateUtils).
51+ */
52+ private val fullDateFormat = DateTimeFormatter .ofPattern(" yyyy-MM-dd" )
2153
22- object EventHandler: DataRowHandler() {
2354
2455 override fun forMimeType () = Event .CONTENT_ITEM_TYPE
2556
2657 override fun handle (values : ContentValues , contact : Contact ) {
2758 super .handle(values, contact)
2859
2960 val dateStr = values.getAsString(Event .START_DATE ) ? : return
30- var full: LocalDate ? = null
31- var partial: PartialDate ? = null
32- try {
33- full = LocalDate .parse(dateStr)
34- } catch (e: DateTimeParseException ) {
35- try {
36- partial = PartialDate .parse(dateStr)
37- } catch (e: IllegalArgumentException ) {
38- logger.log(Level .WARNING , " Couldn't parse birthday/anniversary date from database" , e)
39- }
40- }
61+ val full: Temporal ? = parseFullDate(dateStr)
62+ val partial: PartialDate ? = if (full == null )
63+ parsePartialDate(dateStr)
64+ else
65+ null
4166
4267 if (full != null || partial != null )
4368 when (values.getAsInteger(Event .TYPE )) {
4469 Event .TYPE_ANNIVERSARY ->
45- contact.anniversary = if (full != null ) Anniversary (full) else Anniversary (partial)
70+ contact.anniversary =
71+ if (full != null ) Anniversary (full) else Anniversary (partial)
72+
4673 Event .TYPE_BIRTHDAY ->
4774 contact.birthDay = if (full != null ) Birthday (full) else Birthday (partial)
4875 /* Event.TYPE_OTHER,
@@ -55,4 +82,80 @@ object EventHandler: DataRowHandler() {
5582 }
5683 }
5784
85+ /* *
86+ * Tries to parse a contact event date string into a [Temporal] object using multiple acceptable formats.
87+ *
88+ * "Full" means "with year" in this context.
89+ *
90+ * @param dateString The contact event date string to parse.
91+ *
92+ * @return The parsed [Temporal] if successful, or `null` if none of the formats match. If format is:
93+ * - `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` or `yyyy-MM-dd'T'HH:mm:ssXXX` ([fullDateTimeFormats]) -> [OffsetDateTime]
94+ * - `yyyy-MM-dd` ([fullDateFormat]) -> [LocalDate]
95+ * - else -> `null`
96+ */
97+ @VisibleForTesting
98+ internal fun parseFullDate (dateString : String ): Temporal ? {
99+ // try to parse as full date-time
100+ for (formatter in fullDateTimeFormats) {
101+ try {
102+ return OffsetDateTime .parse(dateString, formatter)
103+ } catch (_: DateTimeParseException ) {
104+ // ignore: given date is not valid
105+ }
106+ }
107+
108+ // try to parse as full date (without time)
109+ try {
110+ return LocalDate .parse(dateString, fullDateFormat)
111+ } catch (_: DateTimeParseException ) {
112+ // ignore: given date is not valid
113+ }
114+
115+ // could not parse date
116+ return null
117+ }
118+
119+ /* *
120+ * Tries to parse a contact event date string into a [PartialDate] object, covering the cases
121+ * from Android's CommonDateUtils:
122+ *
123+ * - `--MM-dd`
124+ * - `--MM-dd'T'HH:mm:ss.SSS'Z'`
125+ *
126+ * Does some preprocessing to handle the 'Z' suffix and strip nanoseconds
127+ * (both not supported by [PartialDate.parse]).
128+ *
129+ * "Partial" means "without year" in this context.
130+ *
131+ * @param dateString The date string to parse.
132+ * @return The parsed [PartialDate] or `null` if parsing fails.
133+ */
134+ @VisibleForTesting
135+ internal fun parsePartialDate (dateString : String ): PartialDate ? {
136+ return try {
137+ // convert Android partial date/date-time to vCard partial date/date-time so that it can be parsed by ez-vcard
138+
139+ val withoutZ = if (dateString.endsWith(' Z' )) {
140+ // 'Z' is not supported for suffix in PartialDate, replace with actual offset
141+ dateString.removeSuffix(" Z" ) + " +00:00"
142+ } else
143+ dateString
144+
145+ // PartialDate.parse() does not accept fractions of seconds, so strip them if present
146+ val subSecondsRegex = " \\ .\\ d+" .toRegex() // 2025-12-05T010203.456+00:30
147+ // ^^^^ (number of digits may vary)
148+ val subSecondsMatch = subSecondsRegex.find(withoutZ)
149+ val withoutSubSeconds = if (subSecondsMatch != null )
150+ withoutZ.removeRange(subSecondsMatch.range)
151+ else
152+ withoutZ
153+
154+ PartialDate .parse(withoutSubSeconds)
155+ } catch (_: IllegalArgumentException ) {
156+ // An error was thrown by PartialDate.parse
157+ null
158+ }
159+ }
160+
58161}
0 commit comments