diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index acc39f19..f5efed67 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -15,7 +15,6 @@ 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.Event @@ -35,15 +34,7 @@ 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 @@ -53,16 +44,13 @@ 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 /** * Tests mapping from [at.bitfire.synctools.storage.calendar.EventAndExceptions] to [Event]. @@ -159,41 +147,6 @@ class LegacyAndroidEventProcessorTest { return LegacyAndroidCalendar(destinationCalendar).getEvent(androidEvent.id)!! } - @Test - fun testPopulateEvent_Uid_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent2.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( - AndroidEvent2.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() { @@ -606,316 +559,6 @@ class LegacyAndroidEventProcessorTest { } - 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() diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt index 593c8226..619e01e7 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -11,8 +11,6 @@ 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.Event import at.bitfire.ical4android.UnknownProperty import at.bitfire.ical4android.util.AndroidTimeUtils @@ -21,22 +19,18 @@ 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.mapping.calendar.processor.AndroidEventFieldProcessor +import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor +import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor +import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions 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 @@ -46,7 +40,6 @@ 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 @@ -76,11 +69,22 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + private val fieldProcessors: Array = arrayOf( + UidProcessor(), + AttendeesProcessor(), + RemindersProcessor(accountName) + ) + fun populate(eventAndExceptions: EventAndExceptions, to: Event) { - populateEvent(eventAndExceptions.main, to = to) + populateEvent( + entity = eventAndExceptions.main, + main = eventAndExceptions.main, + to = to + ) populateExceptions( exceptions = eventAndExceptions.exceptions, + main = eventAndExceptions.main, originalAllDay = DateUtils.isDate(to.dtStart), to = to ) @@ -94,25 +98,25 @@ class LegacyAndroidEventProcessor( * an [Event] data object. * * @param entity event row as returned by the calendar provider - * @param groupScheduled whether the event is group-scheduled (= the main event has attendees) + * @param main main event row as returned by the calendar provider * @param to destination data object */ - private fun populateEvent(entity: Entity, to: Event) { - // calculate some scheduling properties + private fun populateEvent(entity: Entity, main: Entity, to: Event) { + // legacy processors val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } - - // main row populateEventRow(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) } } + + // new processors + for (processor in fieldProcessors) + processor.process(from = entity, main = main, to = to) } private fun populateEventRow(row: ContentValues, groupScheduled: Boolean, to: Event) { @@ -236,7 +240,6 @@ class LegacyAndroidEventProcessor( logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) } - to.uid = row.getAsString(Events.UID_2445) to.sequence = row.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) @@ -306,79 +309,6 @@ class LegacyAndroidEventProcessor( } } - 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 -> { - 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", accountName, 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) @@ -396,11 +326,6 @@ class LegacyAndroidEventProcessor( logger.warning("Won't process invalid local URL: $rawValue") } - AndroidEvent2.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) } @@ -409,12 +334,12 @@ class LegacyAndroidEventProcessor( } } - private fun populateExceptions(exceptions: List, originalAllDay: Boolean, to: Event) { + private fun populateExceptions(exceptions: List, main: Entity, originalAllDay: Boolean, to: Event) { for (exception in exceptions) { val exceptionEvent = Event() // convert exception row to Event - populateEvent(exception, to = exceptionEvent) + populateEvent(exception, main, to = exceptionEvent) // exceptions are required to have a RECURRENCE-ID val recurrenceId = exceptionEvent.recurrenceId ?: continue diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt new file mode 100644 index 00000000..83f450ea --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt @@ -0,0 +1,36 @@ +/* + * 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.processor + +import android.content.Entity +import at.bitfire.ical4android.Event + +interface AndroidEventFieldProcessor { + + /** + * Takes specific data from an event (= event row plus data rows, taken from the content provider) + * and maps it to the [Event] data class. + * + * If [from] references the same object as [main], this method is called for a main event (not an exception). + * If [from] references another object as [main], this method is called for an exception (not a main event). + * + * So you can use (note the referential equality operator): + * + * ``` + * val isMainEvent = from === main // or + * val isException = from !== main + * ``` + * + * In a later step of refactoring, it should map to [net.fortuna.ical4j.model.component.VEvent]. + * + * @param from event from content provider + * @param main main event from content provider + * @param to destination object where the mapped data are stored + */ + fun process(from: Entity, main: Entity, to: Event) + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt new file mode 100644 index 00000000..9eabef2b --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt @@ -0,0 +1,75 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import at.bitfire.ical4android.Event +import at.bitfire.synctools.mapping.calendar.AttendeeMappings +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.property.Attendee +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import java.util.logging.Logger + +class AttendeesProcessor: AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: Event) { + for (row in from.subValues.filter { it.uri == Attendees.CONTENT_URI }) + populateAttendee(row.values, to) + } + + private fun populateAttendee(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event attendee from calendar 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) + } + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt new file mode 100644 index 00000000..265465ec --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt @@ -0,0 +1,68 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Reminders +import android.util.Patterns +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.Summary +import java.net.URI +import java.time.Duration +import java.util.logging.Level +import java.util.logging.Logger + +class RemindersProcessor( + private val accountName: String +): AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: Event) { + for (row in from.subValues.filter { it.uri == Reminders.CONTENT_URI }) + populateReminder(row.values, to) + } + + private fun populateReminder(row: ContentValues, to: Event) { + logger.log(Level.FINE, "Read event reminder from calendar provider", row) + + val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) + + val props = alarm.properties + when (row.getAsInteger(Reminders.METHOD)) { + Reminders.METHOD_EMAIL -> { + 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", accountName, 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 + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt new file mode 100644 index 00000000..415ab038 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt @@ -0,0 +1,32 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 + +class UidProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + // take from event row or Google Calendar extended property + to.uid = from.entityValues.getAsString(Events.UID_2445) ?: + uidFromExtendedProperties(from.subValues) + } + + private fun uidFromExtendedProperties(rows: List): String? { + val uidRow = rows.firstOrNull { + it.uri == ExtendedProperties.CONTENT_URI && + it.values.getAsString(ExtendedProperties.NAME) == AndroidEvent2.EXTNAME_ICAL_UID + } + + return uidRow?.values?.getAsString(ExtendedProperties.VALUE) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt new file mode 100644 index 00000000..c7dbfca7 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt @@ -0,0 +1,325 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.net.URI + +@RunWith(RobolectricTestRunner::class) +class AttendeesProcessorTest { + + private val processor = AttendeesProcessor() + + @Test + fun `Attendee is email address`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals(URI("mailto:attendee@example.com"), result.attendees.first().calAddress) + } + + @Test + fun `Attendee is other URI`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_ID_NAMESPACE to "https", + Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals(URI("https://example.com/principals/attendee"), result.attendees.first().calAddress) + } + + @Test + fun `Attendee is email address with other URI`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_ID_NAMESPACE to "https", + Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals(1, result.attendees.size) + val attendee = result.attendees.first() + assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) + assertEquals("attendee@example.com", attendee.getParameter(Parameter.EMAIL).value) + } + + + @Test + fun `Attendee with relationship ATTENDEE or ORGANIZER generates empty user-type`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with relationship PERFORMER generates user-type GROUP`() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with relationship SPEAKER generates chair role (user-type person)`() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with relationship SPEAKER generates chair role (user-type RESOURCE)`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + + @Test + fun `Attendee with relationship NONE generates user-type UNKNOWN`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_NONE, null)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) + } + } + + + @Test + fun `Attendee with type NONE doesn't generate ROLE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type REQUIRED doesn't generate ROLE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type OPTIONAL generates OPTIONAL role`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type RESOURCE generates user-type RESOURCE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with type RESOURCE (relationship PERFORMER) generates user-type ROOM`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) + } + + + @Test + fun `Attendee without participation status`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status INVITED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_INVITED + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status ACCEPTED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_ACCEPTED + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status DECLINED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_DECLINED + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status TENTATIVE`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_TENTATIVE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status NONE`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_NONE + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + + + @Test + fun `Attendee RSVP`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, entity, result) + val attendee = result.attendees.first() + assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt new file mode 100644 index 00000000..746f69b4 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt @@ -0,0 +1,108 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Action +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +class RemindersProcessorTest { + + private val accountName = "user@example.com" + private val processor = RemindersProcessor(accountName) + + @Test + fun `Email reminder`() { + // account name looks like an email address + assumeTrue(accountName.endsWith("@example.com")) + + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_EMAIL, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, entity, result) + val alarm = result.alarms.first() + assertEquals(Action.EMAIL, alarm.action) + assertNotNull(alarm.summary) + assertNotNull(alarm.description) + } + + @Test + fun `Email reminder (account name is not an email address)`() { + // test account name that doesn't look like an email address + val nonEmailAccountName = "ical4android" + val processor2 = RemindersProcessor(nonEmailAccountName) + + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_EMAIL, + Reminders.MINUTES to 10 + )) + val result = Event() + processor2.process(entity, entity, result) + val alarm = result.alarms.first() + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + + @Test + fun `Non-email reminder`() { + for (type in arrayOf(null, Reminders.METHOD_ALARM, Reminders.METHOD_ALERT, Reminders.METHOD_DEFAULT, Reminders.METHOD_SMS)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to type, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, entity, result) + val alarm = result.alarms.first() + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + } + + + @Test + fun `Number of minutes is positive`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_ALERT, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, entity, result) + val alarm = result.alarms.first() + assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) + } + + @Test + fun `Number of minutes is negative`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_ALERT, + Reminders.MINUTES to -10 + )) + val result = Event() + processor.process(entity, entity, result) + val alarm = result.alarms.first() + assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt new file mode 100644 index 00000000..07069509 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt @@ -0,0 +1,71 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UidProcessorTest { + + private val processor = UidProcessor() + + @Test + fun `No UID`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.uid) + } + + @Test + fun `UID from event row`() { + val entity = Entity(contentValuesOf( + Events.UID_2445 to "from-event" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("from-event", result.uid) + } + + @Test + fun `UID from extended row`() { + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_ICAL_UID, + ExtendedProperties.VALUE to "from-extended" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("from-extended", result.uid) + } + + @Test + fun `UID from event and extended row`() { + val entity = Entity(contentValuesOf( + Events.UID_2445 to "from-event" + )) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_ICAL_UID, + ExtendedProperties.VALUE to "from-extended" + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("from-event", result.uid) + } + +} \ No newline at end of file