diff --git a/lib/src/main/kotlin/at/bitfire/vcard4android/contactrow/EventHandler.kt b/lib/src/main/kotlin/at/bitfire/vcard4android/contactrow/EventHandler.kt index 66a581f7..f9c5d321 100644 --- a/lib/src/main/kotlin/at/bitfire/vcard4android/contactrow/EventHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/vcard4android/contactrow/EventHandler.kt @@ -8,41 +8,127 @@ 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 +import at.bitfire.vcard4android.contactrow.EventHandler.fullDateFormat +import at.bitfire.vcard4android.contactrow.EventHandler.fullDateTimeFormats import at.bitfire.vcard4android.property.XAbDate 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 + } + } + + // 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. + */ + @VisibleForTesting + internal fun parsePartialDate(dateString: String): PartialDate? { + return try { + // convert Android partial date/date-time to vCard partial date/date-time so that it can be parsed by ez-vcard + + val withoutZ = if (dateString.endsWith('Z')) { + // 'Z' is not supported for suffix in PartialDate, replace with actual offset + dateString.removeSuffix("Z") + "+00:00" + } else { + dateString + } + + val regex = "\\.\\d+".toRegex() + if (withoutZ.contains(regex)) { + // partial dates do not accept nanoseconds, so strip them if present + val withoutNanos = withoutZ.replace(regex, "") + PartialDate.parse(withoutNanos) + } else { + PartialDate.parse(withoutZ) + } + } 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, diff --git a/lib/src/test/kotlin/at/bitfire/vcard4android/contactrow/EventHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/vcard4android/contactrow/EventHandlerTest.kt index fba3dfc7..7ef96692 100644 --- a/lib/src/test/kotlin/at/bitfire/vcard4android/contactrow/EventHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/vcard4android/contactrow/EventHandlerTest.kt @@ -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() @@ -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") @@ -47,7 +87,19 @@ 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") @@ -55,6 +107,19 @@ class EventHandlerTest { 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() {