Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package at.bitfire.vcard4android.contactrow

import android.content.ContentValues
import android.provider.ContactsContract.CommonDataKinds.Event
import androidx.annotation.VisibleForTesting
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.LabeledProperty
import at.bitfire.vcard4android.Utils.trimToNull
Expand All @@ -16,33 +17,79 @@ import ezvcard.property.Anniversary
import ezvcard.property.Birthday
import ezvcard.util.PartialDate
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.logging.Level
import java.time.temporal.Temporal

object EventHandler: DataRowHandler() {
object EventHandler : DataRowHandler() {

// source: https://cs.android.com/android/platform/superproject/main/+/main:packages/apps/Contacts/src/com/android/contacts/util/CommonDateUtils.java;drc=61197364367c9e404c7da6900658f1b16c42d0da;l=25
private val acceptableFormats: List<Pair<DateTimeFormatter, (String, DateTimeFormatter) -> Temporal>> = listOf(
// Formats provided by Android's CommonDateUtils
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") to OffsetDateTime::parse,
DateTimeFormatter.ofPattern("yyyy-MM-dd") to LocalDate::parse,
// Additional common formats
DateTimeFormatter.ISO_OFFSET_DATE_TIME to OffsetDateTime::parse, // "yyyy-MM-dd'T'HH:mm:ssXXX"
)

override fun forMimeType() = Event.CONTENT_ITEM_TYPE

/**
* Tries to parse a date string into a [Temporal] object using multiple acceptable formats.
* Returns the parsed [Temporal] if successful, or `null` if none of the formats match.
* @param dateString The date string to parse.
* @return If format is:
* - `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` or `yyyy-MM-dd'T'HH:mm:ssXXX` -> [OffsetDateTime]
* - `yyyy-MM-dd` -> [LocalDate]
* - else -> `null`
*/
@VisibleForTesting
internal fun parseStartDate(dateString: String): Temporal? {
for ((formatter, parse) in acceptableFormats) {
try {
return parse(dateString, formatter)
} catch (_: DateTimeParseException) {
// ignore: given date is not valid
continue
}
}

// could not parse date
return null
}

override fun handle(values: ContentValues, contact: Contact) {
super.handle(values, contact)

val dateStr = values.getAsString(Event.START_DATE) ?: return
var full: LocalDate? = null
var partial: PartialDate? = null
try {
full = LocalDate.parse(dateStr)
} catch(e: DateTimeParseException) {
try {
partial = PartialDate.parse(dateStr)
} catch (e: IllegalArgumentException) {
logger.log(Level.WARNING, "Couldn't parse birthday/anniversary date from database", e)
var dateStr = values.getAsString(Event.START_DATE) ?: return
val full: Temporal? = parseStartDate(dateStr)
val partial: PartialDate? = if (full == null) try {
if (dateStr.endsWith('Z')) {
// 'Z' is not supported for suffix in PartialDate, replace with actual offset
dateStr = dateStr.removeSuffix("Z") + "+00:00"
}

val regex = "\\.\\d{3}".toRegex()
if (dateStr.contains(regex)) {
// partial dates do not accept nanoseconds, so strip them if present
dateStr = dateStr.replace(regex, "")
PartialDate.parse(dateStr)
} else {
PartialDate.parse(dateStr)
}
} catch (_: IllegalArgumentException) {
null
} else {
null
}

if (full != null || partial != null)
when (values.getAsInteger(Event.TYPE)) {
Event.TYPE_ANNIVERSARY ->
contact.anniversary = if (full != null) Anniversary(full) else Anniversary(partial)
contact.anniversary =
if (full != null) Anniversary(full) else Anniversary(partial)

Event.TYPE_BIRTHDAY ->
contact.birthDay = if (full != null) Birthday(full) else Birthday(partial)
/* Event.TYPE_OTHER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,31 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset

@RunWith(RobolectricTestRunner::class)
class EventHandlerTest {

// Tested date formats are as provided by Android AOSP Contacts App
// https://android.googlesource.com/platform/packages/apps/Contacts/+/refs/tags/android-13.0.0_r49/src/com/android/contacts/util/CommonDateUtils.java

@Test
fun test_parseStartDate_ISO_DATE_AND_TIME_FORMAT_DateTime() {
assertEquals(
OffsetDateTime.of(1953, 10, 15, 23, 10, 0, 0, ZoneOffset.UTC),
EventHandler.parseStartDate("1953-10-15T23:10:00Z")
)
}

@Test
fun test_parseStartDate_FULL_DATE_FORMAT_Date() {
assertEquals(
LocalDate.of(1953, 10, 15),
EventHandler.parseStartDate("1953-10-15")
)
}

@Test
fun testStartDate_Empty() {
val contact = Contact()
Expand All @@ -35,7 +56,7 @@ class EventHandlerTest {
}

@Test
fun testStartDate_Full() {
fun testStartDate_FULL_DATE_FORMAT() {
val contact = Contact()
EventHandler.handle(ContentValues().apply {
put(Event.START_DATE, "1984-08-20")
Expand All @@ -47,14 +68,39 @@ class EventHandlerTest {
}

@Test
fun testStartDate_Partial() {
fun testStartDate_DATE_AND_TIME_FORMAT() {
val contact = Contact()
EventHandler.handle(ContentValues().apply {
put(Event.START_DATE, "1953-10-15T23:10:12.345Z")
}, contact)
assertEquals(
OffsetDateTime.of(1953, 10, 15, 23, 10, 12, 345_000_000, ZoneOffset.UTC),
contact.customDates[0].property.date
)
}

@Test
fun testStartDate_NO_YEAR_DATE_FORMAT() {
val contact = Contact()
EventHandler.handle(ContentValues().apply {
put(Event.START_DATE, "--08-20")
}, contact)
assertEquals(PartialDate.parse("--0820"), contact.customDates[0].property.partialDate)
}

@Test
fun testStartDate_NO_YEAR_DATE_AND_TIME_FORMAT() {
val contact = Contact()
EventHandler.handle(ContentValues().apply {
put(Event.START_DATE, "--08-20T23:10:12.345Z")
}, contact)
// Note that nanoseconds are stripped in PartialDate
assertEquals(
PartialDate.builder().month(8).date(20).hour(23).minute(10).second(12).offset(ZoneOffset.UTC).build(),
contact.customDates[0].property.partialDate
)
}


@Test
fun testType_Anniversary() {
Expand Down