diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt new file mode 100644 index 0000000..c07f44e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.provider.CalendarContract +import android.util.Log +import de.jeanlucmakiola.calendula.domain.Attendee +import de.jeanlucmakiola.calendula.domain.AttendeeStatus +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance + +private const val TAG = "EventDetailMapper" + +internal fun ColumnReader.toEventDetailCore(attendees: List): EventDetail? { + val begin = getLong(EventDetailProjection.IDX_DTSTART) + val end = getLong(EventDetailProjection.IDX_DTEND) + + if (begin < 0L) { + Log.w(TAG, "Dropping event with negative dtstart=$begin") + return null + } + if (end < begin) { + Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin") + return null + } + + val rawTitle = getString(EventDetailProjection.IDX_TITLE) + val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + + val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { + getInt(EventDetailProjection.IDX_CALENDAR_COLOR) + } else { + getInt(EventDetailProjection.IDX_EVENT_COLOR) + } + + val eventId = getLong(EventDetailProjection.IDX_EVENT_ID) + val instance = EventInstance( + instanceId = eventId, + eventId = eventId, + calendarId = getLong(EventDetailProjection.IDX_CALENDAR_ID), + title = title, + start = begin.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0, + color = color, + location = getString(EventDetailProjection.IDX_LOCATION), + ) + + return EventDetail( + instance = instance, + description = getString(EventDetailProjection.IDX_DESCRIPTION), + organizer = getString(EventDetailProjection.IDX_ORGANIZER), + attendees = attendees, + rrule = getString(EventDetailProjection.IDX_RRULE), + ) +} + +internal fun ColumnReader.toAttendee(): Attendee = Attendee( + name = getString(AttendeeProjection.IDX_NAME).orEmpty(), + email = getString(AttendeeProjection.IDX_EMAIL), + status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)), +) + +internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) { + CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED -> AttendeeStatus.Accepted + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> AttendeeStatus.Declined + CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE -> AttendeeStatus.Tentative + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction + else -> AttendeeStatus.Unknown +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt new file mode 100644 index 0000000..9100767 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.AttendeeStatus +import org.junit.jupiter.api.Test + +class EventDetailMapperTest { + + private fun detailReader( + eventId: Long = 1L, + title: String? = "Meet", + description: String? = "Body", + organizer: String? = "x@y", + rrule: String? = null, + eventColor: Any? = null, + calendarColor: Int = 0xFFAABBCC.toInt(), + dtstart: Long = 1_000_000_000L, + dtend: Long = 1_000_003_600L, + allDay: Int = 0, + location: String? = "Berlin", + calendarId: Long = 7L, + ): MapColumnReader = MapColumnReader( + EventDetailProjection.IDX_EVENT_ID to eventId, + EventDetailProjection.IDX_TITLE to title, + EventDetailProjection.IDX_DESCRIPTION to description, + EventDetailProjection.IDX_ORGANIZER to organizer, + EventDetailProjection.IDX_RRULE to rrule, + EventDetailProjection.IDX_EVENT_COLOR to eventColor, + EventDetailProjection.IDX_CALENDAR_COLOR to calendarColor, + EventDetailProjection.IDX_DTSTART to dtstart, + EventDetailProjection.IDX_DTEND to dtend, + EventDetailProjection.IDX_ALL_DAY to allDay, + EventDetailProjection.IDX_LOCATION to location, + EventDetailProjection.IDX_CALENDAR_ID to calendarId, + ) + + private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader = + MapColumnReader( + AttendeeProjection.IDX_NAME to name, + AttendeeProjection.IDX_EMAIL to email, + AttendeeProjection.IDX_STATUS to status, + ) + + @Test + fun `happy path detail maps all fields and embeds matching EventInstance`() { + val detail = detailReader().toEventDetailCore(attendees = emptyList()) + assertThat(detail).isNotNull() + assertThat(detail!!.description).isEqualTo("Body") + assertThat(detail.organizer).isEqualTo("x@y") + assertThat(detail.instance.title).isEqualTo("Meet") + assertThat(detail.instance.location).isEqualTo("Berlin") + assertThat(detail.attendees).isEmpty() + } + + @Test + fun `event color falls back to calendar color when null`() { + val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt()) + .toEventDetailCore(attendees = emptyList()) + assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt()) + } + + @Test + fun `dtend before dtstart drops detail`() { + val detail = detailReader(dtstart = 2000L, dtend = 1000L) + .toEventDetailCore(attendees = emptyList()) + assertThat(detail).isNull() + } + + @Test + fun `rrule passes through when present`() { + val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO") + .toEventDetailCore(attendees = emptyList()) + assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO") + } + + // Raw CalendarContract.Attendees status integer constants from the Android + // source (kept inline so the test doesn't depend on the mockable.jar's + // possibly-stubbed constants): + // ACCEPTED=1, DECLINED=2, INVITED=3, TENTATIVE=4, NONE=0 + @Test + fun `attendee status maps known integer codes`() { + assertThat(attendeeReader("A", "a@x", 1).toAttendee().status) + .isEqualTo(AttendeeStatus.Accepted) + assertThat(attendeeReader("B", "b@x", 2).toAttendee().status) + .isEqualTo(AttendeeStatus.Declined) + assertThat(attendeeReader("C", "c@x", 4).toAttendee().status) + .isEqualTo(AttendeeStatus.Tentative) + assertThat(attendeeReader("D", "d@x", 3).toAttendee().status) + .isEqualTo(AttendeeStatus.NeedsAction) + assertThat(attendeeReader("E", "e@x", 0).toAttendee().status) + .isEqualTo(AttendeeStatus.Unknown) + assertThat(attendeeReader("F", "f@x", 99).toAttendee().status) + .isEqualTo(AttendeeStatus.Unknown) + } + + @Test + fun `attendee with null name maps to empty string`() { + val a = attendeeReader(null, "alice@x", 1).toAttendee() + assertThat(a.name).isEqualTo("") + } + + @Test + fun `attendee email passes through nullably`() { + assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull() + assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y") + } +}