Skip to content

Commit 5fb54ec

Browse files
ArnyminerZrfc2822
andauthored
Add support for ZonedDateTime in EventHandler (#136)
* Add support for ZonedDateTime in `EventHandler` * Optimize imports Signed-off-by: Arnau Mora <[email protected]> * Adjust date Signed-off-by: Arnau Mora <[email protected]> * Typo Signed-off-by: Arnau Mora <[email protected]> * Add `parseStartDate` Signed-off-by: Arnau Mora <[email protected]> * Added tests for all datetime formats from AOSP * Remove debug * Update link * Move partial date parsing logic to function * Cleanup * Remove continue Signed-off-by: Arnau Mora <[email protected]> * Add `@VisibleForTesting` Signed-off-by: Arnau Mora <[email protected]> * Replace vars Signed-off-by: Arnau Mora <[email protected]> * Optimization of stripping fractions of seconds * Comments / KDoc * Reorder methods * Reorder tests --------- Signed-off-by: Arnau Mora <[email protected]> Co-authored-by: Ricki Hirner <[email protected]>
1 parent 2cc77a5 commit 5fb54ec

File tree

2 files changed

+186
-16
lines changed

2 files changed

+186
-16
lines changed

lib/src/main/kotlin/at/bitfire/vcard4android/contactrow/EventHandler.kt

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,68 @@ package at.bitfire.vcard4android.contactrow
88

99
import android.content.ContentValues
1010
import android.provider.ContactsContract.CommonDataKinds.Event
11+
import androidx.annotation.VisibleForTesting
1112
import at.bitfire.vcard4android.Contact
1213
import at.bitfire.vcard4android.LabeledProperty
1314
import at.bitfire.vcard4android.Utils.trimToNull
15+
import at.bitfire.vcard4android.contactrow.EventHandler.fullDateFormat
16+
import at.bitfire.vcard4android.contactrow.EventHandler.fullDateTimeFormats
1417
import at.bitfire.vcard4android.property.XAbDate
1518
import ezvcard.property.Anniversary
1619
import ezvcard.property.Birthday
1720
import ezvcard.util.PartialDate
1821
import java.time.LocalDate
22+
import java.time.OffsetDateTime
23+
import java.time.format.DateTimeFormatter
1924
import 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
}

lib/src/test/kotlin/at/bitfire/vcard4android/contactrow/EventHandlerTest.kt

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ import org.junit.Test
1919
import org.junit.runner.RunWith
2020
import org.robolectric.RobolectricTestRunner
2121
import java.time.LocalDate
22+
import java.time.OffsetDateTime
23+
import java.time.ZoneOffset
2224

2325
@RunWith(RobolectricTestRunner::class)
2426
class EventHandlerTest {
2527

28+
// Tested date formats are as provided by Android AOSP Contacts App
29+
// https://android.googlesource.com/platform/packages/apps/Contacts/+/refs/tags/android-13.0.0_r49/src/com/android/contacts/util/CommonDateUtils.java
30+
2631
@Test
2732
fun testStartDate_Empty() {
2833
val contact = Contact()
@@ -35,7 +40,7 @@ class EventHandlerTest {
3540
}
3641

3742
@Test
38-
fun testStartDate_Full() {
43+
fun testStartDate_FULL_DATE_FORMAT() {
3944
val contact = Contact()
4045
EventHandler.handle(ContentValues().apply {
4146
put(Event.START_DATE, "1984-08-20")
@@ -47,14 +52,39 @@ class EventHandlerTest {
4752
}
4853

4954
@Test
50-
fun testStartDate_Partial() {
55+
fun testStartDate_DATE_AND_TIME_FORMAT() {
56+
val contact = Contact()
57+
EventHandler.handle(ContentValues().apply {
58+
put(Event.START_DATE, "1953-10-15T23:10:12.345Z")
59+
}, contact)
60+
assertEquals(
61+
OffsetDateTime.of(1953, 10, 15, 23, 10, 12, 345_000_000, ZoneOffset.UTC),
62+
contact.customDates[0].property.date
63+
)
64+
}
65+
66+
@Test
67+
fun testStartDate_NO_YEAR_DATE_FORMAT() {
5168
val contact = Contact()
5269
EventHandler.handle(ContentValues().apply {
5370
put(Event.START_DATE, "--08-20")
5471
}, contact)
5572
assertEquals(PartialDate.parse("--0820"), contact.customDates[0].property.partialDate)
5673
}
5774

75+
@Test
76+
fun testStartDate_NO_YEAR_DATE_AND_TIME_FORMAT() {
77+
val contact = Contact()
78+
EventHandler.handle(ContentValues().apply {
79+
put(Event.START_DATE, "--08-20T23:10:12.345Z")
80+
}, contact)
81+
// Note that nanoseconds are stripped in PartialDate
82+
assertEquals(
83+
PartialDate.builder().month(8).date(20).hour(23).minute(10).second(12).offset(ZoneOffset.UTC).build(),
84+
contact.customDates[0].property.partialDate
85+
)
86+
}
87+
5888

5989
@Test
6090
fun testType_Anniversary() {
@@ -106,4 +136,41 @@ class EventHandlerTest {
106136
assertFalse(contact.customDates.isEmpty())
107137
}
108138

139+
140+
// test parser methods
141+
142+
@Test
143+
fun test_parseFullDate_ISO_DATE_AND_TIME_FORMAT_DateTime() {
144+
assertEquals(
145+
OffsetDateTime.of(1953, 10, 15, 23, 10, 0, 0, ZoneOffset.UTC),
146+
EventHandler.parseFullDate("1953-10-15T23:10:00Z")
147+
)
148+
}
149+
150+
@Test
151+
fun test_parseFullDate_FULL_DATE_FORMAT_Date() {
152+
assertEquals(
153+
LocalDate.of(1953, 10, 15),
154+
EventHandler.parseFullDate("1953-10-15")
155+
)
156+
}
157+
158+
159+
@Test
160+
fun test_parsePartialDate_NO_YEAR_DATE_FORMAT() {
161+
assertEquals(
162+
PartialDate.builder().month(10).date(15).build(),
163+
EventHandler.parsePartialDate("--10-15")
164+
)
165+
}
166+
167+
@Test
168+
fun test_parsePartialDate_NO_YEAR_DATE_AND_TIME_FORMAT() {
169+
// Partial date does not support nanoseconds, so they will be removed
170+
assertEquals(
171+
PartialDate.builder().month(8).date(20).hour(23).minute(10).second(12).offset(ZoneOffset.UTC).build(),
172+
EventHandler.parsePartialDate("--08-20T23:10:12.345Z")
173+
)
174+
}
175+
109176
}

0 commit comments

Comments
 (0)