diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt index d2725982..4d206b6c 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt @@ -8,48 +8,33 @@ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris -import android.content.ContentValues -import android.database.DatabaseUtils import android.os.Build import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.AUTHORITY -import android.provider.CalendarContract.Attendees import android.provider.CalendarContract.Events -import android.provider.CalendarContract.ExtendedProperties -import android.provider.CalendarContract.Reminders import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import at.bitfire.ical4android.impl.TestCalendar -import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.closeCompat import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.storage.calendar.AndroidCalendar -import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider import at.bitfire.synctools.test.InitCalendarProviderRule import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.ParameterList -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.Recur -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Cn -import net.fortuna.ical4j.model.parameter.CuType -import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.parameter.Language -import net.fortuna.ical4j.model.parameter.PartStat -import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.parameter.Role -import net.fortuna.ical4j.model.parameter.Rsvp import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.* -import net.fortuna.ical4j.util.TimeZones +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -60,29 +45,16 @@ import org.junit.Test import org.junit.rules.TestRule import java.net.URI import java.time.Duration -import java.time.Period -import java.util.UUID -import java.util.logging.Logger -import kotlin.collections.plusAssign class AndroidEventTest { @get:Rule val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize() - private val logger = Logger.getLogger(javaClass.name) + private val testAccount = Account(javaClass.name, ACCOUNT_TYPE_LOCAL) - private val testAccount = Account("ical4android@example.com", ACCOUNT_TYPE_LOCAL) - - private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! - private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! - private val tzShanghai = tzRegistry.getTimeZone("Asia/Shanghai")!! - - private val tzIdDefault = java.util.TimeZone.getDefault().id - private val tzDefault = tzRegistry.getTimeZone(tzIdDefault) - - lateinit var client: ContentProviderClient private lateinit var calendar: AndroidCalendar + lateinit var client: ContentProviderClient @Before fun prepare() { @@ -118,2236 +90,6 @@ class AndroidEventTest { } - /** - * buildEvent() BASIC TEST MATRIX: - * - * all-day event | hasDtEnd | hasDuration | recurring event | notes - * 0 0 0 0 dtEnd = dtStart - * 0 0 0 1 duration = 0s, rRule/rDate set - * 0 0 1 0 dtEnd calulcated from duration - * 0 0 1 1 - * 0 1 0 0 - * 0 1 0 1 dtEnd calulcated from duration - * 0 1 1 0 duration ignored - * 0 1 1 1 duration ignored - * 1 0 0 0 duration = 1d - * 1 0 0 1 duration = 1d - * 1 0 1 0 dtEnd calculated from duration - * 1 0 1 1 - * 1 1 0 0 - * 1 1 0 1 duration calculated from dtEnd; ignore times in rDate - * 1 1 1 0 duration ignored - * 1 1 1 1 duration ignored - * - * buildEvent() EXTRA TESTS: - * - * - floating times - * - floating times in rdate/exdate - * - UTC times - */ - - private fun buildEvent(automaticDates: Boolean, eventBuilder: Event.() -> Unit): ContentValues { - val event = Event().apply { - if (automaticDates) - dtStart = DtStart(DateTime()) - eventBuilder() - } - // write event with random file name/sync_id - val uri = AndroidEvent(calendar, event, syncId = UUID.randomUUID().toString()).add() - client.query(uri, null, null, null, null)!!.use { cursor -> - cursor.moveToNext() - val values = ContentValues(cursor.columnCount) - DatabaseUtils.cursorRowToContentValues(cursor, values) - return values - } - } - - private fun firstExtendedProperty(values: ContentValues): String? { - val id = values.getAsInteger(Events._ID) - client.query(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), arrayOf(ExtendedProperties.VALUE), - "${ExtendedProperties.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { - if (it.moveToNext()) - return it.getString(0) - } - return null - } - - private fun firstUnknownProperty(values: ContentValues): Property? { - val rawValue = firstExtendedProperty(values) - return if (rawValue != null) - UnknownProperty.fromJsonString(rawValue) - else - null - } - - @Test - fun testBuildEvent_NonAllDay_NoDtEnd_NoDuration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTEND)) - assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) - } - - @Test - fun testBuildEvent_NonAllDay_NoDtEnd_NoDuration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - rRules += RRule("FREQ=DAILY;COUNT=5") - rRules += RRule("FREQ=WEEKLY;COUNT=10") - rDates += RDate(DateList("20210601T123000", Value.DATE_TIME, tzVienna)) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("P0D", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("FREQ=DAILY;COUNT=5\nFREQ=WEEKLY;COUNT=10", values.getAsString(Events.RRULE)) - assertEquals("${tzVienna.id};20200601T123000,20210601T123000", values.getAsString(Events.RDATE)) - } - - @Test - fun testBuildEvent_NonAllDay_NoDtEnd_Duration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - duration = Duration(null, "PT1H30M") - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591007400000L + 90*60000, values.getAsLong(Events.DTEND)) - assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_NonAllDayUtc_NoDtEnd_Duration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart(DateTime("20200601T103000Z").apply { isUtc = true }) - duration = Duration(null, "PT1H30M") - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(TimeZones.getUtcTimeZone().id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591007400000L + 90*60000, values.getAsLong(Events.DTEND)) - assertEquals(TimeZones.getUtcTimeZone().id, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_NonAllDay_NoDtEnd_Duration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - duration = Duration(null, "PT1H30M") - rDates += RDate(DateList("20200602T113000", Value.DATE_TIME, tzVienna)) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("PT1H30M", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("${tzVienna.id};20200601T123000,20200602T113000", values.get(Events.RDATE)) - } - - @Test - fun testBuildEvent_NonAllDay_DtEnd_NoDuration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - dtEnd = DtEnd("20200602T143000", tzShanghai) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591079400000L, values.getAsLong(Events.DTEND)) - assertEquals(tzShanghai.id, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_NonAllDay_DtEnd_NoDuration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzShanghai) - dtEnd = DtEnd("20200601T123000", tzVienna) - rDates += RDate(DateList("20200701T123000,20200702T123000", Value.DATE_TIME, tzVienna)) - rDates += RDate(DateList("20200801T123000,20200802T123000", Value.DATE_TIME, tzShanghai)) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590985800000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzShanghai.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("PT6H", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("${tzShanghai.id};20200601T123000,20200701T183000,20200702T183000,20200801T123000,20200802T123000", values.getAsString(Events.RDATE)) - } - - @Test - fun testBuildEvent_NonAllDay_DtEnd_NoDuration_Recurring_InfiniteRruleAndRdate() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzShanghai) - dtEnd = DtEnd("20200601T123000", tzVienna) - rRules += RRule( - Recur("FREQ=DAILY;INTERVAL=2") - ) - rDates += RDate(DateList("20200701T123000,20200702T123000", Value.DATE_TIME, tzVienna)) - } - - assertNull(values.get(Events.RDATE)) - assertEquals("FREQ=DAILY;INTERVAL=2", values.get(Events.RRULE)) - } - - @Test - fun testBuildEvent_NonAllDay_DtEnd_Duration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - dtEnd = DtEnd("20200601T143000", tzVienna) - duration = Duration(null, "PT1S") - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591014600000L, values.getAsLong(Events.DTEND)) - assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_NonAllDay_DtEnd_Duration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzVienna) - dtEnd = DtEnd("20200601T143000", tzVienna) - duration = Duration(null, "PT10S") - rRules += RRule("FREQ=MONTHLY;COUNT=1") - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("PT2H", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("FREQ=MONTHLY;COUNT=1", values.get(Events.RRULE)) - } - - @Test - fun testBuildEvent_AllDay_NoDtEnd_NoDuration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591056000000L, values.getAsLong(Events.DTEND)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_AllDay_NoDtEnd_NoDuration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - rRules += RRule("FREQ=MONTHLY;COUNT=3") - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("P1D", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("FREQ=MONTHLY;COUNT=3", values.get(Events.RRULE)) - } - - @Test - fun testBuildEvent_AllDay_NoDtEnd_Duration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - duration = Duration(null, "P2W1D") - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1592265600000L, values.getAsLong(Events.DTEND)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_AllDay_NoDtEnd_Duration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - duration = Duration(null, "P2D") - rRules += RRule("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU") - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("P2D", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU", values.get(Events.RRULE)) - } - - @Test - fun testBuildEvent_AllDay_DtEnd_NoDuration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - dtEnd = DtEnd(Date("20200701")) - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1593561600000L, values.getAsLong(Events.DTEND)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_AllDay_DtEnd_NoDuration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - dtEnd = DtEnd(Date("20200701")) - rDates += RDate(DateList("20210601", Value.DATE)) - rDates += RDate(DateList("20220601T120030", Value.DATE_TIME)) - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("P30D", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("20200601T000000Z,20210601T000000Z,20220601T000000Z", values.get(Events.RDATE)) - } - - @Test - fun testBuildEvent_AllDay_DtEnd_Duration_NonRecurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - dtEnd = DtEnd(Date("20200701")) - duration = Duration(null, "PT5M") - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1593561600000L, values.getAsLong(Events.DTEND)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) - assertNull(values.get(Events.DURATION)) - } - - @Test - fun testBuildEvent_AllDay_DtEnd_Duration_Recurring() { - val values = buildEvent(false) { - dtStart = DtStart(Date("20200601")) - dtEnd = DtEnd(Date("20200701")) - duration = Duration(null, "PT1M") - rRules += RRule("FREQ=DAILY;COUNT=1") - } - assertEquals(1, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("P30D", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.DTEND)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - assertEquals("FREQ=DAILY;COUNT=1", values.get(Events.RRULE)) - } - - @Test - fun testBuildEvent_FloatingTimes() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000") - dtEnd = DtEnd("20200601T123001") - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(DateTime("20200601T123000", tzDefault).time, values.getAsLong(Events.DTSTART)) - assertEquals(tzIdDefault, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(DateTime("20200601T123001", tzDefault).time, values.getAsLong(Events.DTEND)) - assertEquals(tzIdDefault, values.get(Events.EVENT_END_TIMEZONE)) - } - - @Test - fun testBuildEvent_FloatingTimesInRecurrenceDates() { - val values = buildEvent(false) { - dtStart = DtStart("20200601T123000", tzShanghai) - duration = Duration(null, "PT5M30S") - rDates += RDate(DateList("20200602T113000", Value.DATE_TIME)) - exDates += ExDate(DateList("20200602T113000", Value.DATE_TIME)) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1590985800000L, values.getAsLong(Events.DTSTART)) - assertEquals(tzShanghai.id, values.get(Events.EVENT_TIMEZONE)) - - assertEquals("PT5M30S", values.getAsString(Events.DURATION)) - assertNull(values.get(Events.EVENT_END_TIMEZONE)) - - val rewritten = DateTime("20200602T113000") - rewritten.timeZone = tzShanghai - assertEquals("${tzShanghai.id};20200601T123000,$rewritten", values.get(Events.RDATE)) - assertEquals("$tzIdDefault;20200602T113000", values.get(Events.EXDATE)) - } - - @Test - fun testBuildEvent_UTC() { - val values = buildEvent(false) { - dtStart = DtStart(DateTime(1591014600000L), true) - dtEnd = DtEnd(DateTime(1591021801000L), true) - } - assertEquals(0, values.getAsInteger(Events.ALL_DAY)) - - assertEquals(1591014600000L, values.getAsLong(Events.DTSTART)) - assertEquals(TimeZones.UTC_ID, values.get(Events.EVENT_TIMEZONE)) - - assertEquals(1591021801000L, values.getAsLong(Events.DTEND)) - assertEquals(TimeZones.UTC_ID, values.get(Events.EVENT_END_TIMEZONE)) - } - - @Test - fun testBuildEvent_Summary() { - buildEvent(true) { - summary = "Sample Summary" - }.let { result -> - assertEquals("Sample Summary", result.get(Events.TITLE)) - } - } - - @Test - fun testBuildEvent_Location() { - buildEvent(true) { - location = "Sample Location" - }.let { result -> - assertEquals("Sample Location", result.get(Events.EVENT_LOCATION)) - } - } - - @Test - fun testBuildEvent_Url() { - buildEvent(true) { - url = URI("https://example.com") - }.let { result -> - assertEquals("https://example.com", firstExtendedProperty(result)) - } - } - - @Test - fun testBuildEvent_Description() { - buildEvent(true) { - description = "Sample Description" - }.let { result -> - assertEquals("Sample Description", result.get(Events.DESCRIPTION)) - } - } - - @Test - fun testBuildEvent_Color_WhenNotAvailable() { - buildEvent(true) { - color = Css3Color.darkseagreen - }.let { result -> - assertNull(result.get(Events.CALENDAR_COLOR_KEY)) - } - } - - @Test - fun testBuildEvent_Color_WhenAvailable() { - val provider = AndroidCalendarProvider(testAccount, client) - provider.provideCss3ColorIndices() - buildEvent(true) { - color = Css3Color.darkseagreen - }.let { result -> - assertEquals(Css3Color.darkseagreen.name, result.get(Events.EVENT_COLOR_KEY)) - } - } - - @Test - fun testBuildEvent_Organizer_NotGroupScheduled() { - buildEvent(true) { - organizer = Organizer("mailto:organizer@example.com") - }.let { result -> - assertNull(result.get(Events.ORGANIZER)) - } - } - - @Test - fun testBuildEvent_Organizer_MailTo() { - buildEvent(true) { - organizer = Organizer("mailto:organizer@example.com") - attendees += Attendee("mailto:attendee@example.com") - }.let { result -> - assertEquals("organizer@example.com", result.get(Events.ORGANIZER)) - } - } - - @Test - fun testBuildEvent_Organizer_EmailParameter() { - buildEvent(true) { - organizer = Organizer("local-id:user").apply { - parameters.add(Email("organizer@example.com")) - } - attendees += Attendee("mailto:attendee@example.com") - }.let { result -> - assertEquals("organizer@example.com", result.get(Events.ORGANIZER)) - } - } - - @Test - fun testBuildEvent_Organizer_NotEmail() { - buildEvent(true) { - organizer = Organizer("local-id:user") - attendees += Attendee("mailto:attendee@example.com") - }.let { result -> - assertNull(result.get(Events.ORGANIZER)) - } - } - - @Test - fun testBuildEvent_Status_Confirmed() { - buildEvent(true) { - status = Status.VEVENT_CONFIRMED - }.let { result -> - assertEquals(Events.STATUS_CONFIRMED, result.getAsInteger(Events.STATUS)) - } - } - - @Test - fun testBuildEvent_Status_Cancelled() { - buildEvent(true) { - status = Status.VEVENT_CANCELLED - }.let { result -> - assertEquals(Events.STATUS_CANCELED, result.getAsInteger(Events.STATUS)) - } - } - - @Test - fun testBuildEvent_Status_Tentative() { - buildEvent(true) { - status = Status.VEVENT_TENTATIVE - }.let { result -> - assertEquals(Events.STATUS_TENTATIVE, result.getAsInteger(Events.STATUS)) - } - } - - @Test - fun testBuildEvent_Status_Invalid() { - buildEvent(true) { - status = Status.VTODO_IN_PROCESS - }.let { result -> - assertEquals(Events.STATUS_TENTATIVE, result.getAsInteger(Events.STATUS)) - } - } - - @Test - fun testBuildEvent_Status_None() { - buildEvent(true) { - }.let { result -> - assertNull(result.get(Events.STATUS)) - } - } - - @Test - fun testBuildEvent_Opaque_True() { - buildEvent(true) { - opaque = true - }.let { result -> - assertEquals(Events.AVAILABILITY_BUSY, result.getAsInteger(Events.AVAILABILITY)) - } - } - - @Test - fun testBuildEvent_Opaque_False() { - buildEvent(true) { - opaque = false - }.let { result -> - assertEquals(Events.AVAILABILITY_FREE, result.getAsInteger(Events.AVAILABILITY)) - } - } - - @Test - fun testBuildEvent_Classification_Public() { - buildEvent(true) { - classification = Clazz.PUBLIC - }.let { result -> - assertEquals(Events.ACCESS_PUBLIC, result.getAsInteger(Events.ACCESS_LEVEL)) - assertNull(firstUnknownProperty(result)) - } - } - - @Test - fun testBuildEvent_Classification_Private() { - buildEvent(true) { - classification = Clazz.PRIVATE - }.let { result -> - assertEquals(Events.ACCESS_PRIVATE, result.getAsInteger(Events.ACCESS_LEVEL)) - assertNull(firstUnknownProperty(result)) - } - } - - @Test - fun testBuildEvent_Classification_Confidential() { - buildEvent(true) { - classification = Clazz.CONFIDENTIAL - }.let { result -> - assertEquals(Events.ACCESS_CONFIDENTIAL, result.getAsInteger(Events.ACCESS_LEVEL)) - assertEquals(Clazz.CONFIDENTIAL, firstUnknownProperty(result)) - } - } - - @Test - fun testBuildEvent_Classification_Custom() { - buildEvent(true) { - classification = Clazz("TOP-SECRET") - }.let { result -> - assertEquals(Events.ACCESS_PRIVATE, result.getAsInteger(Events.ACCESS_LEVEL)) - assertEquals(Clazz("TOP-SECRET"), firstUnknownProperty(result)) - } - } - - @Test - fun testBuildEvent_Classification_None() { - buildEvent(true) { - }.let { result -> - assertEquals(Events.ACCESS_DEFAULT, result.getAsInteger(Events.ACCESS_LEVEL)) - assertNull(firstUnknownProperty(result)) - } - } - - @Test - fun testBuildEvent_UID2445() { - buildEvent(true) { - uid = "event1@example.com" - }.let { result -> - assertEquals("event1@example.com", result.getAsString(Events.UID_2445)) - } - } - - - private fun firstReminder(row: ContentValues): ContentValues? { - val id = row.getAsInteger(Events._ID) - client.query(Reminders.CONTENT_URI.asSyncAdapter(testAccount), null, - "${Reminders.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> - if (cursor.moveToNext()) { - val subRow = ContentValues(cursor.count) - DatabaseUtils.cursorRowToContentValues(cursor, subRow) - return subRow - } - } - return null - } - - @Test - fun testBuildReminder_Trigger_None() { - buildEvent(true) { - alarms += VAlarm() - }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(Reminders.METHOD_DEFAULT, reminder.getAsInteger(Reminders.METHOD)) - assertEquals(Reminders.MINUTES_DEFAULT, reminder.getAsInteger(Reminders.MINUTES)) - } - } - } - - @Test - fun testBuildReminder_Trigger_Type_Audio() { - buildEvent(true) { - alarms += VAlarm(Duration.ofMinutes(-10)).apply { - properties += Action.AUDIO - } - }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(Reminders.METHOD_ALERT, reminder.getAsInteger(Reminders.METHOD)) - assertEquals(10, reminder.getAsInteger(Reminders.MINUTES)) - } - } - } - - @Test - fun testBuildReminder_Trigger_Type_Display() { - buildEvent(true) { - alarms += VAlarm(Duration.ofMinutes(-10)).apply { - properties += Action.DISPLAY - } - }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(Reminders.METHOD_ALERT, reminder.getAsInteger(Reminders.METHOD)) - assertEquals(10, reminder.getAsInteger(Reminders.MINUTES)) - } - } - } - - @Test - fun testBuildReminder_Trigger_Type_Email() { - buildEvent(true) { - alarms += VAlarm(Duration.ofSeconds(-120)).apply { - properties += Action.EMAIL - } - }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(Reminders.METHOD_EMAIL, reminder.getAsInteger(Reminders.METHOD)) - assertEquals(2, reminder.getAsInteger(Reminders.MINUTES)) - } - } - } - - @Test - fun testBuildReminder_Trigger_Type_Custom() { - buildEvent(true) { - alarms += VAlarm(Duration.ofSeconds(-120)).apply { - properties += Action("X-CUSTOM") - } - }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(Reminders.METHOD_DEFAULT, reminder.getAsInteger(Reminders.METHOD)) - assertEquals(2, reminder.getAsInteger(Reminders.MINUTES)) - } - } - } - - @Test - fun testBuildReminder_Trigger_RelStart_Duration() { - buildEvent(true) { - alarms += VAlarm(Period.ofDays(-1)) - }.let { result -> - assertEquals(1440, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_RelStart_Duration_LessThanOneMinute() { - buildEvent(true) { - alarms += VAlarm(Duration.ofSeconds(-10)) - }.let { result -> - assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_RelStart_Duration_Positive() { - // positive duration -> reminder is AFTER reference time -> negative minutes field - buildEvent(true) { - alarms += VAlarm(Duration.ofMinutes(10)) - }.let { result -> - assertEquals(-10, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_RelEnd_Duration() { - buildEvent(false) { - dtStart = DtStart(DateTime("20200621T120000", tzVienna)) - dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) - alarms += VAlarm(Period.ofDays(-1)).apply { - trigger.parameters.add(Related.END) - } - }.let { result -> - assertEquals(1320, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_RelEnd_Duration_LessThanOneMinute() { - buildEvent(false) { - dtStart = DtStart(DateTime("20200621T120000", tzVienna)) - dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) - alarms += VAlarm(Duration.ofSeconds(-7240)).apply { - trigger.parameters.add(Related.END) - } - }.let { result -> - assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_RelEnd_Duration_Positive() { - // positive duration -> reminder is AFTER reference time -> negative minutes field - buildEvent(false) { - dtStart = DtStart(DateTime("20200621T120000", tzVienna)) - dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) - alarms += VAlarm(Duration.ofMinutes(10)).apply { - trigger.parameters.add(Related.END) - } - }.let { result -> - assertEquals(-130, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_Absolute() { - buildEvent(false) { - dtStart = DtStart(DateTime("20200621T120000", tzVienna)) - alarms += VAlarm(DateTime("20200621T110000", tzVienna)) - }.let { result -> - assertEquals(60, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - @Test - fun testBuildReminder_Trigger_Absolute_OtherTimeZone() { - buildEvent(false) { - dtStart = DtStart(DateTime("20200621T120000", tzVienna)) - alarms += VAlarm(DateTime("20200621T110000", tzShanghai)) - }.let { result -> - assertEquals(420, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) - } - } - - - private fun firstAttendee(row: ContentValues): ContentValues? { - val id = row.getAsInteger(Events._ID) - client.query(Attendees.CONTENT_URI.asSyncAdapter(testAccount), null, - "${Attendees.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> - if (cursor.moveToNext()) { - val subRow = ContentValues(cursor.count) - DatabaseUtils.cursorRowToContentValues(cursor, subRow) - return subRow - } - } - return null - } - - @Test - fun testBuildAttendee_MailTo() { - buildEvent(true) { - attendees += Attendee("mailto:attendee1@example.com") - }.let { result -> - assertEquals("attendee1@example.com", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_EMAIL)) - } - } - - @Test - fun testBuildAttendee_OtherUri() { - buildEvent(true) { - attendees += Attendee("https://example.com/principals/attendee") - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals("https", attendee.getAsString(Attendees.ATTENDEE_ID_NAMESPACE)) - assertEquals("//example.com/principals/attendee", attendee.getAsString(Attendees.ATTENDEE_IDENTITY)) - } - } - } - - @Test - fun testBuildAttendee_CustomUri_EmailParam() { - buildEvent(true) { - attendees += Attendee("sample:uri").apply { - parameters.add(Email("attendee1@example.com")) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals("sample", attendee.getAsString(Attendees.ATTENDEE_ID_NAMESPACE)) - assertEquals("uri", attendee.getAsString(Attendees.ATTENDEE_IDENTITY)) - assertEquals("attendee1@example.com", attendee.getAsString(Attendees.ATTENDEE_EMAIL)) - } - } - } - - @Test - fun testBuildAttendee_Cn() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(Cn("Sample Attendee")) - } - }.let { result -> - assertEquals("Sample Attendee", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_NAME)) - } - } - - @Test - fun testBuildAttendee_Individual() { - for (cuType in arrayOf(CuType.INDIVIDUAL, null)) { - // REQ-PARTICIPANT (default, includes unknown values) - for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - if (cuType != null) - parameters.add(cuType) - if (role != null) - parameters.add(role) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - // OPT-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - if (cuType != null) - parameters.add(cuType) - parameters.add(Role.OPT_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - // NON-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - if (cuType != null) - parameters.add(cuType) - parameters.add(Role.NON_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - } - - @Test - fun testBuildAttendee_Unknown() { - // REQ-PARTICIPANT (default, includes unknown values) - for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.UNKNOWN) - if (role != null) - parameters.add(role) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - // OPT-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.UNKNOWN) - parameters.add(Role.OPT_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - // NON-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.UNKNOWN) - parameters.add(Role.NON_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.ATTENDEE_STATUS_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - - @Test - fun testBuildAttendee_Group() { - // REQ-PARTICIPANT (default, includes unknown values) - for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.GROUP) - if (role != null) - parameters.add(role) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - // OPT-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.GROUP) - parameters.add(Role.OPT_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - // NON-PARTICIPANT - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.GROUP) - parameters.add(Role.NON_PARTICIPANT) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - - @Test - fun testBuildAttendee_Resource() { - for (role in arrayOf(null, Role.REQ_PARTICIPANT, Role.OPT_PARTICIPANT, Role.NON_PARTICIPANT, Role("X-CUSTOM-ROLE"))) - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.RESOURCE) - if (role != null) - parameters.add(role) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - // CHAIR - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.RESOURCE) - parameters.add(Role.CHAIR) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - - @Test - fun testBuildAttendee_Chair() { - for (cuType in arrayOf(null, CuType.INDIVIDUAL, CuType.UNKNOWN, CuType.GROUP, CuType("x-custom-cutype"))) - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - if (cuType != null) - parameters.add(cuType) - parameters.add(Role.CHAIR) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - - @Test - fun testBuildAttendee_Room() { - for (role in arrayOf(null, Role.CHAIR, Role.REQ_PARTICIPANT, Role.OPT_PARTICIPANT, Role.NON_PARTICIPANT, Role("X-CUSTOM-ROLE"))) - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(CuType.ROOM) - if (role != null) - parameters.add(role) - } - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - - @Test - fun testBuildAttendee_Organizer() { - buildEvent(true) { - attendees += Attendee(URI("mailto", testAccount.name, null)) - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(testAccount.name, attendee.getAsString(Attendees.ATTENDEE_EMAIL)) - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_ORGANIZER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } - } - } - @Test - fun testBuildAttendee_PartStat_None() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com") - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_NeedsAction() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat.NEEDS_ACTION) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_Accepted() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat.ACCEPTED) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_ACCEPTED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_Declined() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat.DECLINED) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_DECLINED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_Tentative() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat.TENTATIVE) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_TENTATIVE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_Delegated() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat.DELEGATED) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_NONE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - @Test - fun testBuildAttendee_PartStat_Custom() { - buildEvent(true) { - attendees += Attendee("mailto:attendee@example.com").apply { - parameters.add(PartStat("X-WILL-ASK")) - } - }.let { result -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) - } - } - - - @Test - fun testBuildUnknownProperty() { - buildEvent(true) { - val params = ParameterList() - params.add(Language("en")) - unknownProperties += XProperty("X-NAME", params, "Custom Value") - }.let { result -> - firstUnknownProperty(result)!!.let { property -> - assertEquals("X-NAME", property.name) - assertEquals("en", property.getParameter(Parameter.LANGUAGE).value) - assertEquals("Custom Value", property.value) - } - } - } - - @Test - fun testBuildUnknownProperty_NoValue() { - buildEvent(true) { - unknownProperties += XProperty("ATTACH", ParameterList(), null) - }.let { result -> - // The property should not have been added, so the first unknown property should be null - assertNull(firstUnknownProperty(result)) - } - } - - private fun firstException(values: ContentValues): ContentValues? { - val id = values.getAsInteger(Events._ID) - client.query(Events.CONTENT_URI.asSyncAdapter(testAccount), null, - "${Events.ORIGINAL_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> - if (cursor.moveToNext()) { - val result = ContentValues(cursor.count) - DatabaseUtils.cursorRowToContentValues(cursor, result) - return result - } - } - return null - } - - @Test - fun testBuildException_NonAllDay() { - buildEvent(false) { - dtStart = DtStart("20200706T193000", tzVienna) - rRules += RRule("FREQ=DAILY;COUNT=10") - exceptions += Event().apply { - recurrenceId = RecurrenceId("20200707T193000", tzVienna) - dtStart = DtStart("20200706T203000", tzShanghai) - summary = "Event moved to one hour later" - } - }.let { result -> - assertEquals(1594056600000L, result.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, result.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(0, result.getAsInteger(Events.ALL_DAY)) - assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Events.RRULE)) - firstException(result)!!.let { exception -> - assertEquals(1594143000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) - assertEquals(0, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) - assertEquals(1594038600000L, exception.getAsLong(Events.DTSTART)) - assertEquals(tzShanghai.id, exception.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) - assertEquals("Event moved to one hour later", exception.getAsString(Events.TITLE)) - } - } - } - - @Test - fun testBuildException_NonAllDay_RecurrenceIdAllDay() { - buildEvent(false) { - dtStart = DtStart("20200706T193000", tzVienna) - rRules += RRule("FREQ=DAILY;COUNT=10") - exceptions += Event().apply { - recurrenceId = RecurrenceId(Date("20200707")) // illegal! should be rewritten to DateTime("20200707T193000", tzVienna) - dtStart = DtStart("20200706T203000", tzShanghai) - summary = "Event moved to one hour later" - } - }.let { result -> - assertEquals(1594056600000L, result.getAsLong(Events.DTSTART)) - assertEquals(tzVienna.id, result.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(0, result.getAsInteger(Events.ALL_DAY)) - assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Events.RRULE)) - firstException(result)!!.let { exception -> - assertEquals(1594143000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) - assertEquals(0, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) - assertEquals(1594038600000L, exception.getAsLong(Events.DTSTART)) - assertEquals(tzShanghai.id, exception.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) - assertEquals("Event moved to one hour later", exception.getAsString(Events.TITLE)) - } - } - } - - @Test - fun testBuildException_AllDay() { - buildEvent(false) { - dtStart = DtStart(Date("20200706")) - rRules += RRule("FREQ=WEEKLY;COUNT=3") - exceptions += Event().apply { - recurrenceId = RecurrenceId(Date("20200707")) - dtStart = DtStart("20200706T123000", tzVienna) - summary = "Today not an all-day event" - } - }.let { result -> - assertEquals(1593993600000L, result.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, result.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(1, result.getAsInteger(Events.ALL_DAY)) - assertEquals("FREQ=WEEKLY;COUNT=3", result.getAsString(Events.RRULE)) - firstException(result)!!.let { exception -> - assertEquals(1594080000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) - assertEquals(1, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) - assertEquals(1594031400000L, exception.getAsLong(Events.DTSTART)) - assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) - assertEquals("Today not an all-day event", exception.getAsString(Events.TITLE)) - } - } - } - - @Test - fun testBuildException_AllDay_RecurrenceIdNonAllDay() { - buildEvent(false) { - dtStart = DtStart(Date("20200706")) - rRules += RRule("FREQ=WEEKLY;COUNT=3") - exceptions += Event().apply { - recurrenceId = RecurrenceId("20200707T000000", tzVienna) // illegal! should be rewritten to Date("20200707") - dtStart = DtStart("20200706T123000", tzVienna) - summary = "Today not an all-day event" - } - }.let { result -> - assertEquals(1593993600000L, result.getAsLong(Events.DTSTART)) - assertEquals(AndroidTimeUtils.TZID_ALLDAY, result.getAsString(Events.EVENT_TIMEZONE)) - assertEquals(1, result.getAsInteger(Events.ALL_DAY)) - assertEquals("FREQ=WEEKLY;COUNT=3", result.getAsString(Events.RRULE)) - firstException(result)!!.let { exception -> - assertEquals(1594080000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) - assertEquals(1, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) - assertEquals(1594031400000L, exception.getAsLong(Events.DTSTART)) - assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) - assertEquals("Today not an all-day event", exception.getAsString(Events.TITLE)) - } - } - } - - - private fun populateAndroidEvent( - automaticDates: Boolean, - destinationCalendar: AndroidCalendar = calendar, - asSyncAdapter: Boolean = false, - insertCallback: (id: Long) -> Unit = {}, - extendedProperties: Map = emptyMap(), - valuesBuilder: ContentValues.() -> Unit = {} - ): AndroidEvent { - val values = ContentValues() - values.put(Events.CALENDAR_ID, destinationCalendar.id) - if (automaticDates) { - values.put(Events.DTSTART, 1592733600000L) // 21/06/2020 12:00 +0200 - values.put(Events.EVENT_TIMEZONE, "Europe/Berlin") - values.put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 - values.put(Events.EVENT_END_TIMEZONE, "Europe/Berlin") - } - valuesBuilder(values) - logger.info("Inserting test event: $values") - val uri = client.insert( - if (asSyncAdapter) - Events.CONTENT_URI.asSyncAdapter(testAccount) - else - Events.CONTENT_URI, - values)!! - val id = ContentUris.parseId(uri) - - // insert additional rows etc. - insertCallback(id) - - // insert extended properties - for ((name, value) in extendedProperties) { - val extendedValues = contentValuesOf( - ExtendedProperties.EVENT_ID to id, - ExtendedProperties.NAME to name, - ExtendedProperties.VALUE to value - ) - client.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), extendedValues) - } - - return destinationCalendar.getEvent(id)!! - } - - private fun populateEvent( - automaticDates: Boolean, - destinationCalendar: AndroidCalendar = calendar, - asSyncAdapter: Boolean = false, - insertCallback: (id: Long) -> Unit = {}, - extendedProperties: Map = emptyMap(), - valuesBuilder: ContentValues.() -> Unit = {} - ): Event { - return populateAndroidEvent( - automaticDates, - destinationCalendar, - asSyncAdapter, - insertCallback, - extendedProperties, - valuesBuilder - ).event!! - } - - @Test - fun testPopulateEvent_Uid_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" - ) - ).let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445() { - populateEvent(true) { - put(Events.UID_2445, "event1@example.com") - }.let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" - ) - ) { - put(Events.UID_2445, "event2@example.com") - }.let { result -> - assertEquals("event2@example.com", result.uid) - } - } - - - @Test - fun testPopulateEvent_Sequence_Int() { - populateEvent(true, asSyncAdapter = true) { - put(AndroidEvent.COLUMN_SEQUENCE, 5) - }.let { result -> - assertEquals(5, result.sequence) - } - } - - @Test - fun testPopulateEvent_Sequence_Null() { - populateEvent(true, asSyncAdapter = true) { - putNull(AndroidEvent.COLUMN_SEQUENCE) - }.let { result -> - assertNull(result.sequence) - } - } - - @Test - fun testPopulateEvent_IsOrganizer_False() { - populateEvent(true, asSyncAdapter = true) { - put(Events.IS_ORGANIZER, "0") - }.let { result -> - assertFalse(result.isOrganizer!!) - } - } - - @Test - fun testPopulateEvent_IsOrganizer_Null() { - populateEvent(true, asSyncAdapter = true) { - putNull(Events.IS_ORGANIZER) - }.let { result -> - assertNull(result.isOrganizer) - } - } - - @Test - fun testPopulateEvent_IsOrganizer_True() { - populateEvent(true, asSyncAdapter = true) { - put(Events.IS_ORGANIZER, "1") - }.let { result -> - assertTrue(result.isOrganizer!!) - } - } - - @Test - fun testPopulateEvent_NonAllDay_NonRecurring() { - populateEvent(false) { - put(Events.DTSTART, 1592733600000L) // 21/06/2020 12:00 +0200 - put(Events.EVENT_TIMEZONE, "Europe/Vienna") - put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 - put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") - }.let { result -> - assertEquals(DtStart(DateTime("20200621T120000", tzVienna)), result.dtStart) - assertEquals(DtEnd(DateTime("20200621T143000", tzVienna)), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_NonAllDay_NonRecurring_MixedZones() { - populateEvent(false) { - put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 - put(Events.EVENT_TIMEZONE, "Asia/Shanghai") - put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 - put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") - }.let { result -> - assertEquals(DtStart(DateTime("20200621T180000", tzShanghai)), result.dtStart) - assertEquals(DtEnd(DateTime("20200621T143000", tzVienna)), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_NonAllDay_NonRecurring_Duration() { - /* This should not happen, because according to the documentation, non-recurring events MUST - have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ - populateEvent(false, asSyncAdapter = false) { - put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 - put(Events.EVENT_TIMEZONE, "Asia/Shanghai") - put(Events.DURATION, "PT1H") - }.let { result -> - assertEquals(DtStart(DateTime("20200621T180000", tzShanghai)), result.dtStart) - assertEquals(DtEnd(DateTime("20200621T190000", tzShanghai)), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_NonAllDay_Recurring_Duration_KievTimeZone() { - populateEvent(false) { - put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 - put(Events.EVENT_TIMEZONE, "Europe/Kiev") - put(Events.DURATION, "PT1H") - put(Events.RRULE, "FREQ=DAILY;COUNT=2") - }.let { result -> - assertEquals(1592733600000L, result.dtStart?.date?.time) - assertEquals(1592733600000L + 3600000, result.dtEnd?.date?.time) - assertEquals("Europe/Kiev", result.dtStart?.timeZone?.id) - assertEquals("Europe/Kiev", result.dtEnd?.timeZone?.id) - } - } - - @Test - fun testPopulateEvent_NonAllDay_NonRecurring_NoTime() { - populateEvent(false) { - put(Events.DTSTART, 1592742600000L) // 21/06/2020 14:30 +0200 - put(Events.EVENT_TIMEZONE, "Europe/Vienna") - put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 - put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") - }.let { result -> - assertEquals(DtStart(DateTime("20200621T143000", tzVienna)), result.dtStart) - //assertNull(result.dtEnd) - assertEquals(result.dtEnd!!.date, result.dtStart!!.date) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_AllDay_NonRecurring_NoTime() { - populateEvent(false) { - put(Events.ALL_DAY, 1) - put(Events.DTSTART, 1592697600000L) // 21/06/2020 - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.DTEND, 1592697600000L) // 21/06/2020 - put(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - }.let { result -> - assertEquals(DtStart(Date("20200621")), result.dtStart) - assertNull(result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_AllDay_NonRecurring_1Day() { - populateEvent(false) { - put(Events.ALL_DAY, 1) - put(Events.DTSTART, 1592697600000L) // 21/06/2020 - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.DTEND, 1592784000000L) // 22/06/2020 - put(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - }.let { result -> - assertEquals(DtStart(Date("20200621")), result.dtStart) - assertEquals(DtEnd(Date("20200622")), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_AllDay_NonRecurring_AllDayDuration() { - /* This should not happen, because according to the documentation, non-recurring events MUST - have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ - populateEvent(false, asSyncAdapter = false) { - put(Events.ALL_DAY, 1) - put(Events.DTSTART, 1592697600000L) // 21/06/2020 - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.DURATION, "P1W") - }.let { result -> - assertEquals(DtStart(Date("20200621")), result.dtStart) - assertEquals(DtEnd(Date("20200628")), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_AllDay_NonRecurring_NonAllDayDuration_LessThanOneDay() { - /* This should not happen, because according to the documentation, non-recurring events MUST - have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ - populateEvent(false, asSyncAdapter = false) { - put(Events.ALL_DAY, 1) - put(Events.DTSTART, 1592697600000L) // 21/06/2020 - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.DURATION, "PT1H30M") - }.let { result -> - assertEquals(DtStart(Date("20200621")), result.dtStart) - assertNull(result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_AllDay_NonRecurring_NonAllDayDuration_MoreThanOneDay() { - /* This should not happen, because according to the documentation, non-recurring events MUST - have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ - populateEvent(false, asSyncAdapter = false) { - put(Events.ALL_DAY, 1) - put(Events.DTSTART, 1592697600000L) // 21/06/2020 - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.DURATION, "PT49H2M") - }.let { result -> - assertEquals(DtStart(Date("20200621")), result.dtStart) - assertEquals(DtEnd(Date("20200623")), result.dtEnd) - assertNull(result.duration) - } - } - - @Test - fun testPopulateEvent_Summary() { - populateEvent(true) { - put(Events.TITLE, "Sample Title") - }.let { result -> - assertEquals("Sample Title", result.summary) - } - } - - @Test - fun testPopulateEvent_Location() { - populateEvent(true) { - put(Events.EVENT_LOCATION, "Sample Location") - }.let { result -> - assertEquals("Sample Location", result.location) - } - } - - @Test - fun testPopulateEvent_Url() { - populateEvent(true, - extendedProperties = mapOf(AndroidEvent.EXTNAME_URL to "https://example.com") - ).let { result -> - assertEquals(URI("https://example.com"), result.url) - } - } - - @Test - fun testPopulateEvent_Description() { - populateEvent(true) { - put(Events.DESCRIPTION, "Sample Description") - }.let { result -> - assertEquals("Sample Description", result.description) - } - } - - @Test - fun testPopulateEvent_Color_FromIndex() { - val provider = AndroidCalendarProvider(testAccount, client) - provider.provideCss3ColorIndices() - populateEvent(true) { - put(Events.EVENT_COLOR_KEY, Css3Color.silver.name) - }.let { result -> - assertEquals(Css3Color.silver, result.color) - } - } - - @Test - fun testPopulateEvent_Color_FromValue() { - populateEvent(true) { - put(Events.EVENT_COLOR, Css3Color.silver.argb) - }.let { result -> - assertEquals(Css3Color.silver, result.color) - } - } - - @Test - fun testPopulateEvent_Status_Confirmed() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_CONFIRMED) - }.let { result -> - assertEquals(Status.VEVENT_CONFIRMED, result.status) - } - } - - @Test - fun testPopulateEvent_Status_Tentative() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_TENTATIVE) - }.let { result -> - assertEquals(Status.VEVENT_TENTATIVE, result.status) - } - } - - @Test - fun testPopulateEvent_Status_Cancelled() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_CANCELED) - }.let { result -> - assertEquals(Status.VEVENT_CANCELLED, result.status) - } - } - - @Test - fun testPopulateEvent_Status_None() { - assertNull(populateEvent(true).status) - } - - @Test - fun testPopulateEvent_Availability_Busy() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_BUSY) - }.let { result -> - assertTrue(result.opaque) - } - } - - @Test - fun testPopulateEvent_Availability_Tentative() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_TENTATIVE) - }.let { result -> - assertTrue(result.opaque) - } - } - - @Test - fun testPopulateEvent_Availability_Free() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_FREE) - }.let { result -> - assertFalse(result.opaque) - } - } - - @Test - fun testPopulateEvent_Organizer_NotGroupScheduled() { - assertNull(populateEvent(true).organizer) - } - - @Test - fun testPopulateEvent_Organizer_NotGroupScheduled_ExplicitOrganizer() { - populateEvent(true) { - put(Events.ORGANIZER, "sample@example.com") - }.let { result -> - assertNull(result.organizer) - } - } - - @Test - fun testPopulateEvent_Organizer_GroupScheduled() { - populateEvent(true, insertCallback = { id -> - client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { - put(Attendees.EVENT_ID, id) - put(Attendees.ATTENDEE_EMAIL, "organizer@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.RELATIONSHIP_ORGANIZER) - }) - }) { - put(Events.ORGANIZER, "organizer@example.com") - }.let { result -> - assertEquals("mailto:organizer@example.com", result.organizer?.value) - } - } - - @Test - fun testPopulateEvent_Classification_Public() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_PUBLIC) - }.let { result -> - assertEquals(Clazz.PUBLIC, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Private() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_PRIVATE) - }.let { result -> - assertEquals(Clazz.PRIVATE, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Confidential() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_CONFIDENTIAL) - }.let { result -> - assertEquals(Clazz.CONFIDENTIAL, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Confidential_Retained() { - populateEvent(true, - extendedProperties = mapOf(UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz.CONFIDENTIAL)) - ) { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }.let { result -> - assertEquals(Clazz.CONFIDENTIAL, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Default() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }.let { result -> - assertNull(result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Custom() { - populateEvent( - true, - valuesBuilder = { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }, - extendedProperties = mapOf( - UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz("TOP-SECRET")) - ) - ).let { result -> - assertEquals(Clazz("TOP-SECRET"), result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_None() { - populateEvent(true) { - }.let { result -> - assertNull(result.classification) - } - } - - - private fun populateReminder(destinationCalendar: AndroidCalendar = calendar, builder: ContentValues.() -> Unit): VAlarm? { - populateEvent(true, destinationCalendar = destinationCalendar, insertCallback = { id -> - val reminderValues = ContentValues() - reminderValues.put(Reminders.EVENT_ID, id) - builder(reminderValues) - logger.info("Inserting test reminder: $reminderValues") - client.insert(Reminders.CONTENT_URI.asSyncAdapter(testAccount), reminderValues) - }).let { result -> - return result.alarms.firstOrNull() - } - } - - @Test - fun testPopulateReminder_TypeEmail_AccountNameEmail() { - // account name looks like an email address - assertEquals("ical4android@example.com", testAccount.name) - - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_EMAIL) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Action.EMAIL, alarm.action) - assertNotNull(alarm.summary) - assertNotNull(alarm.description) - } - } - - @Test - fun testPopulateReminder_TypeEmail_AccountNameNotEmail() { - // test account name that doesn't look like an email address - val nonEmailAccount = Account("ical4android", ACCOUNT_TYPE_LOCAL) - val testCalendar = TestCalendar.findOrCreate(nonEmailAccount, client) - try { - populateReminder(testCalendar) { - put(Reminders.METHOD, Reminders.METHOD_EMAIL) - }!!.let { alarm -> - assertEquals(Action.DISPLAY, alarm.action) - assertNotNull(alarm.description) - } - } finally { - testCalendar.delete() - } - } - - @Test - fun testPopulateReminder_TypeNotEmail() { - for (type in arrayOf(null, Reminders.METHOD_ALARM, Reminders.METHOD_ALERT, Reminders.METHOD_DEFAULT, Reminders.METHOD_SMS)) - populateReminder { - put(Reminders.METHOD, type) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Action.DISPLAY, alarm.action) - assertNotNull(alarm.description) - } - } - - @Test - fun testPopulateReminder_Minutes_Positive() { - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_ALERT) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) - } - } - - @Test - fun testPopulateReminder_Minutes_Negative() { - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_ALERT) - put(Reminders.MINUTES, -10) - }!!.let { alarm -> - assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) - } - } - - - private fun populateAttendee(builder: ContentValues.() -> Unit): Attendee? { - populateEvent(true, insertCallback = { id -> - val attendeeValues = ContentValues() - attendeeValues.put(Attendees.EVENT_ID, id) - builder(attendeeValues) - logger.info("Inserting test attendee: $attendeeValues") - client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), attendeeValues) - }).let { result -> - return result.attendees.firstOrNull() - } - } - - @Test - fun testPopulateAttendee_Email() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertEquals(URI("mailto:attendee@example.com"), attendee.calAddress) - } - } - - @Test - fun testPopulateAttendee_OtherUri() { - populateAttendee { - put(Attendees.ATTENDEE_ID_NAMESPACE, "https") - put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") - }!!.let { attendee -> - assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) - } - } - - @Test - fun testPopulateAttendee_EmailAndOtherUri() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_ID_NAMESPACE, "https") - put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") - }!!.let { attendee -> - assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) - assertEquals("attendee@example.com", attendee.getParameter(Parameter.EMAIL).value) - } - } - - @Test - fun testPopulateAttendee_AttendeeOrganizer() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER)) - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Performer() { - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Speaker() { - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) - } - // TYPE_RESOURCE - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - }!!.let { attendee -> - assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_RelNone() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_NONE, null)) - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_TypeNone() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Required() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Optional() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Resource() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) - } - // RELATIONSHIP_PERFORMER - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }!!.let { attendee -> - assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Status_Null() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Invited() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_INVITED) - }!!.let { attendee -> - assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Accepted() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED) - }!!.let { attendee -> - assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Declined() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_DECLINED) - }!!.let { attendee -> - assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Tentative() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_TENTATIVE) - }!!.let { attendee -> - assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_None() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_NONE) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Rsvp() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) - } - } - - @Test - fun testPopulateUnknownProperty() { - val params = ParameterList() - params.add(Language("en")) - val unknownProperty = XProperty("X-NAME", params, "Custom Value") - val (result) = populateEvent( - true, - extendedProperties = mapOf( - UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(unknownProperty) - ) - ).unknownProperties - assertEquals("X-NAME", result.name) - assertEquals("en", result.getParameter(Parameter.LANGUAGE).value) - assertEquals("Custom Value", result.value) - } - - - private fun populateException(mainBuilder: ContentValues.() -> Unit, exceptionBuilder: ContentValues.() -> Unit) = - populateEvent(false, asSyncAdapter = true, valuesBuilder = mainBuilder, insertCallback = { id -> - val exceptionValues = ContentValues() - exceptionValues.put(Events.CALENDAR_ID, calendar.id) - exceptionBuilder(exceptionValues) - client.insert(Events.CONTENT_URI.asSyncAdapter(testAccount), exceptionValues) - }) - - @Test - fun testPopulateException_NonAllDay() { - populateException({ - put(Events._SYNC_ID, "testPopulateException_NonAllDay") - put(Events.TITLE, "Recurring non-all-day event with exception") - put(Events.DTSTART, 1594056600000L) - put(Events.EVENT_TIMEZONE, tzVienna.id) - put(Events.ALL_DAY, 0) - put(Events.RRULE, "FREQ=DAILY;COUNT=10") - }, { - put(Events.ORIGINAL_SYNC_ID, "testPopulateException_NonAllDay") - put(Events.ORIGINAL_INSTANCE_TIME, 1594143000000L) - put(Events.ORIGINAL_ALL_DAY, 0) - put(Events.DTSTART, 1594038600000L) - put(Events.EVENT_TIMEZONE, tzShanghai.id) - put(Events.ALL_DAY, 0) - put(Events.TITLE, "Event moved to one hour later") - }).let { event -> - assertEquals("Recurring non-all-day event with exception", event.summary) - assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) - assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) - val exception = event.exceptions.first() - assertEquals(RecurrenceId("20200708T013000", tzShanghai), exception.recurrenceId) - assertEquals(DtStart("20200706T203000", tzShanghai), exception.dtStart) - assertEquals("Event moved to one hour later", exception.summary) - } - } - - @Test - fun testPopulateException_AllDay() { - populateException({ - put(Events._SYNC_ID, "testPopulateException_AllDay") - put(Events.TITLE, "Recurring all-day event with exception") - put(Events.DTSTART, 1593993600000L) - put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) - put(Events.ALL_DAY, 1) - put(Events.RRULE, "FREQ=WEEKLY;COUNT=3") - }, { - put(Events.ORIGINAL_SYNC_ID, "testPopulateException_AllDay") - put(Events.ORIGINAL_INSTANCE_TIME, 1594080000000L) - put(Events.ORIGINAL_ALL_DAY, 1) - put(Events.DTSTART, 1594031400000L) - put(Events.ALL_DAY, 0) - put(Events.EVENT_TIMEZONE, tzShanghai.id) - put(Events.TITLE, "Today not an all-day event") - }).let { event -> - assertEquals("Recurring all-day event with exception", event.summary) - assertEquals(DtStart(Date("20200706")), event.dtStart) - assertEquals("FREQ=WEEKLY;COUNT=3", event.rRules.first().value) - val exception = event.exceptions.first() - assertEquals(RecurrenceId(Date("20200707")), exception.recurrenceId) - assertEquals(DtStart("20200706T183000", tzShanghai), exception.dtStart) - assertEquals("Today not an all-day event", exception.summary) - } - } - - @Test - fun testPopulateException_Exdate() { - populateException({ - put(Events._SYNC_ID, "testPopulateException_AllDay") - put(Events.TITLE, "Recurring all-day event with cancelled exception") - put(Events.DTSTART, 1594056600000L) - put(Events.EVENT_TIMEZONE, tzVienna.id) - put(Events.ALL_DAY, 0) - put(Events.RRULE, "FREQ=DAILY;COUNT=10") - }, { - put(Events.ORIGINAL_SYNC_ID, "testPopulateException_AllDay") - put(Events.ORIGINAL_INSTANCE_TIME, 1594143000000L) - put(Events.ORIGINAL_ALL_DAY, 0) - put(Events.DTSTART, 1594143000000L) - put(Events.ALL_DAY, 0) - put(Events.EVENT_TIMEZONE, tzShanghai.id) - put(Events.STATUS, Events.STATUS_CANCELED) - }).let { event -> - assertEquals("Recurring all-day event with cancelled exception", event.summary) - assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) - assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) - assertEquals(DateTime("20200708T013000", tzShanghai), event.exDates.first().dates.first()) - assertTrue(event.exceptions.isEmpty()) - } - } - - @Test fun testUpdateEvent() { // add test event without reminder diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt index 8b989d2d..1bc8b00c 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AospTest.kt @@ -30,7 +30,7 @@ class AospTest { Manifest.permission.WRITE_CALENDAR )!! - private val testAccount = Account("test@example.com", CalendarContract.ACCOUNT_TYPE_LOCAL) + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) private val provider by lazy { InstrumentationRegistry.getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt index d3b6554c..c8f62ec8 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskListTest.kt @@ -10,6 +10,7 @@ import android.accounts.Account import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils +import android.provider.CalendarContract import at.bitfire.ical4android.impl.TestTask import at.bitfire.ical4android.impl.TestTaskList import net.fortuna.ical4j.model.property.RelatedTo @@ -25,7 +26,7 @@ import org.junit.Test class DmfsTaskListTest(providerName: TaskProvider.ProviderName): DmfsStyleProvidersTaskTest(providerName) { - private val testAccount = Account("AndroidTaskListTest", TaskContract.LOCAL_ACCOUNT_TYPE) + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) private fun createTaskList(): TestTaskList { diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt index d99dc660..a09cd3d2 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsTaskTest.kt @@ -11,6 +11,7 @@ import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri +import android.provider.CalendarContract import at.bitfire.ical4android.impl.TestTask import at.bitfire.ical4android.impl.TestTaskList import at.bitfire.synctools.storage.LocalStorageException @@ -37,7 +38,6 @@ import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo import net.fortuna.ical4j.model.property.Status import net.fortuna.ical4j.model.property.XProperty -import org.dmfs.tasks.contract.TaskContract.LOCAL_ACCOUNT_TYPE import org.dmfs.tasks.contract.TaskContract.Properties import org.dmfs.tasks.contract.TaskContract.Property import org.dmfs.tasks.contract.TaskContract.Property.Category @@ -63,7 +63,7 @@ class DmfsTaskTest( private val tzChicago = tzRegistry.getTimeZone("America/Chicago")!! private val tzDefault = tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! - private val testAccount = Account("AndroidTaskTest", LOCAL_ACCOUNT_TYPE) + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) private lateinit var taskListUri: Uri private var taskList: TestTaskList? = null diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt index d6739f51..2e9168d4 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt @@ -9,6 +9,7 @@ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentValues +import android.provider.CalendarContract import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.impl.TestJtxCollection import at.bitfire.ical4android.impl.testProdId @@ -34,7 +35,7 @@ class JtxCollectionTest { val context = InstrumentationRegistry.getInstrumentation().targetContext private lateinit var client: ContentProviderClient - private val testAccount = Account("TEST", JtxContract.JtxCollection.TEST_ACCOUNT_TYPE) + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) private val url = "https://jtx.techbee.at" private val displayname = "jtx" diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index ba892521..802ad04b 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -11,6 +11,7 @@ import android.content.ContentProviderClient import android.content.ContentValues import android.database.DatabaseUtils import android.os.ParcelFileDescriptor +import android.provider.CalendarContract import androidx.core.content.pm.PackageInfoCompat import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.impl.TestJtxCollection @@ -44,7 +45,7 @@ class JtxICalObjectTest { val context = InstrumentationRegistry.getInstrumentation().targetContext private lateinit var client: ContentProviderClient - private val testAccount = Account("TEST", JtxContract.JtxCollection.TEST_ACCOUNT_TYPE) + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) private var collection: JtxCollection? = null private var sample: at.bitfire.ical4android.JtxICalObject? = null diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt index 772126f7..ee54ef70 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt @@ -13,14 +13,19 @@ import android.provider.CalendarContract.Reminders import androidx.core.content.contentValuesOf import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import java.util.UUID object TestCalendar { fun findOrCreate(account: Account, client: ContentProviderClient): AndroidCalendar { val provider = AndroidCalendarProvider(account, client) + + // we use colors for testing + provider.provideCss3ColorIndices() + return provider.findFirstCalendar( null, null) ?: provider.createAndGetCalendar(contentValuesOf( - Calendars.NAME to "TestCalendar", + Calendars.NAME to UUID.randomUUID().toString(), Calendars.CALENDAR_DISPLAY_NAME to "ical4android Test Calendar", Calendars.ALLOWED_REMINDERS to Reminders.METHOD_DEFAULT) ) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilderTest.kt new file mode 100644 index 00000000..c1c58d6e --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilderTest.kt @@ -0,0 +1,1393 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.AUTHORITY +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.ical4android.impl.TestCalendar +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import at.bitfire.synctools.storage.toContentValues +import at.bitfire.synctools.test.InitCalendarProviderRule +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.Language +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.XProperty +import net.fortuna.ical4j.util.TimeZones +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.URI +import java.time.Period +import java.util.UUID + +class AndroidEventBuilderTest { + + @get:Rule + val initCalendarProviderRule = InitCalendarProviderRule.initialize() + + private val testAccount = Account(javaClass.name, ACCOUNT_TYPE_LOCAL) + + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzIdDefault = java.util.TimeZone.getDefault().id + private val tzDefault = tzRegistry.getTimeZone(tzIdDefault) + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna") + private val tzShanghai = tzRegistry.getTimeZone("Asia/Shanghai") + + lateinit var client: ContentProviderClient + lateinit var calendar: AndroidCalendar + + @Before + fun setUp() { + val context = getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(AUTHORITY)!! + calendar = TestCalendar.findOrCreate(testAccount, client) + } + + @After + fun tearDown() { + calendar.delete() + client.closeCompat() + } + + + /** + * buildEvent() BASIC TEST MATRIX: + * + * all-day event | hasDtEnd | hasDuration | recurring event | notes + * 0 0 0 0 dtEnd = dtStart + * 0 0 0 1 duration = 0s, rRule/rDate set + * 0 0 1 0 dtEnd calulcated from duration + * 0 0 1 1 + * 0 1 0 0 + * 0 1 0 1 dtEnd calulcated from duration + * 0 1 1 0 duration ignored + * 0 1 1 1 duration ignored + * 1 0 0 0 duration = 1d + * 1 0 0 1 duration = 1d + * 1 0 1 0 dtEnd calculated from duration + * 1 0 1 1 + * 1 1 0 0 + * 1 1 0 1 duration calculated from dtEnd; ignore times in rDate + * 1 1 1 0 duration ignored + * 1 1 1 1 duration ignored + * + * buildEvent() EXTRA TESTS: + * + * - floating times + * - floating times in rdate/exdate + * - UTC times + */ + + private fun buildEvent(automaticDates: Boolean, eventBuilder: Event.() -> Unit): ContentValues { + val event = Event().apply { + if (automaticDates) + dtStart = DtStart(DateTime()) + eventBuilder() + } + // write event with random file name/sync_id + val uri = AndroidEvent(calendar, event, syncId = UUID.randomUUID().toString()).add() + client.query(uri, null, null, null, null)!!.use { cursor -> + cursor.moveToNext() + return cursor.toContentValues() + } + } + + private fun firstExtendedProperty(values: ContentValues): String? { + val id = values.getAsInteger(Events._ID) + client.query(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), arrayOf(ExtendedProperties.VALUE), + "${ExtendedProperties.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { + if (it.moveToNext()) + return it.getString(0) + } + return null + } + + private fun firstUnknownProperty(values: ContentValues): Property? { + val rawValue = firstExtendedProperty(values) + return if (rawValue != null) + UnknownProperty.fromJsonString(rawValue) + else + null + } + + @Test + fun testBuildEvent_NonAllDay_NoDtEnd_NoDuration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTEND)) + assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) + } + + @Test + fun testBuildEvent_NonAllDay_NoDtEnd_NoDuration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + rRules += RRule("FREQ=DAILY;COUNT=5") + rRules += RRule("FREQ=WEEKLY;COUNT=10") + rDates += RDate(DateList("20210601T123000", Value.DATE_TIME, tzVienna)) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("P0D", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("FREQ=DAILY;COUNT=5\nFREQ=WEEKLY;COUNT=10", values.getAsString(Events.RRULE)) + assertEquals("${tzVienna.id};20200601T123000,20210601T123000", values.getAsString(Events.RDATE)) + } + + @Test + fun testBuildEvent_NonAllDay_NoDtEnd_Duration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + duration = Duration(null, "PT1H30M") + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591007400000L + 90*60000, values.getAsLong(Events.DTEND)) + assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_NonAllDayUtc_NoDtEnd_Duration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart(DateTime("20200601T103000Z").apply { isUtc = true }) + duration = Duration(null, "PT1H30M") + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(TimeZones.getUtcTimeZone().id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591007400000L + 90*60000, values.getAsLong(Events.DTEND)) + assertEquals(TimeZones.getUtcTimeZone().id, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_NonAllDay_NoDtEnd_Duration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + duration = Duration(null, "PT1H30M") + rDates += RDate(DateList("20200602T113000", Value.DATE_TIME, tzVienna)) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("PT1H30M", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("${tzVienna.id};20200601T123000,20200602T113000", values.get(Events.RDATE)) + } + + @Test + fun testBuildEvent_NonAllDay_DtEnd_NoDuration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + dtEnd = DtEnd("20200602T143000", tzShanghai) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591079400000L, values.getAsLong(Events.DTEND)) + assertEquals(tzShanghai.id, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_NonAllDay_DtEnd_NoDuration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzShanghai) + dtEnd = DtEnd("20200601T123000", tzVienna) + rDates += RDate(DateList("20200701T123000,20200702T123000", Value.DATE_TIME, tzVienna)) + rDates += RDate(DateList("20200801T123000,20200802T123000", Value.DATE_TIME, tzShanghai)) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590985800000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzShanghai.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("PT6H", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("${tzShanghai.id};20200601T123000,20200701T183000,20200702T183000,20200801T123000,20200802T123000", values.getAsString(Events.RDATE)) + } + + @Test + fun testBuildEvent_NonAllDay_DtEnd_NoDuration_Recurring_InfiniteRruleAndRdate() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzShanghai) + dtEnd = DtEnd("20200601T123000", tzVienna) + rRules += RRule( + Recur("FREQ=DAILY;INTERVAL=2") + ) + rDates += RDate(DateList("20200701T123000,20200702T123000", Value.DATE_TIME, tzVienna)) + } + + assertNull(values.get(Events.RDATE)) + assertEquals("FREQ=DAILY;INTERVAL=2", values.get(Events.RRULE)) + } + + @Test + fun testBuildEvent_NonAllDay_DtEnd_Duration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + dtEnd = DtEnd("20200601T143000", tzVienna) + duration = Duration(null, "PT1S") + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591014600000L, values.getAsLong(Events.DTEND)) + assertEquals(tzVienna.id, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_NonAllDay_DtEnd_Duration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzVienna) + dtEnd = DtEnd("20200601T143000", tzVienna) + duration = Duration(null, "PT10S") + rRules += RRule("FREQ=MONTHLY;COUNT=1") + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591007400000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("PT2H", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("FREQ=MONTHLY;COUNT=1", values.get(Events.RRULE)) + } + + @Test + fun testBuildEvent_AllDay_NoDtEnd_NoDuration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591056000000L, values.getAsLong(Events.DTEND)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_AllDay_NoDtEnd_NoDuration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + rRules += RRule("FREQ=MONTHLY;COUNT=3") + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("P1D", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("FREQ=MONTHLY;COUNT=3", values.get(Events.RRULE)) + } + + @Test + fun testBuildEvent_AllDay_NoDtEnd_Duration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + duration = Duration(null, "P2W1D") + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1592265600000L, values.getAsLong(Events.DTEND)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_AllDay_NoDtEnd_Duration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + duration = Duration(null, "P2D") + rRules += RRule("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU") + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("P2D", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU", values.get(Events.RRULE)) + } + + @Test + fun testBuildEvent_AllDay_DtEnd_NoDuration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + dtEnd = DtEnd(Date("20200701")) + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1593561600000L, values.getAsLong(Events.DTEND)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_AllDay_DtEnd_NoDuration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + dtEnd = DtEnd(Date("20200701")) + rDates += RDate(DateList("20210601", Value.DATE)) + rDates += RDate(DateList("20220601T120030", Value.DATE_TIME)) + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("P30D", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("20200601T000000Z,20210601T000000Z,20220601T000000Z", values.get(Events.RDATE)) + } + + @Test + fun testBuildEvent_AllDay_DtEnd_Duration_NonRecurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + dtEnd = DtEnd(Date("20200701")) + duration = Duration(null, "PT5M") + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1593561600000L, values.getAsLong(Events.DTEND)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_END_TIMEZONE)) + assertNull(values.get(Events.DURATION)) + } + + @Test + fun testBuildEvent_AllDay_DtEnd_Duration_Recurring() { + val values = buildEvent(false) { + dtStart = DtStart(Date("20200601")) + dtEnd = DtEnd(Date("20200701")) + duration = Duration(null, "PT1M") + rRules += RRule("FREQ=DAILY;COUNT=1") + } + assertEquals(1, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590969600000L, values.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("P30D", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.DTEND)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + assertEquals("FREQ=DAILY;COUNT=1", values.get(Events.RRULE)) + } + + @Test + fun testBuildEvent_FloatingTimes() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000") + dtEnd = DtEnd("20200601T123001") + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(DateTime("20200601T123000", tzDefault).time, values.getAsLong(Events.DTSTART)) + assertEquals(tzIdDefault, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(DateTime("20200601T123001", tzDefault).time, values.getAsLong(Events.DTEND)) + assertEquals(tzIdDefault, values.get(Events.EVENT_END_TIMEZONE)) + } + + @Test + fun testBuildEvent_FloatingTimesInRecurrenceDates() { + val values = buildEvent(false) { + dtStart = DtStart("20200601T123000", tzShanghai) + duration = Duration(null, "PT5M30S") + rDates += RDate(DateList("20200602T113000", Value.DATE_TIME)) + exDates += ExDate(DateList("20200602T113000", Value.DATE_TIME)) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1590985800000L, values.getAsLong(Events.DTSTART)) + assertEquals(tzShanghai.id, values.get(Events.EVENT_TIMEZONE)) + + assertEquals("PT5M30S", values.getAsString(Events.DURATION)) + assertNull(values.get(Events.EVENT_END_TIMEZONE)) + + val rewritten = DateTime("20200602T113000") + rewritten.timeZone = tzShanghai + assertEquals("${tzShanghai.id};20200601T123000,$rewritten", values.get(Events.RDATE)) + assertEquals("$tzIdDefault;20200602T113000", values.get(Events.EXDATE)) + } + + @Test + fun testBuildEvent_UTC() { + val values = buildEvent(false) { + dtStart = DtStart(DateTime(1591014600000L), true) + dtEnd = DtEnd(DateTime(1591021801000L), true) + } + assertEquals(0, values.getAsInteger(Events.ALL_DAY)) + + assertEquals(1591014600000L, values.getAsLong(Events.DTSTART)) + assertEquals(TimeZones.UTC_ID, values.get(Events.EVENT_TIMEZONE)) + + assertEquals(1591021801000L, values.getAsLong(Events.DTEND)) + assertEquals(TimeZones.UTC_ID, values.get(Events.EVENT_END_TIMEZONE)) + } + + @Test + fun testBuildEvent_Summary() { + buildEvent(true) { + summary = "Sample Summary" + }.let { result -> + assertEquals("Sample Summary", result.get(Events.TITLE)) + } + } + + @Test + fun testBuildEvent_Location() { + buildEvent(true) { + location = "Sample Location" + }.let { result -> + assertEquals("Sample Location", result.get(Events.EVENT_LOCATION)) + } + } + + @Test + fun testBuildEvent_Url() { + buildEvent(true) { + url = URI("https://example.com") + }.let { result -> + assertEquals("https://example.com", firstExtendedProperty(result)) + } + } + + @Test + fun testBuildEvent_Description() { + buildEvent(true) { + description = "Sample Description" + }.let { result -> + assertEquals("Sample Description", result.get(Events.DESCRIPTION)) + } + } + + @Test + fun testBuildEvent_Color_WhenNotAvailable() { + buildEvent(true) { + color = Css3Color.darkseagreen + }.let { result -> + assertNull(result.get(Events.CALENDAR_COLOR_KEY)) + } + } + + @Test + fun testBuildEvent_Color_WhenAvailable() { + val provider = AndroidCalendarProvider(testAccount, client) + provider.provideCss3ColorIndices() + buildEvent(true) { + color = Css3Color.darkseagreen + }.let { result -> + assertEquals(Css3Color.darkseagreen.name, result.get(Events.EVENT_COLOR_KEY)) + } + } + + @Test + fun testBuildEvent_Organizer_NotGroupScheduled() { + buildEvent(true) { + organizer = Organizer("mailto:organizer@example.com") + }.let { result -> + assertNull(result.get(Events.ORGANIZER)) + } + } + + @Test + fun testBuildEvent_Organizer_MailTo() { + buildEvent(true) { + organizer = Organizer("mailto:organizer@example.com") + attendees += Attendee("mailto:attendee@example.com") + }.let { result -> + assertEquals("organizer@example.com", result.get(Events.ORGANIZER)) + } + } + + @Test + fun testBuildEvent_Organizer_EmailParameter() { + buildEvent(true) { + organizer = Organizer("local-id:user").apply { + parameters.add(Email("organizer@example.com")) + } + attendees += Attendee("mailto:attendee@example.com") + }.let { result -> + assertEquals("organizer@example.com", result.get(Events.ORGANIZER)) + } + } + + @Test + fun testBuildEvent_Organizer_NotEmail() { + buildEvent(true) { + organizer = Organizer("local-id:user") + attendees += Attendee("mailto:attendee@example.com") + }.let { result -> + assertNull(result.get(Events.ORGANIZER)) + } + } + + @Test + fun testBuildEvent_Status_Confirmed() { + buildEvent(true) { + status = Status.VEVENT_CONFIRMED + }.let { result -> + assertEquals(Events.STATUS_CONFIRMED, result.getAsInteger(Events.STATUS)) + } + } + + @Test + fun testBuildEvent_Status_Cancelled() { + buildEvent(true) { + status = Status.VEVENT_CANCELLED + }.let { result -> + assertEquals(Events.STATUS_CANCELED, result.getAsInteger(Events.STATUS)) + } + } + + @Test + fun testBuildEvent_Status_Tentative() { + buildEvent(true) { + status = Status.VEVENT_TENTATIVE + }.let { result -> + assertEquals(Events.STATUS_TENTATIVE, result.getAsInteger(Events.STATUS)) + } + } + + @Test + fun testBuildEvent_Status_Invalid() { + buildEvent(true) { + status = Status.VTODO_IN_PROCESS + }.let { result -> + assertEquals(Events.STATUS_TENTATIVE, result.getAsInteger(Events.STATUS)) + } + } + + @Test + fun testBuildEvent_Status_None() { + buildEvent(true) { + }.let { result -> + assertNull(result.get(Events.STATUS)) + } + } + + @Test + fun testBuildEvent_Opaque_True() { + buildEvent(true) { + opaque = true + }.let { result -> + assertEquals(Events.AVAILABILITY_BUSY, result.getAsInteger(Events.AVAILABILITY)) + } + } + + @Test + fun testBuildEvent_Opaque_False() { + buildEvent(true) { + opaque = false + }.let { result -> + assertEquals(Events.AVAILABILITY_FREE, result.getAsInteger(Events.AVAILABILITY)) + } + } + + @Test + fun testBuildEvent_Classification_Public() { + buildEvent(true) { + classification = Clazz.PUBLIC + }.let { result -> + assertEquals(Events.ACCESS_PUBLIC, result.getAsInteger(Events.ACCESS_LEVEL)) + assertNull(firstUnknownProperty(result)) + } + } + + @Test + fun testBuildEvent_Classification_Private() { + buildEvent(true) { + classification = Clazz.PRIVATE + }.let { result -> + assertEquals(Events.ACCESS_PRIVATE, result.getAsInteger(Events.ACCESS_LEVEL)) + assertNull(firstUnknownProperty(result)) + } + } + + @Test + fun testBuildEvent_Classification_Confidential() { + buildEvent(true) { + classification = Clazz.CONFIDENTIAL + }.let { result -> + assertEquals(Events.ACCESS_CONFIDENTIAL, result.getAsInteger(Events.ACCESS_LEVEL)) + assertEquals(Clazz.CONFIDENTIAL, firstUnknownProperty(result)) + } + } + + @Test + fun testBuildEvent_Classification_Custom() { + buildEvent(true) { + classification = Clazz("TOP-SECRET") + }.let { result -> + assertEquals(Events.ACCESS_PRIVATE, result.getAsInteger(Events.ACCESS_LEVEL)) + assertEquals(Clazz("TOP-SECRET"), firstUnknownProperty(result)) + } + } + + @Test + fun testBuildEvent_Classification_None() { + buildEvent(true) { + }.let { result -> + assertEquals(Events.ACCESS_DEFAULT, result.getAsInteger(Events.ACCESS_LEVEL)) + assertNull(firstUnknownProperty(result)) + } + } + + @Test + fun testBuildEvent_UID2445() { + buildEvent(true) { + uid = "event1@example.com" + }.let { result -> + assertEquals("event1@example.com", result.getAsString(Events.UID_2445)) + } + } + + + private fun firstReminder(row: ContentValues): ContentValues? { + val id = row.getAsInteger(Events._ID) + client.query(Reminders.CONTENT_URI.asSyncAdapter(testAccount), null, + "${Reminders.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.toContentValues() + } + return null + } + + @Test + fun testBuildReminder_Trigger_None() { + buildEvent(true) { + alarms += VAlarm() + }.let { result -> + firstReminder(result)!!.let { reminder -> + assertEquals(Reminders.METHOD_DEFAULT, reminder.getAsInteger(Reminders.METHOD)) + assertEquals(Reminders.MINUTES_DEFAULT, reminder.getAsInteger(Reminders.MINUTES)) + } + } + } + + @Test + fun testBuildReminder_Trigger_Type_Audio() { + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofMinutes(-10)).apply { + properties += Action.AUDIO + } + }.let { result -> + firstReminder(result)!!.let { reminder -> + assertEquals(Reminders.METHOD_ALERT, reminder.getAsInteger(Reminders.METHOD)) + assertEquals(10, reminder.getAsInteger(Reminders.MINUTES)) + } + } + } + + @Test + fun testBuildReminder_Trigger_Type_Display() { + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofMinutes(-10)).apply { + properties += Action.DISPLAY + } + }.let { result -> + firstReminder(result)!!.let { reminder -> + assertEquals(Reminders.METHOD_ALERT, reminder.getAsInteger(Reminders.METHOD)) + assertEquals(10, reminder.getAsInteger(Reminders.MINUTES)) + } + } + } + + @Test + fun testBuildReminder_Trigger_Type_Email() { + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofSeconds(-120)).apply { + properties += Action.EMAIL + } + }.let { result -> + firstReminder(result)!!.let { reminder -> + assertEquals(Reminders.METHOD_EMAIL, reminder.getAsInteger(Reminders.METHOD)) + assertEquals(2, reminder.getAsInteger(Reminders.MINUTES)) + } + } + } + + @Test + fun testBuildReminder_Trigger_Type_Custom() { + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofSeconds(-120)).apply { + properties += Action("X-CUSTOM") + } + }.let { result -> + firstReminder(result)!!.let { reminder -> + assertEquals(Reminders.METHOD_DEFAULT, reminder.getAsInteger(Reminders.METHOD)) + assertEquals(2, reminder.getAsInteger(Reminders.MINUTES)) + } + } + } + + @Test + fun testBuildReminder_Trigger_RelStart_Duration() { + buildEvent(true) { + alarms += VAlarm(Period.ofDays(-1)) + }.let { result -> + assertEquals(1440, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_RelStart_Duration_LessThanOneMinute() { + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofSeconds(-10)) + }.let { result -> + assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_RelStart_Duration_Positive() { + // positive duration -> reminder is AFTER reference time -> negative minutes field + buildEvent(true) { + alarms += VAlarm(java.time.Duration.ofMinutes(10)) + }.let { result -> + assertEquals(-10, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_RelEnd_Duration() { + buildEvent(false) { + dtStart = DtStart(DateTime("20200621T120000", tzVienna)) + dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) + alarms += VAlarm(Period.ofDays(-1)).apply { + trigger.parameters.add(Related.END) + } + }.let { result -> + assertEquals(1320, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_RelEnd_Duration_LessThanOneMinute() { + buildEvent(false) { + dtStart = DtStart(DateTime("20200621T120000", tzVienna)) + dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) + alarms += VAlarm(java.time.Duration.ofSeconds(-7240)).apply { + trigger.parameters.add(Related.END) + } + }.let { result -> + assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_RelEnd_Duration_Positive() { + // positive duration -> reminder is AFTER reference time -> negative minutes field + buildEvent(false) { + dtStart = DtStart(DateTime("20200621T120000", tzVienna)) + dtEnd = DtEnd(DateTime("20200621T140000", tzVienna)) + alarms += VAlarm(java.time.Duration.ofMinutes(10)).apply { + trigger.parameters.add(Related.END) + } + }.let { result -> + assertEquals(-130, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_Absolute() { + buildEvent(false) { + dtStart = DtStart(DateTime("20200621T120000", tzVienna)) + alarms += VAlarm(DateTime("20200621T110000", tzVienna)) + }.let { result -> + assertEquals(60, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + @Test + fun testBuildReminder_Trigger_Absolute_OtherTimeZone() { + buildEvent(false) { + dtStart = DtStart(DateTime("20200621T120000", tzVienna)) + alarms += VAlarm(DateTime("20200621T110000", tzShanghai)) + }.let { result -> + assertEquals(420, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) + } + } + + + private fun firstAttendee(row: ContentValues): ContentValues? { + val id = row.getAsInteger(Events._ID) + client.query(Attendees.CONTENT_URI.asSyncAdapter(testAccount), null, + "${Attendees.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.toContentValues() + } + return null + } + + @Test + fun testBuildAttendee_MailTo() { + buildEvent(true) { + attendees += Attendee("mailto:attendee1@example.com") + }.let { result -> + assertEquals("attendee1@example.com", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_EMAIL)) + } + } + + @Test + fun testBuildAttendee_OtherUri() { + buildEvent(true) { + attendees += Attendee("https://example.com/principals/attendee") + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals("https", attendee.getAsString(Attendees.ATTENDEE_ID_NAMESPACE)) + assertEquals("//example.com/principals/attendee", attendee.getAsString(Attendees.ATTENDEE_IDENTITY)) + } + } + } + + @Test + fun testBuildAttendee_CustomUri_EmailParam() { + buildEvent(true) { + attendees += Attendee("sample:uri").apply { + parameters.add(Email("attendee1@example.com")) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals("sample", attendee.getAsString(Attendees.ATTENDEE_ID_NAMESPACE)) + assertEquals("uri", attendee.getAsString(Attendees.ATTENDEE_IDENTITY)) + assertEquals("attendee1@example.com", attendee.getAsString(Attendees.ATTENDEE_EMAIL)) + } + } + } + + @Test + fun testBuildAttendee_Cn() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(Cn("Sample Attendee")) + } + }.let { result -> + assertEquals("Sample Attendee", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_NAME)) + } + } + + @Test + fun testBuildAttendee_Individual() { + for (cuType in arrayOf(CuType.INDIVIDUAL, null)) { + // REQ-PARTICIPANT (default, includes unknown values) + for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + if (cuType != null) + parameters.add(cuType) + if (role != null) + parameters.add(role) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + // OPT-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + if (cuType != null) + parameters.add(cuType) + parameters.add(Role.OPT_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + // NON-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + if (cuType != null) + parameters.add(cuType) + parameters.add(Role.NON_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_ATTENDEE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + } + + @Test + fun testBuildAttendee_Unknown() { + // REQ-PARTICIPANT (default, includes unknown values) + for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.UNKNOWN) + if (role != null) + parameters.add(role) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + // OPT-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.UNKNOWN) + parameters.add(Role.OPT_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + // NON-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.UNKNOWN) + parameters.add(Role.NON_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.ATTENDEE_STATUS_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + + @Test + fun testBuildAttendee_Group() { + // REQ-PARTICIPANT (default, includes unknown values) + for (role in arrayOf(Role.REQ_PARTICIPANT, Role("x-custom-role"), null)) { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.GROUP) + if (role != null) + parameters.add(role) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + // OPT-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.GROUP) + parameters.add(Role.OPT_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_OPTIONAL, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + // NON-PARTICIPANT + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.GROUP) + parameters.add(Role.NON_PARTICIPANT) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_NONE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + + @Test + fun testBuildAttendee_Resource() { + for (role in arrayOf(null, Role.REQ_PARTICIPANT, Role.OPT_PARTICIPANT, Role.NON_PARTICIPANT, Role("X-CUSTOM-ROLE"))) + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.RESOURCE) + if (role != null) + parameters.add(role) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_NONE, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + // CHAIR + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.RESOURCE) + parameters.add(Role.CHAIR) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_SPEAKER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + + @Test + fun testBuildAttendee_Chair() { + for (cuType in arrayOf(null, CuType.INDIVIDUAL, CuType.UNKNOWN, CuType.GROUP, CuType("x-custom-cutype"))) + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + if (cuType != null) + parameters.add(cuType) + parameters.add(Role.CHAIR) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_SPEAKER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + + @Test + fun testBuildAttendee_Room() { + for (role in arrayOf(null, Role.CHAIR, Role.REQ_PARTICIPANT, Role.OPT_PARTICIPANT, Role.NON_PARTICIPANT, Role("X-CUSTOM-ROLE"))) + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(CuType.ROOM) + if (role != null) + parameters.add(role) + } + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(Attendees.TYPE_RESOURCE, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_PERFORMER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + + @Test + fun testBuildAttendee_Organizer() { + buildEvent(true) { + attendees += Attendee(URI("mailto", testAccount.name, null)) + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(testAccount.name, attendee.getAsString(Attendees.ATTENDEE_EMAIL)) + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_ORGANIZER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) + } + } + } + @Test + fun testBuildAttendee_PartStat_None() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com") + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_NeedsAction() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat.NEEDS_ACTION) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_Accepted() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat.ACCEPTED) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_ACCEPTED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_Declined() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat.DECLINED) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_DECLINED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_Tentative() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat.TENTATIVE) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_TENTATIVE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_Delegated() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat.DELEGATED) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_NONE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + @Test + fun testBuildAttendee_PartStat_Custom() { + buildEvent(true) { + attendees += Attendee("mailto:attendee@example.com").apply { + parameters.add(PartStat("X-WILL-ASK")) + } + }.let { result -> + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) + } + } + + + @Test + fun testBuildUnknownProperty() { + buildEvent(true) { + val params = ParameterList() + params.add(Language("en")) + unknownProperties += XProperty("X-NAME", params, "Custom Value") + }.let { result -> + firstUnknownProperty(result)!!.let { property -> + assertEquals("X-NAME", property.name) + assertEquals("en", property.getParameter(Parameter.LANGUAGE).value) + assertEquals("Custom Value", property.value) + } + } + } + + @Test + fun testBuildUnknownProperty_NoValue() { + buildEvent(true) { + unknownProperties += XProperty("ATTACH", ParameterList(), null) + }.let { result -> + // The property should not have been added, so the first unknown property should be null + assertNull(firstUnknownProperty(result)) + } + } + + private fun firstException(values: ContentValues): ContentValues? { + val id = values.getAsInteger(Events._ID) + client.query(Events.CONTENT_URI.asSyncAdapter(testAccount), null, + "${Events.ORIGINAL_ID}=?", arrayOf(id.toString()), null)?.use { cursor -> + if (cursor.moveToNext()) + return cursor.toContentValues() + } + return null + } + + @Test + fun testBuildException_NonAllDay() { + buildEvent(false) { + dtStart = DtStart("20200706T193000", tzVienna) + rRules += RRule("FREQ=DAILY;COUNT=10") + exceptions += Event().apply { + recurrenceId = RecurrenceId("20200707T193000", tzVienna) + dtStart = DtStart("20200706T203000", tzShanghai) + summary = "Event moved to one hour later" + } + }.let { result -> + assertEquals(1594056600000L, result.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, result.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(0, result.getAsInteger(Events.ALL_DAY)) + assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Events.RRULE)) + firstException(result)!!.let { exception -> + assertEquals(1594143000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) + assertEquals(0, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) + assertEquals(1594038600000L, exception.getAsLong(Events.DTSTART)) + assertEquals(tzShanghai.id, exception.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) + assertEquals("Event moved to one hour later", exception.getAsString(Events.TITLE)) + } + } + } + + @Test + fun testBuildException_NonAllDay_RecurrenceIdAllDay() { + buildEvent(false) { + dtStart = DtStart("20200706T193000", tzVienna) + rRules += RRule("FREQ=DAILY;COUNT=10") + exceptions += Event().apply { + recurrenceId = RecurrenceId(Date("20200707")) // illegal! should be rewritten to DateTime("20200707T193000", tzVienna) + dtStart = DtStart("20200706T203000", tzShanghai) + summary = "Event moved to one hour later" + } + }.let { result -> + assertEquals(1594056600000L, result.getAsLong(Events.DTSTART)) + assertEquals(tzVienna.id, result.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(0, result.getAsInteger(Events.ALL_DAY)) + assertEquals("FREQ=DAILY;COUNT=10", result.getAsString(Events.RRULE)) + firstException(result)!!.let { exception -> + assertEquals(1594143000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) + assertEquals(0, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) + assertEquals(1594038600000L, exception.getAsLong(Events.DTSTART)) + assertEquals(tzShanghai.id, exception.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) + assertEquals("Event moved to one hour later", exception.getAsString(Events.TITLE)) + } + } + } + + @Test + fun testBuildException_AllDay() { + buildEvent(false) { + dtStart = DtStart(Date("20200706")) + rRules += RRule("FREQ=WEEKLY;COUNT=3") + exceptions += Event().apply { + recurrenceId = RecurrenceId(Date("20200707")) + dtStart = DtStart("20200706T123000", tzVienna) + summary = "Today not an all-day event" + } + }.let { result -> + assertEquals(1593993600000L, result.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, result.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(1, result.getAsInteger(Events.ALL_DAY)) + assertEquals("FREQ=WEEKLY;COUNT=3", result.getAsString(Events.RRULE)) + firstException(result)!!.let { exception -> + assertEquals(1594080000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) + assertEquals(1, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) + assertEquals(1594031400000L, exception.getAsLong(Events.DTSTART)) + assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) + assertEquals("Today not an all-day event", exception.getAsString(Events.TITLE)) + } + } + } + + @Test + fun testBuildException_AllDay_RecurrenceIdNonAllDay() { + buildEvent(false) { + dtStart = DtStart(Date("20200706")) + rRules += RRule("FREQ=WEEKLY;COUNT=3") + exceptions += Event().apply { + recurrenceId = RecurrenceId("20200707T000000", tzVienna) // illegal! should be rewritten to Date("20200707") + dtStart = DtStart("20200706T123000", tzVienna) + summary = "Today not an all-day event" + } + }.let { result -> + assertEquals(1593993600000L, result.getAsLong(Events.DTSTART)) + assertEquals(AndroidTimeUtils.TZID_ALLDAY, result.getAsString(Events.EVENT_TIMEZONE)) + assertEquals(1, result.getAsInteger(Events.ALL_DAY)) + assertEquals("FREQ=WEEKLY;COUNT=3", result.getAsString(Events.RRULE)) + firstException(result)!!.let { exception -> + assertEquals(1594080000000L, exception.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) + assertEquals(1, exception.getAsInteger(Events.ORIGINAL_ALL_DAY)) + assertEquals(1594031400000L, exception.getAsLong(Events.DTSTART)) + assertEquals(0, exception.getAsInteger(Events.ALL_DAY)) + assertEquals("Today not an all-day event", exception.getAsString(Events.TITLE)) + } + } + } + +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessorTest.kt new file mode 100644 index 00000000..06d3ffbc --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessorTest.kt @@ -0,0 +1,1022 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.net.Uri +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.AUTHORITY +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.ical4android.impl.TestCalendar +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider +import at.bitfire.synctools.test.InitCalendarProviderRule +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.Language +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.XProperty +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import java.net.URI +import java.time.Duration + +class AndroidEventProcessorTest { + + @get:Rule + val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize() + + private val testAccount = Account("${javaClass.name}@example.com", ACCOUNT_TYPE_LOCAL) + private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! + private val tzShanghai = tzRegistry.getTimeZone("Asia/Shanghai")!! + + private lateinit var calendarUri: Uri + private lateinit var calendar: AndroidCalendar + lateinit var client: ContentProviderClient + + @Before + fun prepare() { + val context = getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(AUTHORITY)!! + + calendar = TestCalendar.findOrCreate(testAccount, client) + assertNotNull(calendar) + calendarUri = ContentUris.withAppendedId(Calendars.CONTENT_URI, calendar.id) + } + + @After + fun shutdown() { + client.closeCompat() + calendar.delete() + } + + + private fun populateAndroidEvent( + automaticDates: Boolean, + destinationCalendar: AndroidCalendar = calendar, + asSyncAdapter: Boolean = false, + insertCallback: (id: Long) -> Unit = {}, + extendedProperties: Map = emptyMap(), + valuesBuilder: ContentValues.() -> Unit = {} + ): AndroidEvent { + val values = ContentValues() + values.put(Events.CALENDAR_ID, destinationCalendar.id) + if (automaticDates) { + values.put(Events.DTSTART, 1592733600000L) // 21/06/2020 12:00 +0200 + values.put(Events.EVENT_TIMEZONE, "Europe/Berlin") + values.put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 + values.put(Events.EVENT_END_TIMEZONE, "Europe/Berlin") + } + valuesBuilder(values) + val uri = client.insert( + if (asSyncAdapter) + Events.CONTENT_URI.asSyncAdapter(testAccount) + else + Events.CONTENT_URI, + values)!! + val id = ContentUris.parseId(uri) + + // insert additional rows etc. + insertCallback(id) + + // insert extended properties + for ((name, value) in extendedProperties) { + val extendedValues = contentValuesOf( + ExtendedProperties.EVENT_ID to id, + ExtendedProperties.NAME to name, + ExtendedProperties.VALUE to value + ) + client.insert(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), extendedValues) + } + + return destinationCalendar.getEvent(id)!! + } + + private fun populateEvent( + automaticDates: Boolean, + destinationCalendar: AndroidCalendar = calendar, + asSyncAdapter: Boolean = false, + insertCallback: (id: Long) -> Unit = {}, + extendedProperties: Map = emptyMap(), + valuesBuilder: ContentValues.() -> Unit = {} + ): Event { + return populateAndroidEvent( + automaticDates, + destinationCalendar, + asSyncAdapter, + insertCallback, + extendedProperties, + valuesBuilder + ).event!! + } + + @Test + fun testPopulateEvent_Uid_iCalUid() { + populateEvent( + true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + ) + ).let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445() { + populateEvent(true) { + put(Events.UID_2445, "event1@example.com") + }.let { result -> + assertEquals("event1@example.com", result.uid) + } + } + + @Test + fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { + populateEvent( + true, + extendedProperties = mapOf( + AndroidEvent.EXTNAME_ICAL_UID to "event1@example.com" + ) + ) { + put(Events.UID_2445, "event2@example.com") + }.let { result -> + assertEquals("event2@example.com", result.uid) + } + } + + + @Test + fun testPopulateEvent_Sequence_Int() { + populateEvent(true, asSyncAdapter = true) { + put(AndroidEvent.COLUMN_SEQUENCE, 5) + }.let { result -> + assertEquals(5, result.sequence) + } + } + + @Test + fun testPopulateEvent_Sequence_Null() { + populateEvent(true, asSyncAdapter = true) { + putNull(AndroidEvent.COLUMN_SEQUENCE) + }.let { result -> + assertNull(result.sequence) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_False() { + populateEvent(true, asSyncAdapter = true) { + put(Events.IS_ORGANIZER, "0") + }.let { result -> + assertFalse(result.isOrganizer!!) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_Null() { + populateEvent(true, asSyncAdapter = true) { + putNull(Events.IS_ORGANIZER) + }.let { result -> + assertNull(result.isOrganizer) + } + } + + @Test + fun testPopulateEvent_IsOrganizer_True() { + populateEvent(true, asSyncAdapter = true) { + put(Events.IS_ORGANIZER, "1") + }.let { result -> + assertTrue(result.isOrganizer!!) + } + } + + @Test + fun testPopulateEvent_NonAllDay_NonRecurring() { + populateEvent(false) { + put(Events.DTSTART, 1592733600000L) // 21/06/2020 12:00 +0200 + put(Events.EVENT_TIMEZONE, "Europe/Vienna") + put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 + put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") + }.let { result -> + assertEquals(DtStart(DateTime("20200621T120000", tzVienna)), result.dtStart) + assertEquals(DtEnd(DateTime("20200621T143000", tzVienna)), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_NonAllDay_NonRecurring_MixedZones() { + populateEvent(false) { + put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 + put(Events.EVENT_TIMEZONE, "Asia/Shanghai") + put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 + put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") + }.let { result -> + assertEquals(DtStart(DateTime("20200621T180000", tzShanghai)), result.dtStart) + assertEquals(DtEnd(DateTime("20200621T143000", tzVienna)), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_NonAllDay_NonRecurring_Duration() { + /* This should not happen, because according to the documentation, non-recurring events MUST + have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ + populateEvent(false, asSyncAdapter = false) { + put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 + put(Events.EVENT_TIMEZONE, "Asia/Shanghai") + put(Events.DURATION, "PT1H") + }.let { result -> + assertEquals(DtStart(DateTime("20200621T180000", tzShanghai)), result.dtStart) + assertEquals(DtEnd(DateTime("20200621T190000", tzShanghai)), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_NonAllDay_Recurring_Duration_KievTimeZone() { + populateEvent(false) { + put(Events.DTSTART, 1592733600000L) // 21/06/2020 18:00 +0800 + put(Events.EVENT_TIMEZONE, "Europe/Kiev") + put(Events.DURATION, "PT1H") + put(Events.RRULE, "FREQ=DAILY;COUNT=2") + }.let { result -> + assertEquals(1592733600000L, result.dtStart?.date?.time) + assertEquals(1592733600000L + 3600000, result.dtEnd?.date?.time) + assertEquals("Europe/Kiev", result.dtStart?.timeZone?.id) + assertEquals("Europe/Kiev", result.dtEnd?.timeZone?.id) + } + } + + @Test + fun testPopulateEvent_NonAllDay_NonRecurring_NoTime() { + populateEvent(false) { + put(Events.DTSTART, 1592742600000L) // 21/06/2020 14:30 +0200 + put(Events.EVENT_TIMEZONE, "Europe/Vienna") + put(Events.DTEND, 1592742600000L) // 21/06/2020 14:30 +0200 + put(Events.EVENT_END_TIMEZONE, "Europe/Vienna") + }.let { result -> + assertEquals(DtStart(DateTime("20200621T143000", tzVienna)), result.dtStart) + //assertNull(result.dtEnd) + assertEquals(result.dtEnd!!.date, result.dtStart!!.date) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_AllDay_NonRecurring_NoTime() { + populateEvent(false) { + put(Events.ALL_DAY, 1) + put(Events.DTSTART, 1592697600000L) // 21/06/2020 + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.DTEND, 1592697600000L) // 21/06/2020 + put(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + }.let { result -> + assertEquals(DtStart(Date("20200621")), result.dtStart) + assertNull(result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_AllDay_NonRecurring_1Day() { + populateEvent(false) { + put(Events.ALL_DAY, 1) + put(Events.DTSTART, 1592697600000L) // 21/06/2020 + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.DTEND, 1592784000000L) // 22/06/2020 + put(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + }.let { result -> + assertEquals(DtStart(Date("20200621")), result.dtStart) + assertEquals(DtEnd(Date("20200622")), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_AllDay_NonRecurring_AllDayDuration() { + /* This should not happen, because according to the documentation, non-recurring events MUST + have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ + populateEvent(false, asSyncAdapter = false) { + put(Events.ALL_DAY, 1) + put(Events.DTSTART, 1592697600000L) // 21/06/2020 + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.DURATION, "P1W") + }.let { result -> + assertEquals(DtStart(Date("20200621")), result.dtStart) + assertEquals(DtEnd(Date("20200628")), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_AllDay_NonRecurring_NonAllDayDuration_LessThanOneDay() { + /* This should not happen, because according to the documentation, non-recurring events MUST + have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ + populateEvent(false, asSyncAdapter = false) { + put(Events.ALL_DAY, 1) + put(Events.DTSTART, 1592697600000L) // 21/06/2020 + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.DURATION, "PT1H30M") + }.let { result -> + assertEquals(DtStart(Date("20200621")), result.dtStart) + assertNull(result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_AllDay_NonRecurring_NonAllDayDuration_MoreThanOneDay() { + /* This should not happen, because according to the documentation, non-recurring events MUST + have a dtEnd. However, the calendar provider doesn't enforce this for non-sync-adapters. */ + populateEvent(false, asSyncAdapter = false) { + put(Events.ALL_DAY, 1) + put(Events.DTSTART, 1592697600000L) // 21/06/2020 + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.DURATION, "PT49H2M") + }.let { result -> + assertEquals(DtStart(Date("20200621")), result.dtStart) + assertEquals(DtEnd(Date("20200623")), result.dtEnd) + assertNull(result.duration) + } + } + + @Test + fun testPopulateEvent_Summary() { + populateEvent(true) { + put(Events.TITLE, "Sample Title") + }.let { result -> + assertEquals("Sample Title", result.summary) + } + } + + @Test + fun testPopulateEvent_Location() { + populateEvent(true) { + put(Events.EVENT_LOCATION, "Sample Location") + }.let { result -> + assertEquals("Sample Location", result.location) + } + } + + @Test + fun testPopulateEvent_Url() { + populateEvent(true, + extendedProperties = mapOf(AndroidEvent.EXTNAME_URL to "https://example.com") + ).let { result -> + assertEquals(URI("https://example.com"), result.url) + } + } + + @Test + fun testPopulateEvent_Description() { + populateEvent(true) { + put(Events.DESCRIPTION, "Sample Description") + }.let { result -> + assertEquals("Sample Description", result.description) + } + } + + @Test + fun testPopulateEvent_Color_FromIndex() { + val provider = AndroidCalendarProvider(testAccount, client) + provider.provideCss3ColorIndices() + populateEvent(true) { + put(Events.EVENT_COLOR_KEY, Css3Color.silver.name) + }.let { result -> + assertEquals(Css3Color.silver, result.color) + } + } + + @Test + fun testPopulateEvent_Color_FromValue() { + populateEvent(true) { + put(Events.EVENT_COLOR, Css3Color.silver.argb) + }.let { result -> + assertEquals(Css3Color.silver, result.color) + } + } + + @Test + fun testPopulateEvent_Status_Confirmed() { + populateEvent(true) { + put(Events.STATUS, Events.STATUS_CONFIRMED) + }.let { result -> + assertEquals(Status.VEVENT_CONFIRMED, result.status) + } + } + + @Test + fun testPopulateEvent_Status_Tentative() { + populateEvent(true) { + put(Events.STATUS, Events.STATUS_TENTATIVE) + }.let { result -> + assertEquals(Status.VEVENT_TENTATIVE, result.status) + } + } + + @Test + fun testPopulateEvent_Status_Cancelled() { + populateEvent(true) { + put(Events.STATUS, Events.STATUS_CANCELED) + }.let { result -> + assertEquals(Status.VEVENT_CANCELLED, result.status) + } + } + + @Test + fun testPopulateEvent_Status_None() { + assertNull(populateEvent(true).status) + } + + @Test + fun testPopulateEvent_Availability_Busy() { + populateEvent(true) { + put(Events.AVAILABILITY, Events.AVAILABILITY_BUSY) + }.let { result -> + assertTrue(result.opaque) + } + } + + @Test + fun testPopulateEvent_Availability_Tentative() { + populateEvent(true) { + put(Events.AVAILABILITY, Events.AVAILABILITY_TENTATIVE) + }.let { result -> + assertTrue(result.opaque) + } + } + + @Test + fun testPopulateEvent_Availability_Free() { + populateEvent(true) { + put(Events.AVAILABILITY, Events.AVAILABILITY_FREE) + }.let { result -> + assertFalse(result.opaque) + } + } + + @Test + fun testPopulateEvent_Organizer_NotGroupScheduled() { + assertNull(populateEvent(true).organizer) + } + + @Test + fun testPopulateEvent_Organizer_NotGroupScheduled_ExplicitOrganizer() { + populateEvent(true) { + put(Events.ORGANIZER, "sample@example.com") + }.let { result -> + assertNull(result.organizer) + } + } + + @Test + fun testPopulateEvent_Organizer_GroupScheduled() { + populateEvent(true, insertCallback = { id -> + client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { + put(Attendees.EVENT_ID, id) + put(Attendees.ATTENDEE_EMAIL, "organizer@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.RELATIONSHIP_ORGANIZER) + }) + }) { + put(Events.ORGANIZER, "organizer@example.com") + }.let { result -> + assertEquals("mailto:organizer@example.com", result.organizer?.value) + } + } + + @Test + fun testPopulateEvent_Classification_Public() { + populateEvent(true) { + put(Events.ACCESS_LEVEL, Events.ACCESS_PUBLIC) + }.let { result -> + assertEquals(Clazz.PUBLIC, result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_Private() { + populateEvent(true) { + put(Events.ACCESS_LEVEL, Events.ACCESS_PRIVATE) + }.let { result -> + assertEquals(Clazz.PRIVATE, result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_Confidential() { + populateEvent(true) { + put(Events.ACCESS_LEVEL, Events.ACCESS_CONFIDENTIAL) + }.let { result -> + assertEquals(Clazz.CONFIDENTIAL, result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_Confidential_Retained() { + populateEvent(true, + extendedProperties = mapOf(UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz.CONFIDENTIAL)) + ) { + put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) + }.let { result -> + assertEquals(Clazz.CONFIDENTIAL, result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_Default() { + populateEvent(true) { + put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) + }.let { result -> + assertNull(result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_Custom() { + populateEvent( + true, + valuesBuilder = { + put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) + }, + extendedProperties = mapOf( + UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz("TOP-SECRET")) + ) + ).let { result -> + assertEquals(Clazz("TOP-SECRET"), result.classification) + } + } + + @Test + fun testPopulateEvent_Classification_None() { + populateEvent(true) { + }.let { result -> + assertNull(result.classification) + } + } + + + private fun populateReminder(destinationCalendar: AndroidCalendar = calendar, builder: ContentValues.() -> Unit): VAlarm? { + populateEvent(true, destinationCalendar = destinationCalendar, insertCallback = { id -> + val reminderValues = ContentValues() + reminderValues.put(Reminders.EVENT_ID, id) + builder(reminderValues) + client.insert(Reminders.CONTENT_URI.asSyncAdapter(testAccount), reminderValues) + }).let { result -> + return result.alarms.firstOrNull() + } + } + + @Test + fun testPopulateReminder_TypeEmail_AccountNameEmail() { + // account name looks like an email address + assumeTrue(testAccount.name.endsWith("@example.com")) + + populateReminder { + put(Reminders.METHOD, Reminders.METHOD_EMAIL) + put(Reminders.MINUTES, 10) + }!!.let { alarm -> + assertEquals(Action.EMAIL, alarm.action) + assertNotNull(alarm.summary) + assertNotNull(alarm.description) + } + } + + @Test + fun testPopulateReminder_TypeEmail_AccountNameNotEmail() { + // test account name that doesn't look like an email address + val nonEmailAccount = Account("ical4android", ACCOUNT_TYPE_LOCAL) + val testCalendar = TestCalendar.findOrCreate(nonEmailAccount, client) + try { + populateReminder(testCalendar) { + put(Reminders.METHOD, Reminders.METHOD_EMAIL) + }!!.let { alarm -> + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + } finally { + testCalendar.delete() + } + } + + @Test + fun testPopulateReminder_TypeNotEmail() { + for (type in arrayOf(null, Reminders.METHOD_ALARM, Reminders.METHOD_ALERT, Reminders.METHOD_DEFAULT, Reminders.METHOD_SMS)) + populateReminder { + put(Reminders.METHOD, type) + put(Reminders.MINUTES, 10) + }!!.let { alarm -> + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + } + + @Test + fun testPopulateReminder_Minutes_Positive() { + populateReminder { + put(Reminders.METHOD, Reminders.METHOD_ALERT) + put(Reminders.MINUTES, 10) + }!!.let { alarm -> + assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) + } + } + + @Test + fun testPopulateReminder_Minutes_Negative() { + populateReminder { + put(Reminders.METHOD, Reminders.METHOD_ALERT) + put(Reminders.MINUTES, -10) + }!!.let { alarm -> + assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) + } + } + + + private fun populateAttendee(builder: ContentValues.() -> Unit): Attendee? { + populateEvent(true, insertCallback = { id -> + val attendeeValues = ContentValues() + attendeeValues.put(Attendees.EVENT_ID, id) + builder(attendeeValues) + client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), attendeeValues) + }).let { result -> + return result.attendees.firstOrNull() + } + } + + @Test + fun testPopulateAttendee_Email() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + }!!.let { attendee -> + assertEquals(URI("mailto:attendee@example.com"), attendee.calAddress) + } + } + + @Test + fun testPopulateAttendee_OtherUri() { + populateAttendee { + put(Attendees.ATTENDEE_ID_NAMESPACE, "https") + put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") + }!!.let { attendee -> + assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) + } + } + + @Test + fun testPopulateAttendee_EmailAndOtherUri() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_ID_NAMESPACE, "https") + put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") + }!!.let { attendee -> + assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) + assertEquals("attendee@example.com", attendee.getParameter(Parameter.EMAIL).value) + } + } + + @Test + fun testPopulateAttendee_AttendeeOrganizer() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + if (type != null) + put(Attendees.ATTENDEE_TYPE, type as Int?) + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun testPopulateAttendee_Performer() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) + if (type != null) + put(Attendees.ATTENDEE_TYPE, type as Int?) + }!!.let { attendee -> + assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun testPopulateAttendee_Speaker() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) + if (type != null) + put(Attendees.ATTENDEE_TYPE, type as Int?) + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + // TYPE_RESOURCE + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) + }!!.let { attendee -> + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun testPopulateAttendee_RelNone() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_NONE, null)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + if (type != null) + put(Attendees.ATTENDEE_TYPE, type as Int?) + }!!.let { attendee -> + assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun testPopulateAttendee_TypeNone() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) + if (relationship != null) + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun testPopulateAttendee_Required() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) + if (relationship != null) + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun testPopulateAttendee_Optional() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) + if (relationship != null) + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + }!!.let { attendee -> + assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun testPopulateAttendee_Resource() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_NONE, null)) + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) + if (relationship != null) + put(Attendees.ATTENDEE_RELATIONSHIP, relationship) + }!!.let { attendee -> + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + } + // RELATIONSHIP_PERFORMER + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) + put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) + }!!.let { attendee -> + assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun testPopulateAttendee_Status_Null() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Status_Invited() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_INVITED) + }!!.let { attendee -> + assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Status_Accepted() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED) + }!!.let { attendee -> + assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Status_Declined() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_DECLINED) + }!!.let { attendee -> + assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Status_Tentative() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_TENTATIVE) + }!!.let { attendee -> + assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Status_None() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_NONE) + }!!.let { attendee -> + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + } + + @Test + fun testPopulateAttendee_Rsvp() { + populateAttendee { + put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") + }!!.let { attendee -> + assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) + } + } + + @Test + fun testPopulateUnknownProperty() { + val params = ParameterList() + params.add(Language("en")) + val unknownProperty = XProperty("X-NAME", params, "Custom Value") + val (result) = populateEvent( + true, + extendedProperties = mapOf( + UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(unknownProperty) + ) + ).unknownProperties + assertEquals("X-NAME", result.name) + assertEquals("en", result.getParameter(Parameter.LANGUAGE).value) + assertEquals("Custom Value", result.value) + } + + + private fun populateException(mainBuilder: ContentValues.() -> Unit, exceptionBuilder: ContentValues.() -> Unit) = + populateEvent(false, asSyncAdapter = true, valuesBuilder = mainBuilder, insertCallback = { id -> + val exceptionValues = ContentValues() + exceptionValues.put(Events.CALENDAR_ID, calendar.id) + exceptionBuilder(exceptionValues) + client.insert(Events.CONTENT_URI.asSyncAdapter(testAccount), exceptionValues) + }) + + @Test + fun testPopulateException_NonAllDay() { + populateException({ + put(Events._SYNC_ID, "testPopulateException_NonAllDay") + put(Events.TITLE, "Recurring non-all-day event with exception") + put(Events.DTSTART, 1594056600000L) + put(Events.EVENT_TIMEZONE, tzVienna.id) + put(Events.ALL_DAY, 0) + put(Events.RRULE, "FREQ=DAILY;COUNT=10") + }, { + put(Events.ORIGINAL_SYNC_ID, "testPopulateException_NonAllDay") + put(Events.ORIGINAL_INSTANCE_TIME, 1594143000000L) + put(Events.ORIGINAL_ALL_DAY, 0) + put(Events.DTSTART, 1594038600000L) + put(Events.EVENT_TIMEZONE, tzShanghai.id) + put(Events.ALL_DAY, 0) + put(Events.TITLE, "Event moved to one hour later") + }).let { event -> + assertEquals("Recurring non-all-day event with exception", event.summary) + assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) + assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) + val exception = event.exceptions.first() + assertEquals(RecurrenceId("20200708T013000", tzShanghai), exception.recurrenceId) + assertEquals(DtStart("20200706T203000", tzShanghai), exception.dtStart) + assertEquals("Event moved to one hour later", exception.summary) + } + } + + @Test + fun testPopulateException_AllDay() { + populateException({ + put(Events._SYNC_ID, "testPopulateException_AllDay") + put(Events.TITLE, "Recurring all-day event with exception") + put(Events.DTSTART, 1593993600000L) + put(Events.EVENT_TIMEZONE, AndroidTimeUtils.TZID_ALLDAY) + put(Events.ALL_DAY, 1) + put(Events.RRULE, "FREQ=WEEKLY;COUNT=3") + }, { + put(Events.ORIGINAL_SYNC_ID, "testPopulateException_AllDay") + put(Events.ORIGINAL_INSTANCE_TIME, 1594080000000L) + put(Events.ORIGINAL_ALL_DAY, 1) + put(Events.DTSTART, 1594031400000L) + put(Events.ALL_DAY, 0) + put(Events.EVENT_TIMEZONE, tzShanghai.id) + put(Events.TITLE, "Today not an all-day event") + }).let { event -> + assertEquals("Recurring all-day event with exception", event.summary) + assertEquals(DtStart(Date("20200706")), event.dtStart) + assertEquals("FREQ=WEEKLY;COUNT=3", event.rRules.first().value) + val exception = event.exceptions.first() + assertEquals(RecurrenceId(Date("20200707")), exception.recurrenceId) + assertEquals(DtStart("20200706T183000", tzShanghai), exception.dtStart) + assertEquals("Today not an all-day event", exception.summary) + } + } + + @Test + fun testPopulateException_Exdate() { + populateException({ + put(Events._SYNC_ID, "testPopulateException_AllDay") + put(Events.TITLE, "Recurring all-day event with cancelled exception") + put(Events.DTSTART, 1594056600000L) + put(Events.EVENT_TIMEZONE, tzVienna.id) + put(Events.ALL_DAY, 0) + put(Events.RRULE, "FREQ=DAILY;COUNT=10") + }, { + put(Events.ORIGINAL_SYNC_ID, "testPopulateException_AllDay") + put(Events.ORIGINAL_INSTANCE_TIME, 1594143000000L) + put(Events.ORIGINAL_ALL_DAY, 0) + put(Events.DTSTART, 1594143000000L) + put(Events.ALL_DAY, 0) + put(Events.EVENT_TIMEZONE, tzShanghai.id) + put(Events.STATUS, Events.STATUS_CANCELED) + }).let { event -> + assertEquals("Recurring all-day event with cancelled exception", event.summary) + assertEquals(DtStart("20200706T193000", tzVienna), event.dtStart) + assertEquals("FREQ=DAILY;COUNT=10", event.rRules.first().value) + assertEquals(DateTime("20200708T013000", tzShanghai), event.exDates.first().dates.first()) + assertTrue(event.exceptions.isEmpty()) + } + } + +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappingsTest.kt similarity index 50% rename from lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt rename to lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappingsTest.kt index cc07ab86..ad1e9ec7 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AttendeeMappingsTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappingsTest.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.mapping.calendar import android.content.ContentValues import android.net.Uri @@ -34,8 +34,12 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertNull( + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter(Parameter.ROLE) + ) } } @@ -45,8 +49,12 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertNull( + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter(Parameter.ROLE) + ) } } @@ -56,8 +64,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) }) { - assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.GROUP, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -67,8 +82,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertEquals( + Role.CHAIR, + getParameter(Parameter.ROLE) + ) } } @@ -78,8 +100,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) }) { - assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.UNKNOWN, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -90,8 +119,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertEquals( + Role.OPT_PARTICIPANT, + getParameter(Parameter.ROLE) + ) } } @@ -101,8 +137,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertEquals( + Role.OPT_PARTICIPANT, + getParameter(Parameter.ROLE) + ) } } @@ -112,8 +155,14 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) }) { - assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) - assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) + assertEquals( + CuType.GROUP, + getParameter(Parameter.CUTYPE) + ) + assertEquals( + Role.OPT_PARTICIPANT, + getParameter(Parameter.ROLE) + ) } } @@ -123,8 +172,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertEquals( + Role.CHAIR, + getParameter(Parameter.ROLE) + ) } } @@ -134,8 +190,14 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) }) { - assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) - assertEquals(Role.OPT_PARTICIPANT, getParameter(Parameter.ROLE)) + assertEquals( + CuType.UNKNOWN, + getParameter(Parameter.CUTYPE) + ) + assertEquals( + Role.OPT_PARTICIPANT, + getParameter(Parameter.ROLE) + ) } } @@ -146,8 +208,16 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -157,8 +227,16 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -168,8 +246,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) }) { - assertEquals(CuType.GROUP, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.GROUP, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -179,8 +264,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) }) { - assertNull(getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) + assertNull( + getParameter( + Parameter.CUTYPE + ) + ) + assertEquals( + Role.CHAIR, + getParameter(Parameter.ROLE) + ) } } @@ -190,8 +282,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) }) { - assertEquals(CuType.UNKNOWN, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.UNKNOWN, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -202,8 +301,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE) }) { - assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.RESOURCE, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -213,8 +319,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ORGANIZER) }) { - assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.RESOURCE, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -224,8 +337,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) }) { - assertEquals(CuType.ROOM, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.ROOM, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -235,8 +355,14 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) }) { - assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, getParameter(Parameter.ROLE)) + assertEquals( + CuType.RESOURCE, + getParameter(Parameter.CUTYPE) + ) + assertEquals( + Role.CHAIR, + getParameter(Parameter.ROLE) + ) } } @@ -246,8 +372,15 @@ class AttendeeMappingsTest { put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_NONE) }) { - assertEquals(CuType.RESOURCE, getParameter(Parameter.CUTYPE)) - assertNull(getParameter(Parameter.ROLE)) + assertEquals( + CuType.RESOURCE, + getParameter(Parameter.CUTYPE) + ) + assertNull( + getParameter( + Parameter.ROLE + ) + ) } } @@ -256,361 +389,625 @@ class AttendeeMappingsTest { @Test fun testICalendarToAndroid_CuTypeNone_RoleNone() { testICalendarToAndroid(Attendee("mailto:attendee@example.com")) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeNone_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeNone_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeNone_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeNone_RoleNonParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeNone_RoleXValue() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleNone() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleNonParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeIndividual_RoleXValue() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.INDIVIDUAL) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_ATTENDEE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_ATTENDEE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleNone() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleNonParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeUnknown_RoleXValue() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.UNKNOWN) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleNone() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_OPTIONAL, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_OPTIONAL, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleNonParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_NONE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_NONE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeGroup_RoleXValue() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.GROUP) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_REQUIRED, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_REQUIRED, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleNone() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_SPEAKER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_SPEAKER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) parameters.add(Role.OPT_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleNonParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) parameters.add(Role.NON_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeResource_RoleXValue() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.RESOURCE) parameters.add(RoleFancy) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_NONE, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_NONE, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeRoom_RoleNone() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.ROOM) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeRoom_RoleChair() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.ROOM) parameters.add(Role.CHAIR) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeRoom_RoleReqParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.ROOM) parameters.add(Role.REQ_PARTICIPANT) }) { - assertEquals(Attendees.TYPE_RESOURCE, values[Attendees.ATTENDEE_TYPE]) - assertEquals(Attendees.RELATIONSHIP_PERFORMER, values[Attendees.ATTENDEE_RELATIONSHIP]) + assertEquals( + Attendees.TYPE_RESOURCE, + values[Attendees.ATTENDEE_TYPE] + ) + assertEquals( + Attendees.RELATIONSHIP_PERFORMER, + values[Attendees.ATTENDEE_RELATIONSHIP] + ) } } @Test fun testICalendarToAndroid_CuTypeRoom_RoleOptParticipant() { - testICalendarToAndroid(Attendee("mailto:attendee@example.com").apply { + testICalendarToAndroid( + Attendee("mailto:attendee@example.com") + .apply { parameters.add(CuType.ROOM) parameters.add(Role.OPT_PARTICIPANT) }) { diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index 49718259..baca56d3 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -16,71 +16,21 @@ import android.net.Uri import android.os.RemoteException import android.provider.CalendarContract import android.provider.CalendarContract.Attendees -import android.provider.CalendarContract.Colors import android.provider.CalendarContract.Events import android.provider.CalendarContract.EventsEntity import android.provider.CalendarContract.ExtendedProperties import android.provider.CalendarContract.Reminders -import android.util.Patterns import androidx.core.content.contentValuesOf import at.bitfire.ical4android.AndroidEvent.Companion.CATEGORIES_SEPARATOR import at.bitfire.ical4android.AndroidEvent.Companion.numInstances -import at.bitfire.ical4android.util.AndroidTimeUtils -import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter -import at.bitfire.ical4android.util.TimeApiExtensions -import at.bitfire.ical4android.util.TimeApiExtensions.requireZoneId -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate -import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime -import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration -import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime -import at.bitfire.synctools.exception.InvalidLocalResourceException -import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder +import at.bitfire.synctools.mapping.calendar.AndroidEventProcessor import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.CalendarBatchOperation -import at.bitfire.synctools.storage.removeBlank -import at.bitfire.synctools.storage.toContentValues -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Cn -import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.parameter.PartStat -import net.fortuna.ical4j.model.parameter.Rsvp -import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Attendee -import net.fortuna.ical4j.model.property.Clazz -import net.fortuna.ical4j.model.property.Description -import net.fortuna.ical4j.model.property.DtEnd -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.ExDate -import net.fortuna.ical4j.model.property.ExRule -import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.RDate -import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.model.property.RecurrenceId -import net.fortuna.ical4j.model.property.Status -import net.fortuna.ical4j.model.property.Summary -import net.fortuna.ical4j.util.TimeZones import java.io.FileNotFoundException -import java.net.URI -import java.net.URISyntaxException -import java.time.Duration -import java.time.Instant -import java.time.Period -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.util.Locale -import java.util.logging.Level import java.util.logging.Logger /** @@ -169,28 +119,13 @@ class AndroidEvent( ) if (iterEvents.hasNext()) { - val e = iterEvents.next() + val entity = iterEvents.next() + return Event().also { newEvent -> + val processor = AndroidEventProcessor(calendar, id, entity) + processor.populate(to = newEvent) - // create new Event which will be populated - val newEvent = Event() - _event = newEvent - - // calculate some scheduling properties - val groupScheduled = e.subValues.any { it.uri == Attendees.CONTENT_URI } - - populateEvent(e.entityValues.removeBlank(), groupScheduled) - - for (subValue in e.subValues) { - val subValues = subValue.values.removeBlank() - when (subValue.uri) { - Attendees.CONTENT_URI -> populateAttendee(subValues) - Reminders.CONTENT_URI -> populateReminder(subValues) - ExtendedProperties.CONTENT_URI -> populateExtended(subValues) - } + _event = newEvent } - populateExceptions() - useRetainedClassification() - return newEvent } } catch (e: Exception) { /* Populating event has been interrupted by an exception, so we reset the event to @@ -201,370 +136,12 @@ class AndroidEvent( } finally { iterEvents?.close() } - throw FileNotFoundException("Couldn't find event $id") - } - - /** - * Reads event data from the calendar provider. - * - * @param row values of an [Events] row, as returned by the calendar provider - */ - private fun populateEvent(row: ContentValues, groupScheduled: Boolean) { - logger.log(Level.FINE, "Read event entity from calender provider", row) - val event = requireNotNull(event) - - val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } - - row.getAsString(Events.MUTATORS)?.let { strPackages -> - val packages = strPackages.split(MUTATORS_SEPARATOR).toSet() - event.userAgents.addAll(packages) - } - - val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 - val tsStart = row.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART") - - var tsEnd = row.getAsLong(Events.DTEND) - var duration = // only use DURATION of DTEND is not defined - if (tsEnd == null) - row.getAsString(Events.DURATION)?.let { AndroidTimeUtils.parseDuration(it) } - else - null - - if (allDay) { - event.dtStart = DtStart(Date(tsStart)) - - // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. - // Assume 1 day if missing (should never occur, but occurs). - if (tsEnd == null && duration == null) - duration = Duration.ofDays(1) - - if (duration != null) { - // Some servers have problems with DURATION, so we always generate DTEND. - val startDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(tsStart), ZoneOffset.UTC).toLocalDate() - if (duration is Duration) - duration = Period.ofDays(duration.toDays().toInt()) - tsEnd = (startDate + duration).toEpochDay() * TimeApiExtensions.MILLIS_PER_DAY - duration = null - } - - if (tsEnd != null) { - when { - tsEnd < tsStart -> - logger.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") - - tsEnd == tsStart -> - logger.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") - - else /* tsEnd > tsStart */ -> - event.dtEnd = DtEnd(Date(tsEnd)) - } - } - - } else /* !allDay */ { - // use DATE-TIME values - // check time zone ID (calendar apps may insert no or an invalid ID) - val startTzId = DateUtils.findAndroidTimezoneID(row.getAsString(Events.EVENT_TIMEZONE)) - val startTz = tzRegistry.getTimeZone(startTzId) - val dtStartDateTime = DateTime(tsStart).apply { - if (startTz != null) { // null if there was not ical4j time zone for startTzId, which should not happen, but technically may happen - if (TimeZones.isUtc(startTz)) - isUtc = true - else - timeZone = startTz - } - } - event.dtStart = DtStart(dtStartDateTime) - - // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. - // Assume 1 hour if missing (should never occur, but occurs). - if (tsEnd == null && duration == null) - duration = Duration.ofHours(1) - - if (duration != null) { - // Some servers have problems with DURATION, so we always generate DTEND. - val zonedStart = dtStartDateTime.toZonedDateTime() - tsEnd = (zonedStart + duration).toInstant().toEpochMilli() - duration = null - } - - if (tsEnd != null) { - if (tsEnd < tsStart) - logger.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") - /*else if (tsEnd == tsStart) // iCloud sends 404 when it receives an iCalendar with DTSTART but without DTEND - logger.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ - else /* tsEnd > tsStart */ { - val endTz = row.getAsString(Events.EVENT_END_TIMEZONE)?.let { tzId -> - tzRegistry.getTimeZone(tzId) - } ?: startTz - event.dtEnd = DtEnd(DateTime(tsEnd).apply { - if (endTz != null) { - if (TimeZones.isUtc(endTz)) - isUtc = true - else - timeZone = endTz - } - }) - } - } - - } - - // recurrence - try { - row.getAsString(Events.RRULE)?.let { rulesStr -> - for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) - event.rRules += RRule(rule) - } - row.getAsString(Events.RDATE)?.let { datesStr -> - val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, tzRegistry, allDay, tsStart) { RDate(it) } - event.rDates += rDate - } - - row.getAsString(Events.EXRULE)?.let { rulesStr -> - for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) - event.exRules += ExRule(null, rule) - } - row.getAsString(Events.EXDATE)?.let { datesStr -> - val exDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, tzRegistry, allDay) { ExDate(it) } - event.exDates += exDate - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) - } - - event.uid = row.getAsString(Events.UID_2445) - event.sequence = row.getAsInteger(COLUMN_SEQUENCE) - event.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) - - event.summary = row.getAsString(Events.TITLE) - event.location = row.getAsString(Events.EVENT_LOCATION) - event.description = row.getAsString(Events.DESCRIPTION) - - // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) - event.color = - row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> // try color key first - try { - Css3Color.valueOf(name) - } catch (_: IllegalArgumentException) { - logger.warning("Ignoring unknown color name \"$name\"") - null - } - } ?: - row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value - Css3Color.entries.firstOrNull { it.argb == color } - } - - // status - when (row.getAsInteger(Events.STATUS)) { - Events.STATUS_CONFIRMED -> event.status = Status.VEVENT_CONFIRMED - Events.STATUS_TENTATIVE -> event.status = Status.VEVENT_TENTATIVE - Events.STATUS_CANCELED -> event.status = Status.VEVENT_CANCELLED - } - - // availability - event.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE - - // scheduling - if (groupScheduled) { - // ORGANIZER must only be set for group-scheduled events (= events with attendees) - if (row.containsKey(Events.ORGANIZER)) - try { - event.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null)) - } catch (e: URISyntaxException) { - logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) - } - } - - // classification - when (row.getAsInteger(Events.ACCESS_LEVEL)) { - Events.ACCESS_PUBLIC -> event.classification = Clazz.PUBLIC - Events.ACCESS_PRIVATE -> event.classification = Clazz.PRIVATE - Events.ACCESS_CONFIDENTIAL -> event.classification = Clazz.CONFIDENTIAL - } - - // exceptions from recurring events - row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> - val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 - val originalDate = - if (originalAllDay) - Date(originalInstanceTime) - else - DateTime(originalInstanceTime) - if (originalDate is DateTime) { - event.dtStart?.let { dtStart -> - if (dtStart.isUtc) - originalDate.isUtc = true - else if (dtStart.timeZone != null) - originalDate.timeZone = dtStart.timeZone - } - } - event.recurrenceId = RecurrenceId(originalDate) - } - } - - private fun populateAttendee(row: ContentValues) { - logger.log(Level.FINE, "Read event attendee from calender provider", row) - - try { - val attendee: Attendee - val email = row.getAsString(Attendees.ATTENDEE_EMAIL) - val idNS = row.getAsString(Attendees.ATTENDEE_ID_NAMESPACE) - val id = row.getAsString(Attendees.ATTENDEE_IDENTITY) - - if (idNS != null || id != null) { - // attendee identified by namespace and ID - attendee = Attendee(URI(idNS, id, null)) - email?.let { attendee.parameters.add(Email(it)) } - } else - // attendee identified by email address - attendee = Attendee(URI("mailto", email, null)) - val params = attendee.parameters - - // always add RSVP (offer attendees to accept/decline) - params.add(Rsvp.TRUE) - - row.getAsString(Attendees.ATTENDEE_NAME)?.let { cn -> params.add(Cn(cn)) } - - // type/relation mapping is complex and thus outsourced to AttendeeMappings - AttendeeMappings.androidToICalendar(row, attendee) - - // status - when (row.getAsInteger(Attendees.ATTENDEE_STATUS)) { - Attendees.ATTENDEE_STATUS_INVITED -> params.add(PartStat.NEEDS_ACTION) - Attendees.ATTENDEE_STATUS_ACCEPTED -> params.add(PartStat.ACCEPTED) - Attendees.ATTENDEE_STATUS_DECLINED -> params.add(PartStat.DECLINED) - Attendees.ATTENDEE_STATUS_TENTATIVE -> params.add(PartStat.TENTATIVE) - Attendees.ATTENDEE_STATUS_NONE -> { /* no information, don't add PARTSTAT */ } - } - - event!!.attendees.add(attendee) - } catch (e: URISyntaxException) { - logger.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) - } - } - - private fun populateReminder(row: ContentValues) { - logger.log(Level.FINE, "Read event reminder from calender provider", row) - val event = requireNotNull(event) - - val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) - - val props = alarm.properties - when (row.getAsInteger(Reminders.METHOD)) { - Reminders.METHOD_EMAIL -> { - val accountName = calendar.account.name - if (Patterns.EMAIL_ADDRESS.matcher(accountName).matches()) { - props += Action.EMAIL - // ACTION:EMAIL requires SUMMARY, DESCRIPTION, ATTENDEE - props += Summary(event.summary) - props += Description(event.description ?: event.summary) - // Android doesn't allow to save email reminder recipients, so we always use the - // account name (should be account owner's email address) - props += Attendee(URI("mailto", calendar.account.name, null)) - } else { - logger.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") - props += Action.DISPLAY - props += Description(event.summary) - } - } - - // default: set ACTION:DISPLAY (requires DESCRIPTION) - else -> { - props += Action.DISPLAY - props += Description(event.summary) - } - } - event.alarms += alarm - } - - private fun populateExtended(row: ContentValues) { - val name = row.getAsString(ExtendedProperties.NAME) - val rawValue = row.getAsString(ExtendedProperties.VALUE) - logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) - val event = requireNotNull(event) - - try { - when (name) { - EXTNAME_CATEGORIES -> - event.categories += rawValue.split(CATEGORIES_SEPARATOR) - - EXTNAME_URL -> - try { - event.url = URI(rawValue) - } catch(_: URISyntaxException) { - logger.warning("Won't process invalid local URL: $rawValue") - } - - EXTNAME_ICAL_UID -> - // only consider iCalUid when there's no uid - if (event.uid == null) - event.uid = rawValue - - UnknownProperty.CONTENT_ITEM_TYPE -> - event.unknownProperties += UnknownProperty.fromJsonString(rawValue) - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't parse extended property", e) - } - } - private fun populateExceptions() { - requireNotNull(id) - val event = requireNotNull(event) - - calendar.client.query(Events.CONTENT_URI.asSyncAdapter(calendar.account), - null, - Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c -> - while (c.moveToNext()) { - val values = c.toContentValues() - try { - val exception = AndroidEvent(calendar, values) - val exceptionEvent = exception.event!! - val recurrenceId = exceptionEvent.recurrenceId!! - - // generate EXDATE instead of RECURRENCE-ID exceptions for cancelled instances - if (exceptionEvent.status == Status.VEVENT_CANCELLED) { - val list = DateList( - if (DateUtils.isDate(recurrenceId)) Value.DATE else Value.DATE_TIME, - recurrenceId.timeZone - ) - list.add(recurrenceId.date) - event.exDates += ExDate(list).apply { - if (DateUtils.isDateTime(recurrenceId)) { - if (recurrenceId.isUtc) - setUtc(true) - else - timeZone = recurrenceId.timeZone - } - } - - } else /* exceptionEvent.status != Status.VEVENT_CANCELLED */ { - // make sure that all components have the same ORGANIZER [RFC 6638 3.1] - exceptionEvent.organizer = event.organizer - - // add exception to list of exceptions - event.exceptions += exceptionEvent - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't find exception details", e) - } - } + throw FileNotFoundException("Couldn't find event $id") } - } - - private fun retainClassification() { - /* retain classification other than PUBLIC and PRIVATE as unknown property so - that it can be reused when "server default" is selected */ - val event = requireNotNull(event) - event.classification?.let { - if (it != Clazz.PUBLIC && it != Clazz.PRIVATE) - event.unknownProperties += it - } - } - /** - * Saves an unsaved event into the calendar storage. + * Saves the unsaved [event] into the calendar storage. * * @return content URI of the created event * @@ -573,7 +150,10 @@ class AndroidEvent( */ fun add(): Uri { val batch = CalendarBatchOperation(calendar.client) - val idxEvent = addOrUpdateRows(batch) ?: throw AssertionError("Expected Events._ID backref") + + val requiredEvent = requireNotNull(event) + val builder = AndroidEventBuilder(calendar, requiredEvent, id, syncId, eTag, scheduleTag, flags) + val idxEvent = builder.addOrUpdateRows(requiredEvent, batch) ?: throw AssertionError("Expected Events._ID backref") batch.commit() val resultUri = batch.getResult(idxEvent)?.uri @@ -582,114 +162,6 @@ class AndroidEvent( return resultUri } - /** - * Adds or updates the calendar provider [Events] main row for this [event]. - * - * @param batch batch operation for insert/update operation - * - * @return [Events._ID] of the created/updated row; *null* if now ID is available - */ - fun addOrUpdateRows(batch: CalendarBatchOperation): Int? { - val event = requireNotNull(event) - val builder = - if (id == null) - CpoBuilder.newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - else - CpoBuilder.newUpdate(eventSyncURI()) - - val idxEvent = if (id == null) batch.nextBackrefIdx() else null - buildEvent(null, builder) - batch += builder - - // add reminders - event.alarms.forEach { insertReminder(batch, idxEvent, it) } - - // add attendees - val organizer = event.organizerEmail ?: - /* no ORGANIZER, use current account owner as ORGANIZER */ - calendar.ownerAccount ?: calendar.account.name - event.attendees.forEach { insertAttendee(batch, idxEvent, it, organizer) } - - // add extended properties - // CATEGORIES - if (event.categories.isNotEmpty()) - insertCategories(batch, idxEvent) - // CLASS - retainClassification() - // URL - event.url?.let { url -> - insertExtendedProperty(batch, idxEvent, EXTNAME_URL, url.toString()) - } - // unknown properties - event.unknownProperties.forEach { - insertUnknownProperty(batch, idxEvent, it) - } - - // add exceptions - for (exception in event.exceptions) { - /* I guess exceptions should be inserted using Events.CONTENT_EXCEPTION_URI so that we could - benefit from some provider logic (for recurring exceptions e.g.). However, this method - has some caveats: - - For instance, only Events.SYNC_DATA1, SYNC_DATA3 and SYNC_DATA7 can be used - in exception events (that's hardcoded in the CalendarProvider, don't ask me why). - - Also, CONTENT_EXCEPTIONS_URI doesn't deal with exceptions for recurring events defined by RDATE - (it checks for RRULE and aborts if no RRULE is found). - So I have chosen the method of inserting the exception event manually. - - It's also noteworthy that linking the main event to the exception only works using _SYNC_ID - and ORIGINAL_SYNC_ID (and not ID and ORIGINAL_ID, as one could assume). So, if you don't - set _SYNC_ID in the main event and ORIGINAL_SYNC_ID in the exception, the exception will - appear additionally (and not *instead* of the instance). - */ - - val recurrenceId = exception.recurrenceId - if (recurrenceId == null) { - logger.warning("Ignoring exception of event ${event.uid} without recurrenceId") - continue - } - - val exBuilder = CpoBuilder - .newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Events.ORIGINAL_ID, idxEvent) - - buildEvent(exception, exBuilder) - if (exBuilder.values[Events.ORIGINAL_SYNC_ID] == null && exBuilder.valueBackrefs[Events.ORIGINAL_SYNC_ID] == null) - throw AssertionError("buildEvent(exception) must set ORIGINAL_SYNC_ID") - - var recurrenceDate = recurrenceId.date - val dtStartDate = event.dtStart!!.date - if (recurrenceDate is DateTime && dtStartDate !is DateTime) { - // rewrite RECURRENCE-ID;VALUE=DATE-TIME to VALUE=DATE for all-day events - val localDate = recurrenceDate.toLocalDate() - recurrenceDate = Date(localDate.toIcal4jDate()) - - } else if (recurrenceDate !is DateTime && dtStartDate is DateTime) { - // rewrite RECURRENCE-ID;VALUE=DATE to VALUE=DATE-TIME for non-all-day-events - val localDate = recurrenceDate.toLocalDate() - // guess time and time zone from DTSTART - val zonedTime = ZonedDateTime.of( - localDate, - dtStartDate.toLocalTime(), - dtStartDate.requireZoneId() - ) - recurrenceDate = zonedTime.toIcal4jDateTime() - } - exBuilder .withValue(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(event.dtStart)) 1 else 0) - .withValue(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate.time) - - val idxException = batch.nextBackrefIdx() - batch += exBuilder - - // add exception reminders - exception.alarms.forEach { insertReminder(batch, idxException, it) } - - // add exception attendees - exception.attendees.forEach { insertAttendee(batch, idxException, it, organizer) } - } - - return idxEvent - } - /** * Updates an already existing event in the calendar storage with the values * from the instance. @@ -739,7 +211,8 @@ class AndroidEvent( ) ) - addOrUpdateRows(batch) + val builder = AndroidEventBuilder(calendar, event, id, syncId, eTag, scheduleTag, flags) + builder.addOrUpdateRows(event, batch) batch.commit() return ContentUris.withAppendedId(Events.CONTENT_URI, existingId) @@ -776,371 +249,7 @@ class AndroidEvent( .newDelete(Events.CONTENT_URI.asSyncAdapter(calendar.account)) .withSelection("${Events.ORIGINAL_ID}=?", arrayOf(existingId.toString())) } - - - /** - * Builds an Android [Events] row for a given event. Takes information from - * - * - this [AndroidEvent] object: fields like calendar ID, sync ID, eTag etc, - * - the [event]: all other fields. - * - * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source - * @param builder data row builder to be used - */ - private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { - val event = recurrence ?: requireNotNull(event) - - val dtStart = event.dtStart ?: throw InvalidLocalResourceException("Events must have DTSTART") - val allDay = DateUtils.isDate(dtStart) - - // make sure that time zone is supported by Android - val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() - AndroidTimeUtils.androidifyTimeZone(dtStart, tzRegistry) - - val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() - - /* [CalendarContract.Events SDK documentation] - When inserting a new event the following fields must be included: - - dtstart - - dtend if the event is non-recurring - - duration if the event is recurring - - rrule or rdate if the event is recurring - - eventTimezone - - a calendar_id */ - - // object-level (AndroidEvent) fields - builder .withValue(Events.CALENDAR_ID, calendar.id) - .withValue(Events.DIRTY, 0) // newly created event rows shall not be marked as dirty - .withValue(Events.DELETED, 0) // or deleted - .withValue(COLUMN_FLAGS, flags) - - if (recurrence == null) - builder.withValue(Events._SYNC_ID, syncId) - .withValue(COLUMN_ETAG, eTag) - .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) - else - builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) - - // UID, sequence - builder .withValue(Events.UID_2445, event.uid) - .withValue(COLUMN_SEQUENCE, event.sequence) - - // time fields - builder .withValue(Events.DTSTART, dtStart.date.time) - .withValue(Events.ALL_DAY, if (allDay) 1 else 0) - .withValue(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) - - var dtEnd = event.dtEnd - AndroidTimeUtils.androidifyTimeZone(dtEnd, tzRegistry) - - var duration = - if (dtEnd == null) - event.duration?.duration - else - null - if (allDay && duration is Duration) - duration = Period.ofDays(duration.toDays().toInt()) - - if (recurring) { - // duration must be set - if (duration == null) { - if (dtEnd != null) { - // calculate duration from dtEnd - duration = if (allDay) - Period.between(dtStart.date.toLocalDate(), dtEnd.date.toLocalDate()) - else - Duration.between(dtStart.date.toInstant(), dtEnd.date.toInstant()) - } else { - // no dtEnd and no duration - duration = if (allDay) - /* [RFC 5545 3.6.1 Event Component] - For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE value type but no - "DTEND" nor "DURATION" property, the event's duration is taken to - be one day. */ - Period.ofDays(1) - else - /* For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE-TIME value type but no - "DTEND" property, the event ends on the same calendar date and - time of day specified by the "DTSTART" property. */ - - // Duration.ofSeconds(0) causes the calendar provider to crash - Period.ofDays(0) - } - } - - // iCalendar doesn't permit years and months, only PwWdDThHmMsS - builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) - .withValue(Events.DTEND, null) - - // add RRULEs - if (event.rRules.isNotEmpty()) { - builder.withValue(Events.RRULE, event.rRules - .joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) - } else - builder.withValue(Events.RRULE, null) - - if (event.rDates.isNotEmpty()) { - // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] - val infiniteRrule = event.rRules.any { rRule -> - rRule.recur.count == -1 && // no COUNT AND - rRule.recur.until == null // no UNTIL - } - - if (infiniteRrule) - logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") - else { - for (rDate in event.rDates) - AndroidTimeUtils.androidifyTimeZone(rDate) - - // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] - val listWithDtStart = DateList() - listWithDtStart.add(dtStart.date) - event.rDates.addFirst(RDate(listWithDtStart)) - - builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, dtStart.date)) - } - } else - builder.withValue(Events.RDATE, null) - - if (event.exRules.isNotEmpty()) - builder.withValue(Events.EXRULE, event.exRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) - else - builder.withValue(Events.EXRULE, null) - - if (event.exDates.isNotEmpty()) { - for (exDate in event.exDates) - AndroidTimeUtils.androidifyTimeZone(exDate) - builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, dtStart.date)) - } else - builder.withValue(Events.EXDATE, null) - - } else /* !recurring */ { - // dtend must be set - if (dtEnd == null) { - if (duration != null) { - // calculate dtEnd from duration - if (allDay) { - val calcDtEnd = dtStart.date.toLocalDate() + duration - dtEnd = DtEnd(calcDtEnd.toIcal4jDate()) - } else { - val zonedStartTime = (dtStart.date as DateTime).toZonedDateTime() - val calcEnd = zonedStartTime + duration - val calcDtEnd = DtEnd(calcEnd.toIcal4jDateTime()) - calcDtEnd.timeZone = dtStart.timeZone - dtEnd = calcDtEnd - } - } else { - // no dtEnd and no duration - dtEnd = if (allDay) { - /* [RFC 5545 3.6.1 Event Component] - For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE value type but no - "DTEND" nor "DURATION" property, the event's duration is taken to - be one day. */ - val calcDtEnd = dtStart.date.toLocalDate() + Period.ofDays(1) - DtEnd(calcDtEnd.toIcal4jDate()) - } else - /* For cases where a "VEVENT" calendar component - specifies a "DTSTART" property with a DATE-TIME value type but no - "DTEND" property, the event ends on the same calendar date and - time of day specified by the "DTSTART" property. */ - DtEnd(dtStart.value, dtStart.timeZone) - } - } - - AndroidTimeUtils.androidifyTimeZone(dtEnd, tzRegistry) - builder .withValue(Events.DTEND, dtEnd.date.time) - .withValue(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.storageTzId(dtEnd)) - .withValue(Events.DURATION, null) - .withValue(Events.RRULE, null) - .withValue(Events.RDATE, null) - .withValue(Events.EXRULE, null) - .withValue(Events.EXDATE, null) - } - - // text fields - builder.withValue(Events.TITLE, event.summary) - .withValue(Events.EVENT_LOCATION, event.location) - .withValue(Events.DESCRIPTION, event.description) - - // color - val color = event.color - if (color != null) { - // set event color (if it's available for this account) - calendar.client.query(Colors.CONTENT_URI.asSyncAdapter(calendar.account), arrayOf(Colors.COLOR_KEY), - "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(color.name), null)?.use { cursor -> - if (cursor.moveToNext()) - builder.withValue(Events.EVENT_COLOR_KEY, color.name) - else - logger.fine("Ignoring event color \"${color.name}\" (not available for this account)") - } - } else { - // reset color index and value - builder .withValue(Events.EVENT_COLOR_KEY, null) - .withValue(Events.EVENT_COLOR, null) - } - - // scheduling - val groupScheduled = event.attendees.isNotEmpty() - if (groupScheduled) { - builder .withValue(Events.HAS_ATTENDEE_DATA, 1) - .withValue(Events.ORGANIZER, event.organizer?.let { organizer -> - val uri = organizer.calAddress - val email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL)?.value - - if (email != null) - return@let email - - logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") - null - } ?: calendar.ownerAccount) - - } else /* !groupScheduled */ - builder .withValue(Events.HAS_ATTENDEE_DATA, 0) - .withValue(Events.ORGANIZER, calendar.ownerAccount) - - // Attention: don't update event with STATUS != null to STATUS = null (causes calendar provider operation to fail)! - // In this case, the whole event must be deleted and inserted again. - if (/* insert, not an update */ id == null || /* update, but we're not updating to null */ event.status != null) - builder.withValue(Events.STATUS, when (event.status) { - null /* not possible by if statement */ -> null - Status.VEVENT_CONFIRMED -> Events.STATUS_CONFIRMED - Status.VEVENT_CANCELLED -> Events.STATUS_CANCELED - else -> Events.STATUS_TENTATIVE - }) - - builder .withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE) - .withValue(Events.ACCESS_LEVEL, when (event.classification) { - null -> Events.ACCESS_DEFAULT - Clazz.PUBLIC -> Events.ACCESS_PUBLIC - Clazz.CONFIDENTIAL -> Events.ACCESS_CONFIDENTIAL - else /* including Events.ACCESS_PRIVATE */ -> Events.ACCESS_PRIVATE - }) - } - - private fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { - val builder = CpoBuilder - .newInsert(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Reminders.EVENT_ID, idxEvent) - - val method = when (alarm.action?.value?.uppercase(Locale.ROOT)) { - Action.DISPLAY.value, - Action.AUDIO.value -> Reminders.METHOD_ALERT // will trigger an alarm on the Android device - - // Note: The calendar provider doesn't support saving specific attendees for email reminders. - Action.EMAIL.value -> Reminders.METHOD_EMAIL - - else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device - } - - val minutes = ICalendar.vAlarmToMin(alarm, event!!, false)?.second ?: Reminders.MINUTES_DEFAULT - - builder .withValue(Reminders.METHOD, method) - .withValue(Reminders.MINUTES, minutes) - batch += builder - } - - private fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { - val builder = CpoBuilder - .newInsert(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(Attendees.EVENT_ID, idxEvent) - - val member = attendee.calAddress - if (member.scheme.equals("mailto", true)) - // attendee identified by email - builder .withValue(Attendees.ATTENDEE_EMAIL, member.schemeSpecificPart) - else { - // attendee identified by other URI - builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme) - .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart) - - attendee.getParameter(Parameter.EMAIL)?.let { email -> - builder.withValue(Attendees.ATTENDEE_EMAIL, email.value) - } - } - - attendee.getParameter(Parameter.CN)?.let { cn -> - builder.withValue(Attendees.ATTENDEE_NAME, cn.value) - } - - // type/relation mapping is complex and thus outsourced to AttendeeMappings - AttendeeMappings.iCalendarToAndroid(attendee, builder, organizer) - - val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) { - PartStat.ACCEPTED -> Attendees.ATTENDEE_STATUS_ACCEPTED - PartStat.DECLINED -> Attendees.ATTENDEE_STATUS_DECLINED - PartStat.TENTATIVE -> Attendees.ATTENDEE_STATUS_TENTATIVE - PartStat.DELEGATED -> Attendees.ATTENDEE_STATUS_NONE - else /* default: PartStat.NEEDS_ACTION */ -> Attendees.ATTENDEE_STATUS_INVITED - } - builder.withValue(Attendees.ATTENDEE_STATUS, status) - batch += builder - } - - private fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { - val builder = CpoBuilder - .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) - .withEventId(ExtendedProperties.EVENT_ID, idxEvent) - .withValue(ExtendedProperties.NAME, name) - .withValue(ExtendedProperties.VALUE, value) - batch += builder - } - - private fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { - val rawCategories = event!!.categories // concatenate, separate by backslash - .joinToString(CATEGORIES_SEPARATOR.toString()) { category -> - // drop occurrences of CATEGORIES_SEPARATOR in category names - category.filter { it != CATEGORIES_SEPARATOR } - } - insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) - } - - private fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { - if (property.value == null) { - logger.warning("Ignoring unknown property with null value") - return - } - if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") - return - } - - insertExtendedProperty(batch, idxEvent, UnknownProperty.CONTENT_ITEM_TYPE, UnknownProperty.toJsonString(property)) - } - - private fun useRetainedClassification() { - val event = requireNotNull(event) - - var retainedClazz: Clazz? = null - val it = event.unknownProperties.iterator() - while (it.hasNext()) { - val prop = it.next() - if (prop is Clazz) { - retainedClazz = prop - it.remove() - } - } - - if (event.classification == null) - // no classification, use retained one if possible - event.classification = retainedClazz - } - - - private fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { - if (idxEvent != null) - withValueBackReference(column, idxEvent) - else - withValue(column, requireNotNull(id)) - return this - } - - + private fun eventSyncURI(): Uri { val id = requireNotNull(id) return ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(calendar.account) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilder.kt new file mode 100644 index 00000000..0e2869af --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilder.kt @@ -0,0 +1,544 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Colors +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import at.bitfire.ical4android.AndroidEvent.Companion.CATEGORIES_SEPARATOR +import at.bitfire.ical4android.AndroidEvent.Companion.COLUMN_ETAG +import at.bitfire.ical4android.AndroidEvent.Companion.COLUMN_FLAGS +import at.bitfire.ical4android.AndroidEvent.Companion.COLUMN_SCHEDULE_TAG +import at.bitfire.ical4android.AndroidEvent.Companion.COLUMN_SEQUENCE +import at.bitfire.ical4android.AndroidEvent.Companion.EXTNAME_CATEGORIES +import at.bitfire.ical4android.AndroidEvent.Companion.EXTNAME_URL +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.ICalendar +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.TimeApiExtensions.requireZoneId +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime +import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.synctools.exception.InvalidLocalResourceException +import at.bitfire.synctools.storage.BatchOperation.CpoBuilder +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.CalendarBatchOperation +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.Status +import java.time.Duration +import java.time.Period +import java.time.ZonedDateTime +import java.util.Locale +import java.util.logging.Logger + +/** + * Legacy mapper from an [Event] data object to Android content provider data rows + * (former "build..." methods). + * + * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID + * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. + */ +class AndroidEventBuilder( + private val calendar: AndroidCalendar, + private val event: Event, + + // AndroidEvent-level fields + private val id: Long?, + private val syncId: String?, + private val eTag: String?, + private val scheduleTag: String?, + private val flags: Int, +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + + + fun addOrUpdateRows(event: Event, batch: CalendarBatchOperation): Int? { + val builder = + if (id == null) + CpoBuilder.newInsert(calendar.eventsUri) + else + CpoBuilder.newUpdate(calendar.eventUri(id)) + + // return the index of the row containing the event ID in the results (only when adding an event) + val idxEvent = if (id == null) + batch.nextBackrefIdx() + else + null + + buildEvent(null, builder) + batch += builder + + // add reminders + event.alarms.forEach { insertReminder(batch, idxEvent, it) } + + // add attendees + val organizer = event.organizerEmail ?: + /* no ORGANIZER, use current account owner as ORGANIZER */ + calendar.ownerAccount ?: calendar.account.name + event.attendees.forEach { insertAttendee(batch, idxEvent, it, organizer) } + + // add extended properties + // CATEGORIES + if (event.categories.isNotEmpty()) + insertCategories(batch, idxEvent) + // CLASS + retainClassification() + // URL + event.url?.let { url -> + insertExtendedProperty(batch, idxEvent, EXTNAME_URL, url.toString()) + } + // unknown properties + event.unknownProperties.forEach { + insertUnknownProperty(batch, idxEvent, it) + } + + // add exceptions + for (exception in event.exceptions) { + /* I guess exceptions should be inserted using Events.CONTENT_EXCEPTION_URI so that we could + benefit from some provider logic (for recurring exceptions e.g.). However, this method + has some caveats: + - For instance, only Events.SYNC_DATA1, SYNC_DATA3 and SYNC_DATA7 can be used + in exception events (that's hardcoded in the CalendarProvider, don't ask me why). + - Also, CONTENT_EXCEPTIONS_URI doesn't deal with exceptions for recurring events defined by RDATE + (it checks for RRULE and aborts if no RRULE is found). + So I have chosen the method of inserting the exception event manually. + + It's also noteworthy that linking the main event to the exception only works using _SYNC_ID + and ORIGINAL_SYNC_ID (and not ID and ORIGINAL_ID, as one could assume). So, if you don't + set _SYNC_ID in the main event and ORIGINAL_SYNC_ID in the exception, the exception will + appear additionally (and not *instead* of the instance). + */ + + val recurrenceId = exception.recurrenceId + if (recurrenceId == null) { + logger.warning("Ignoring exception of event ${event.uid} without recurrenceId") + continue + } + + val exBuilder = CpoBuilder + .newInsert(Events.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Events.ORIGINAL_ID, idxEvent) + + buildEvent(exception, exBuilder) + if (exBuilder.values[Events.ORIGINAL_SYNC_ID] == null && exBuilder.valueBackrefs[Events.ORIGINAL_SYNC_ID] == null) + throw AssertionError("buildEvent(exception) must set ORIGINAL_SYNC_ID") + + var recurrenceDate = recurrenceId.date + val dtStartDate = event.dtStart!!.date + if (recurrenceDate is DateTime && dtStartDate !is DateTime) { + // rewrite RECURRENCE-ID;VALUE=DATE-TIME to VALUE=DATE for all-day events + val localDate = recurrenceDate.toLocalDate() + recurrenceDate = Date(localDate.toIcal4jDate()) + + } else if (recurrenceDate !is DateTime && dtStartDate is DateTime) { + // rewrite RECURRENCE-ID;VALUE=DATE to VALUE=DATE-TIME for non-all-day-events + val localDate = recurrenceDate.toLocalDate() + // guess time and time zone from DTSTART + val zonedTime = ZonedDateTime.of( + localDate, + dtStartDate.toLocalTime(), + dtStartDate.requireZoneId() + ) + recurrenceDate = zonedTime.toIcal4jDateTime() + } + exBuilder .withValue(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(event.dtStart)) 1 else 0) + .withValue(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate.time) + + val idxException = batch.nextBackrefIdx() + batch += exBuilder + + // add exception reminders + exception.alarms.forEach { insertReminder(batch, idxException, it) } + + // add exception attendees + exception.attendees.forEach { insertAttendee(batch, idxException, it, organizer) } + } + + return idxEvent + } + + /** + * Builds an Android [Events] row for a given event. Takes information from + * + * - `this` object: fields like calendar ID, sync ID, eTag etc, + * - the [event]: all other fields. + * + * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source + * @param builder data row builder to be used + */ + private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { + val event = recurrence ?: event + + val dtStart = event.dtStart ?: throw InvalidLocalResourceException("Events must have DTSTART") + val allDay = DateUtils.isDate(dtStart) + + // make sure that time zone is supported by Android + AndroidTimeUtils.androidifyTimeZone(dtStart, tzRegistry) + + val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() + + /* [CalendarContract.Events SDK documentation] + When inserting a new event the following fields must be included: + - dtstart + - dtend if the event is non-recurring + - duration if the event is recurring + - rrule or rdate if the event is recurring + - eventTimezone + - a calendar_id */ + + // object-level (AndroidEvent) fields + builder .withValue(Events.CALENDAR_ID, calendar.id) + .withValue(Events.DIRTY, 0) // newly created event rows shall not be marked as dirty + .withValue(Events.DELETED, 0) // or deleted + .withValue(COLUMN_FLAGS, flags) + + if (recurrence == null) + builder.withValue(Events._SYNC_ID, syncId) + .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) + else + builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) + + // UID, sequence + builder .withValue(Events.UID_2445, event.uid) + .withValue(COLUMN_SEQUENCE, event.sequence) + + // time fields + builder .withValue(Events.DTSTART, dtStart.date.time) + .withValue(Events.ALL_DAY, if (allDay) 1 else 0) + .withValue(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) + + var dtEnd = event.dtEnd + AndroidTimeUtils.androidifyTimeZone(dtEnd, tzRegistry) + + var duration = + if (dtEnd == null) + event.duration?.duration + else + null + if (allDay && duration is Duration) + duration = Period.ofDays(duration.toDays().toInt()) + + if (recurring) { + // duration must be set + if (duration == null) { + if (dtEnd != null) { + // calculate duration from dtEnd + duration = if (allDay) + Period.between(dtStart.date.toLocalDate(), dtEnd.date.toLocalDate()) + else + Duration.between(dtStart.date.toInstant(), dtEnd.date.toInstant()) + } else { + // no dtEnd and no duration + duration = if (allDay) + /* [RFC 5545 3.6.1 Event Component] + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day. */ + Period.ofDays(1) + else + /* For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. */ + + // Duration.ofSeconds(0) causes the calendar provider to crash + Period.ofDays(0) + } + } + + // iCalendar doesn't permit years and months, only PwWdDThHmMsS + builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) + .withValue(Events.DTEND, null) + + // add RRULEs + if (event.rRules.isNotEmpty()) { + builder.withValue(Events.RRULE, event.rRules + .joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + } else + builder.withValue(Events.RRULE, null) + + if (event.rDates.isNotEmpty()) { + // ignore RDATEs when there's also an infinite RRULE [https://issuetracker.google.com/issues/216374004] + val infiniteRrule = event.rRules.any { rRule -> + rRule.recur.count == -1 && // no COUNT AND + rRule.recur.until == null // no UNTIL + } + + if (infiniteRrule) + logger.warning("Android can't handle infinite RRULE + RDATE [https://issuetracker.google.com/issues/216374004]; ignoring RDATE(s)") + else { + for (rDate in event.rDates) + AndroidTimeUtils.androidifyTimeZone(rDate) + + // Calendar provider drops DTSTART instance when using RDATE [https://code.google.com/p/android/issues/detail?id=171292] + val listWithDtStart = DateList() + listWithDtStart.add(dtStart.date) + event.rDates.addFirst(RDate(listWithDtStart)) + + builder.withValue(Events.RDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.rDates, dtStart.date)) + } + } else + builder.withValue(Events.RDATE, null) + + if (event.exRules.isNotEmpty()) + builder.withValue(Events.EXRULE, event.exRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) + else + builder.withValue(Events.EXRULE, null) + + if (event.exDates.isNotEmpty()) { + for (exDate in event.exDates) + AndroidTimeUtils.androidifyTimeZone(exDate) + builder.withValue(Events.EXDATE, AndroidTimeUtils.recurrenceSetsToAndroidString(event.exDates, dtStart.date)) + } else + builder.withValue(Events.EXDATE, null) + + } else /* !recurring */ { + // dtend must be set + if (dtEnd == null) { + if (duration != null) { + // calculate dtEnd from duration + if (allDay) { + val calcDtEnd = dtStart.date.toLocalDate() + duration + dtEnd = DtEnd(calcDtEnd.toIcal4jDate()) + } else { + val zonedStartTime = (dtStart.date as DateTime).toZonedDateTime() + val calcEnd = zonedStartTime + duration + val calcDtEnd = DtEnd(calcEnd.toIcal4jDateTime()) + calcDtEnd.timeZone = dtStart.timeZone + dtEnd = calcDtEnd + } + } else { + // no dtEnd and no duration + dtEnd = if (allDay) { + /* [RFC 5545 3.6.1 Event Component] + For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE value type but no + "DTEND" nor "DURATION" property, the event's duration is taken to + be one day. */ + val calcDtEnd = dtStart.date.toLocalDate() + Period.ofDays(1) + DtEnd(calcDtEnd.toIcal4jDate()) + } else + /* For cases where a "VEVENT" calendar component + specifies a "DTSTART" property with a DATE-TIME value type but no + "DTEND" property, the event ends on the same calendar date and + time of day specified by the "DTSTART" property. */ + DtEnd(dtStart.value, dtStart.timeZone) + } + } + + AndroidTimeUtils.androidifyTimeZone(dtEnd, tzRegistry) + builder .withValue(Events.DTEND, dtEnd.date.time) + .withValue(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.storageTzId(dtEnd)) + .withValue(Events.DURATION, null) + .withValue(Events.RRULE, null) + .withValue(Events.RDATE, null) + .withValue(Events.EXRULE, null) + .withValue(Events.EXDATE, null) + } + + // text fields + builder.withValue(Events.TITLE, event.summary) + .withValue(Events.EVENT_LOCATION, event.location) + .withValue(Events.DESCRIPTION, event.description) + + // color + val color = event.color + if (color != null) { + // set event color (if it's available for this account) + calendar.client.query(Colors.CONTENT_URI.asSyncAdapter(calendar.account), arrayOf(Colors.COLOR_KEY), + "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(color.name), null)?.use { cursor -> + if (cursor.moveToNext()) + builder.withValue(Events.EVENT_COLOR_KEY, color.name) + else + logger.fine("Ignoring event color \"${color.name}\" (not available for this account)") + } + } else { + // reset color index and value + builder .withValue(Events.EVENT_COLOR_KEY, null) + .withValue(Events.EVENT_COLOR, null) + } + + // scheduling + val groupScheduled = event.attendees.isNotEmpty() + if (groupScheduled) { + builder .withValue(Events.HAS_ATTENDEE_DATA, 1) + .withValue(Events.ORGANIZER, event.organizer?.let { organizer -> + val uri = organizer.calAddress + val email = if (uri.scheme.equals("mailto", true)) + uri.schemeSpecificPart + else + organizer.getParameter(Parameter.EMAIL)?.value + + if (email != null) + return@let email + + logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") + null + } ?: calendar.ownerAccount) + + } else /* !groupScheduled */ + builder .withValue(Events.HAS_ATTENDEE_DATA, 0) + .withValue(Events.ORGANIZER, calendar.ownerAccount) + + // Attention: don't update event with STATUS != null to STATUS = null (causes calendar provider operation to fail)! + // In this case, the whole event must be deleted and inserted again. + if (/* insert, not an update */ id == null || /* update, but we're not updating to null */ event.status != null) + builder.withValue(Events.STATUS, when (event.status) { + null /* not possible by if statement */ -> null + Status.VEVENT_CONFIRMED -> Events.STATUS_CONFIRMED + Status.VEVENT_CANCELLED -> Events.STATUS_CANCELED + else -> Events.STATUS_TENTATIVE + }) + + builder .withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE) + .withValue(Events.ACCESS_LEVEL, when (event.classification) { + null -> Events.ACCESS_DEFAULT + Clazz.PUBLIC -> Events.ACCESS_PUBLIC + Clazz.CONFIDENTIAL -> Events.ACCESS_CONFIDENTIAL + else /* including Events.ACCESS_PRIVATE */ -> Events.ACCESS_PRIVATE + }) + } + + private fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { + val builder = CpoBuilder + .newInsert(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Reminders.EVENT_ID, idxEvent) + + val method = when (alarm.action?.value?.uppercase(Locale.ROOT)) { + Action.DISPLAY.value, + Action.AUDIO.value -> Reminders.METHOD_ALERT // will trigger an alarm on the Android device + + // Note: The calendar provider doesn't support saving specific attendees for email reminders. + Action.EMAIL.value -> Reminders.METHOD_EMAIL + + else -> Reminders.METHOD_DEFAULT // won't trigger an alarm on the Android device + } + + val minutes = ICalendar.vAlarmToMin(alarm, event, false)?.second ?: Reminders.MINUTES_DEFAULT + + builder .withValue(Reminders.METHOD, method) + .withValue(Reminders.MINUTES, minutes) + batch += builder + } + + private fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { + val builder = CpoBuilder + .newInsert(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(Attendees.EVENT_ID, idxEvent) + + val member = attendee.calAddress + if (member.scheme.equals("mailto", true)) + // attendee identified by email + builder .withValue(Attendees.ATTENDEE_EMAIL, member.schemeSpecificPart) + else { + // attendee identified by other URI + builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme) + .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart) + + attendee.getParameter(Parameter.EMAIL)?.let { email -> + builder.withValue(Attendees.ATTENDEE_EMAIL, email.value) + } + } + + attendee.getParameter(Parameter.CN)?.let { cn -> + builder.withValue(Attendees.ATTENDEE_NAME, cn.value) + } + + // type/relation mapping is complex and thus outsourced to AttendeeMappings + AttendeeMappings.iCalendarToAndroid(attendee, builder, organizer) + + val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) { + PartStat.ACCEPTED -> Attendees.ATTENDEE_STATUS_ACCEPTED + PartStat.DECLINED -> Attendees.ATTENDEE_STATUS_DECLINED + PartStat.TENTATIVE -> Attendees.ATTENDEE_STATUS_TENTATIVE + PartStat.DELEGATED -> Attendees.ATTENDEE_STATUS_NONE + else /* default: PartStat.NEEDS_ACTION */ -> Attendees.ATTENDEE_STATUS_INVITED + } + builder.withValue(Attendees.ATTENDEE_STATUS, status) + batch += builder + } + + private fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { + val builder = CpoBuilder + .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) + .withEventId(ExtendedProperties.EVENT_ID, idxEvent) + .withValue(ExtendedProperties.NAME, name) + .withValue(ExtendedProperties.VALUE, value) + batch += builder + } + + private fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { + val rawCategories = event.categories // concatenate, separate by backslash + .joinToString(CATEGORIES_SEPARATOR.toString()) { category -> + // drop occurrences of CATEGORIES_SEPARATOR in category names + category.filter { it != CATEGORIES_SEPARATOR } + } + insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) + } + + private fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { + if (property.value == null) { + logger.warning("Ignoring unknown property with null value") + return + } + if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + return + } + + insertExtendedProperty(batch, idxEvent, UnknownProperty.CONTENT_ITEM_TYPE, UnknownProperty.toJsonString(property)) + } + + /** + * Retain classification other than PUBLIC and PRIVATE as unknown property so + * that it can be reused when "server default" is selected. + */ + private fun retainClassification() { + event.classification?.let { + if (it != Clazz.PUBLIC && it != Clazz.PRIVATE) + event.unknownProperties += it + } + } + + + private fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { + if (idxEvent != null) + withValueBackReference(column, idxEvent) + else + withValue(column, requireNotNull(id)) + return this + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessor.kt new file mode 100644 index 00000000..d81c164e --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessor.kt @@ -0,0 +1,470 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.calendar + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import android.provider.CalendarContract.Reminders +import android.util.Patterns +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.AndroidEvent.Companion.CATEGORIES_SEPARATOR +import at.bitfire.ical4android.AndroidEvent.Companion.COLUMN_SEQUENCE +import at.bitfire.ical4android.AndroidEvent.Companion.EXTNAME_CATEGORIES +import at.bitfire.ical4android.AndroidEvent.Companion.EXTNAME_ICAL_UID +import at.bitfire.ical4android.AndroidEvent.Companion.EXTNAME_URL +import at.bitfire.ical4android.AndroidEvent.Companion.MUTATORS_SEPARATOR +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.ical4android.util.AndroidTimeUtils +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter +import at.bitfire.ical4android.util.TimeApiExtensions +import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.synctools.exception.InvalidLocalResourceException +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.toContentValues +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.ExRule +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.util.TimeZones +import java.net.URI +import java.net.URISyntaxException +import java.time.Duration +import java.time.Instant +import java.time.Period +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Legacy mapper from Android event main + data rows to an [Event] + * (former "populate..." methods). + * + * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID + * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. + */ +class AndroidEventProcessor( + private val calendar: AndroidCalendar, + private val id: Long, + private val entity: Entity +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + + + fun populate(to: Event) { + // calculate some scheduling properties + val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } + + // main row + populateEvent(entity.entityValues, groupScheduled = hasAttendees, to = to) + + // data rows + for (subValue in entity.subValues) { + val subValues = subValue.values + when (subValue.uri) { + Attendees.CONTENT_URI -> populateAttendee(subValues, to = to) + Reminders.CONTENT_URI -> populateReminder(subValues, to = to) + ExtendedProperties.CONTENT_URI -> populateExtended(subValues, to = to) + } + } + + // exceptions + populateExceptions(to = to) + + // post-processing + useRetainedClassification(to) + } + + /** + * Reads event data from the calendar provider = maps the [entity] values to + * the [to] data object. + * + * @param row values of an [Events] row, as returned by the calendar provider + */ + private fun populateEvent(row: ContentValues, groupScheduled: Boolean, to: Event) { + logger.log(Level.FINE, "Read event entity from calender provider", row) + + row.getAsString(Events.MUTATORS)?.let { strPackages -> + val packages = strPackages.split(MUTATORS_SEPARATOR).toSet() + to.userAgents.addAll(packages) + } + + val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0 + val tsStart = row.getAsLong(Events.DTSTART) ?: throw InvalidLocalResourceException("Found event without DTSTART") + + var tsEnd = row.getAsLong(Events.DTEND) + var duration = // only use DURATION of DTEND is not defined + if (tsEnd == null) + row.getAsString(Events.DURATION)?.let { AndroidTimeUtils.parseDuration(it) } + else + null + + if (allDay) { + to.dtStart = DtStart(Date(tsStart)) + + // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. + // Assume 1 day if missing (should never occur, but occurs). + if (tsEnd == null && duration == null) + duration = Duration.ofDays(1) + + if (duration != null) { + // Some servers have problems with DURATION, so we always generate DTEND. + val startDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(tsStart), ZoneOffset.UTC).toLocalDate() + if (duration is Duration) + duration = Period.ofDays(duration.toDays().toInt()) + tsEnd = (startDate + duration).toEpochDay() * TimeApiExtensions.MILLIS_PER_DAY + duration = null + } + + if (tsEnd != null) { + when { + tsEnd < tsStart -> + logger.warning("dtEnd $tsEnd (allDay) < dtStart $tsStart (allDay), ignoring") + + tsEnd == tsStart -> + logger.fine("dtEnd $tsEnd (allDay) = dtStart, won't generate DTEND property") + + else /* tsEnd > tsStart */ -> + to.dtEnd = DtEnd(Date(tsEnd)) + } + } + + } else /* !allDay */ { + // use DATE-TIME values + + // check time zone ID (calendar apps may insert no or an invalid ID) + val startTzId = DateUtils.findAndroidTimezoneID(row.getAsString(Events.EVENT_TIMEZONE)) + val startTz = tzRegistry.getTimeZone(startTzId) + val dtStartDateTime = DateTime(tsStart).apply { + if (startTz != null) { // null if there was not ical4j time zone for startTzId, which should not happen, but technically may happen + if (TimeZones.isUtc(startTz)) + isUtc = true + else + timeZone = startTz + } + } + to.dtStart = DtStart(dtStartDateTime) + + // Android events MUST have duration or dtend [https://developer.android.com/reference/android/provider/CalendarContract.Events#operations]. + // Assume 1 hour if missing (should never occur, but occurs). + if (tsEnd == null && duration == null) + duration = Duration.ofHours(1) + + if (duration != null) { + // Some servers have problems with DURATION, so we always generate DTEND. + val zonedStart = dtStartDateTime.toZonedDateTime() + tsEnd = (zonedStart + duration).toInstant().toEpochMilli() + duration = null + } + + if (tsEnd != null) { + if (tsEnd < tsStart) + logger.warning("dtEnd $tsEnd < dtStart $tsStart, ignoring") + /*else if (tsEnd == tsStart) // iCloud sends 404 when it receives an iCalendar with DTSTART but without DTEND + logger.fine("dtEnd $tsEnd == dtStart, won't generate DTEND property")*/ + else /* tsEnd > tsStart */ { + val endTz = row.getAsString(Events.EVENT_END_TIMEZONE)?.let { tzId -> + tzRegistry.getTimeZone(tzId) + } ?: startTz + to.dtEnd = DtEnd(DateTime(tsEnd).apply { + if (endTz != null) { + if (TimeZones.isUtc(endTz)) + isUtc = true + else + timeZone = endTz + } + }) + } + } + + } + + // recurrence + try { + row.getAsString(Events.RRULE)?.let { rulesStr -> + for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) + to.rRules += RRule(rule) + } + row.getAsString(Events.RDATE)?.let { datesStr -> + val rDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, tzRegistry, allDay, tsStart) { RDate(it) } + to.rDates += rDate + } + + row.getAsString(Events.EXRULE)?.let { rulesStr -> + for (rule in rulesStr.split(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR)) + to.exRules += ExRule(null, rule) + } + row.getAsString(Events.EXDATE)?.let { datesStr -> + val exDate = AndroidTimeUtils.androidStringToRecurrenceSet(datesStr, tzRegistry, allDay) { ExDate(it) } + to.exDates += exDate + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) + } + + to.uid = row.getAsString(Events.UID_2445) + to.sequence = row.getAsInteger(COLUMN_SEQUENCE) + to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) + + to.summary = row.getAsString(Events.TITLE) + to.location = row.getAsString(Events.EVENT_LOCATION) + to.description = row.getAsString(Events.DESCRIPTION) + + // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) + to.color = + row.getAsString(Events.EVENT_COLOR_KEY)?.let { name -> // try color key first + try { + Css3Color.valueOf(name) + } catch (_: IllegalArgumentException) { + logger.warning("Ignoring unknown color name \"$name\"") + null + } + } ?: + row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value + Css3Color.entries.firstOrNull { it.argb == color } + } + + // status + when (row.getAsInteger(Events.STATUS)) { + Events.STATUS_CONFIRMED -> to.status = Status.VEVENT_CONFIRMED + Events.STATUS_TENTATIVE -> to.status = Status.VEVENT_TENTATIVE + Events.STATUS_CANCELED -> to.status = Status.VEVENT_CANCELLED + } + + // availability + to.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE + + // scheduling + if (groupScheduled) { + // ORGANIZER must only be set for group-scheduled events (= events with attendees) + if (row.containsKey(Events.ORGANIZER)) + try { + to.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null)) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) + } + } + + // classification + when (row.getAsInteger(Events.ACCESS_LEVEL)) { + Events.ACCESS_PUBLIC -> to.classification = Clazz.PUBLIC + Events.ACCESS_PRIVATE -> to.classification = Clazz.PRIVATE + Events.ACCESS_CONFIDENTIAL -> to.classification = Clazz.CONFIDENTIAL + } + + // exceptions from recurring events + row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> + val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 + val originalDate = + if (originalAllDay) + Date(originalInstanceTime) + else + DateTime(originalInstanceTime) + if (originalDate is DateTime) { + to.dtStart?.let { dtStart -> + if (dtStart.isUtc) + originalDate.isUtc = true + else if (dtStart.timeZone != null) + originalDate.timeZone = dtStart.timeZone + } + } + to.recurrenceId = RecurrenceId(originalDate) + } + } + + private fun populateAttendee(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event attendee from calender provider", row) + + try { + val attendee: Attendee + val email = row.getAsString(Attendees.ATTENDEE_EMAIL) + val idNS = row.getAsString(Attendees.ATTENDEE_ID_NAMESPACE) + val id = row.getAsString(Attendees.ATTENDEE_IDENTITY) + + if (idNS != null || id != null) { + // attendee identified by namespace and ID + attendee = Attendee(URI(idNS, id, null)) + email?.let { attendee.parameters.add(Email(it)) } + } else + // attendee identified by email address + attendee = Attendee(URI("mailto", email, null)) + val params = attendee.parameters + + // always add RSVP (offer attendees to accept/decline) + params.add(Rsvp.TRUE) + + row.getAsString(Attendees.ATTENDEE_NAME)?.let { cn -> params.add(Cn(cn)) } + + // type/relation mapping is complex and thus outsourced to AttendeeMappings + AttendeeMappings.androidToICalendar(row, attendee) + + // status + when (row.getAsInteger(Attendees.ATTENDEE_STATUS)) { + Attendees.ATTENDEE_STATUS_INVITED -> params.add(PartStat.NEEDS_ACTION) + Attendees.ATTENDEE_STATUS_ACCEPTED -> params.add(PartStat.ACCEPTED) + Attendees.ATTENDEE_STATUS_DECLINED -> params.add(PartStat.DECLINED) + Attendees.ATTENDEE_STATUS_TENTATIVE -> params.add(PartStat.TENTATIVE) + Attendees.ATTENDEE_STATUS_NONE -> { /* no information, don't add PARTSTAT */ } + } + + to.attendees.add(attendee) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e) + } + } + + private fun populateReminder(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event reminder from calender provider", row) + + val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) + + val props = alarm.properties + when (row.getAsInteger(Reminders.METHOD)) { + Reminders.METHOD_EMAIL -> { + val accountName = calendar.account.name + if (Patterns.EMAIL_ADDRESS.matcher(accountName).matches()) { + props += Action.EMAIL + // ACTION:EMAIL requires SUMMARY, DESCRIPTION, ATTENDEE + props += Summary(to.summary) + props += Description(to.description ?: to.summary) + // Android doesn't allow to save email reminder recipients, so we always use the + // account name (should be account owner's email address) + props += Attendee(URI("mailto", calendar.account.name, null)) + } else { + logger.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY") + props += Action.DISPLAY + props += Description(to.summary) + } + } + + // default: set ACTION:DISPLAY (requires DESCRIPTION) + else -> { + props += Action.DISPLAY + props += Description(to.summary) + } + } + to.alarms += alarm + } + + private fun populateExtended(row: ContentValues, to: Event) { + val name = row.getAsString(ExtendedProperties.NAME) + val rawValue = row.getAsString(ExtendedProperties.VALUE) + logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) + + try { + when (name) { + EXTNAME_CATEGORIES -> + to.categories += rawValue.split(CATEGORIES_SEPARATOR) + + EXTNAME_URL -> + try { + to.url = URI(rawValue) + } catch(_: URISyntaxException) { + logger.warning("Won't process invalid local URL: $rawValue") + } + + EXTNAME_ICAL_UID -> + // only consider iCalUid when there's no uid + if (to.uid == null) + to.uid = rawValue + + UnknownProperty.CONTENT_ITEM_TYPE -> + to.unknownProperties += UnknownProperty.fromJsonString(rawValue) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse extended property", e) + } + } + + private fun populateExceptions(to: Event) { + calendar.client.query(Events.CONTENT_URI.asSyncAdapter(calendar.account), + null, + Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c -> + while (c.moveToNext()) { + val values = c.toContentValues() + try { + val exception = AndroidEvent(calendar, values) + val exceptionEvent = exception.event!! + val recurrenceId = exceptionEvent.recurrenceId!! + + // generate EXDATE instead of RECURRENCE-ID exceptions for cancelled instances + if (exceptionEvent.status == Status.VEVENT_CANCELLED) { + val list = DateList( + if (DateUtils.isDate(recurrenceId)) Value.DATE else Value.DATE_TIME, + recurrenceId.timeZone + ) + list.add(recurrenceId.date) + to.exDates += ExDate(list).apply { + if (DateUtils.isDateTime(recurrenceId)) { + if (recurrenceId.isUtc) + setUtc(true) + else + timeZone = recurrenceId.timeZone + } + } + + } else /* exceptionEvent.status != Status.VEVENT_CANCELLED */ { + // make sure that all components have the same ORGANIZER [RFC 6638 3.1] + exceptionEvent.organizer = to.organizer + + // add exception to list of exceptions + to.exceptions += exceptionEvent + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't find exception details", e) + } + } + } + } + + private fun useRetainedClassification(event: Event) { + var retainedClazz: Clazz? = null + val it = event.unknownProperties.iterator() + while (it.hasNext()) { + val prop = it.next() + if (prop is Clazz) { + retainedClazz = prop + it.remove() + } + } + + if (event.classification == null) + // no classification, use retained one if possible + event.classification = retainedClazz + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt similarity index 93% rename from lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt rename to lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt index dbee7016..25969c1d 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AttendeeMappings.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AttendeeMappings.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.synctools.mapping.calendar import android.content.ContentValues import android.provider.CalendarContract @@ -17,7 +17,7 @@ import net.fortuna.ical4j.model.parameter.Role import net.fortuna.ical4j.model.property.Attendee /** - * Defines mappings between Android [CalendarContract.Attendees] and iCalendar parameters. + * Defines mappings between Android [Attendees] and iCalendar parameters. * * Because the available Android values are quite different from the one in iCalendar, the * mapping is very lossy. Some special mapping rules are defined: @@ -88,8 +88,8 @@ object AttendeeMappings { /** - * Maps iCalendar [CuType] and [Role] to Android [Attendees.ATTENDEE_TYPE] and - * [Attendees.ATTENDEE_RELATIONSHIP] according to this matrix: + * Maps iCalendar [CuType] and [Role] to Android [CalendarContract.AttendeesColumns.ATTENDEE_TYPE] and + * [CalendarContract.AttendeesColumns.ATTENDEE_RELATIONSHIP] according to this matrix: * * CuType ↓ / Role → CHAIR REQ-PARTICIPANT¹ᴰ OPT-PARTICIPANT NON-PARTICIPANT * INDIVIDUALᴰ req,spk req,att opt,att non,att @@ -113,8 +113,8 @@ object AttendeeMappings { val type: Int var relationship: Int - val cuType = attendee.getParameter(Parameter.CUTYPE) ?: CuType.INDIVIDUAL - val role = attendee.getParameter(Parameter.ROLE) ?: Role.REQ_PARTICIPANT + val cuType = attendee.getParameter(Parameter.CUTYPE) ?: CuType.INDIVIDUAL + val role = attendee.getParameter(Parameter.ROLE) ?: Role.REQ_PARTICIPANT when (cuType) { CuType.RESOURCE -> {