Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,115 @@ 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() {

// CommonDateUtils: https://android.googlesource.com/platform/packages/apps/Contacts/+/c326c157541978c180be4e3432327eceb1e66637/src/com/android/contacts/util/CommonDateUtils.java#25

/**
* Date formats for full date with time. Converts to [OffsetDateTime].
*/
private val fullDateTimeFormats = listOf(
// Provided by Android's CommonDateUtils
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"),
// "yyyy-MM-dd'T'HH:mm:ssXXX"
DateTimeFormatter.ISO_OFFSET_DATE_TIME,
)

/**
* Date format for full date without time. Converts to [LocalDate].
*/
private val fullDateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd")

object EventHandler: DataRowHandler() {

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` ([fullDateTimeFormats]) -> [OffsetDateTime]
* - `yyyy-MM-dd` ([fullDateFormat]) -> [LocalDate]
* - else -> `null`
*/
@VisibleForTesting
internal fun parseFullDate(dateString: String): Temporal? {
for (formatter in fullDateTimeFormats) {
try {
return OffsetDateTime.parse(dateString, formatter)
} catch (_: DateTimeParseException) {
// ignore: given date is not valid
continue
}
}

// try parsing as full date only (no time)
try {
return LocalDate.parse(dateString, fullDateFormat)
} catch (_: DateTimeParseException) {
// ignore: given date is not valid
}

// could not parse date
return null
}

/**
* Tries to parse a date string into a [PartialDate] object.
* Returns the parsed [PartialDate] if successful, or `null` if parsing fails.
*
* Does some preprocessing to handle 'Z' suffix and strip nanoseconds, both not supported by
* [PartialDate.parse].
*
* @param dateString The date string to parse.
* @return The parsed [PartialDate] or `null` if parsing fails.
*/
internal fun parsePartialDate(dateString: String): PartialDate? {
var dateString = dateString // to allow modification
return try {
// convert Android partial date/date-time to vCard partial date/date-time so that it can be parsed by ez-vcard

if (dateString.endsWith('Z')) {
// 'Z' is not supported for suffix in PartialDate, replace with actual offset
dateString = dateString.removeSuffix("Z") + "+00:00"
}

val regex = "\\.\\d{3}".toRegex()
if (dateString.contains(regex)) {
// partial dates do not accept nanoseconds, so strip them if present
dateString = dateString.replace(regex, "")
PartialDate.parse(dateString)
} else {
PartialDate.parse(dateString)
}
} catch (_: IllegalArgumentException) {
// An error was thrown by PartialDate.parse
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)
}
val full: Temporal? = parseFullDate(dateStr)
val partial: PartialDate? = if (full == null) {
parsePartialDate(dateStr)
} 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,50 @@ 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_parseFullDate_ISO_DATE_AND_TIME_FORMAT_DateTime() {
assertEquals(
OffsetDateTime.of(1953, 10, 15, 23, 10, 0, 0, ZoneOffset.UTC),
EventHandler.parseFullDate("1953-10-15T23:10:00Z")
)
}

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


@Test
fun test_parsePartialDate_NO_YEAR_DATE_FORMAT() {
assertEquals(
PartialDate.builder().month(10).date(15).build(),
EventHandler.parsePartialDate("--10-15")
)
}

@Test
fun test_parsePartialDate_NO_YEAR_DATE_AND_TIME_FORMAT() {
// Partial date does not support nanoseconds, so they will be removed
assertEquals(
PartialDate.builder().month(8).date(20).hour(23).minute(10).second(12).offset(ZoneOffset.UTC).build(),
EventHandler.parsePartialDate("--08-20T23:10:12.345Z")
)
}


@Test
fun testStartDate_Empty() {
val contact = Contact()
Expand All @@ -35,7 +75,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 +87,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