diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2Test.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2Test.kt index 572d2cc0..295823bd 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2Test.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2Test.kt @@ -523,15 +523,6 @@ class LegacyAndroidEventBuilder2Test { assertEquals(TimeZones.UTC_ID, entity.entityValues.get(Events.EVENT_END_TIMEZONE)) } - @Test - fun testBuildEvent_Summary() { - buildEvent(true) { - summary = "Sample Summary" - }.let { result -> - assertEquals("Sample Summary", result.entityValues.getAsString(Events.TITLE)) - } - } - @Test fun testBuildEvent_Location() { buildEvent(true) { @@ -1290,7 +1281,6 @@ class LegacyAndroidEventBuilder2Test { 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)) } } } @@ -1318,7 +1308,6 @@ class LegacyAndroidEventBuilder2Test { 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)) } } } @@ -1345,7 +1334,6 @@ class LegacyAndroidEventBuilder2Test { 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)) } } } @@ -1372,7 +1360,6 @@ class LegacyAndroidEventBuilder2Test { 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)) } } } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jDateHelpers.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jDateHelpers.kt new file mode 100644 index 00000000..46e642a6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jDateHelpers.kt @@ -0,0 +1,13 @@ +/* + * 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.icalendar + +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime + +fun Date.isAllDay() = + this !is DateTime \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jHelpers.kt similarity index 84% rename from lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt rename to lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jHelpers.kt index 30ad32a4..3f9fcfce 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/icalendar/ical4jHelpers.kt @@ -11,6 +11,8 @@ import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.component.CalendarComponent +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.Sequence import net.fortuna.ical4j.model.property.Uid @@ -42,3 +44,6 @@ val CalendarComponent.recurrenceId: RecurrenceId? val CalendarComponent.sequence: Sequence? get() = getProperty(Property.SEQUENCE) + +fun CalendarComponent.isRecurring() = + getProperties(Property.RRULE).isNotEmpty() || getProperties(Property.RDATE).isNotEmpty() \ No newline at end of file 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..7ebac8a3 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilder.kt @@ -0,0 +1,104 @@ +/* + * 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 androidx.annotation.VisibleForTesting +import at.bitfire.ical4android.Event +import at.bitfire.synctools.icalendar.AssociatedEvents +import at.bitfire.synctools.icalendar.isRecurring +import at.bitfire.synctools.mapping.calendar.builder.AndroidEventFieldBuilder +import at.bitfire.synctools.mapping.calendar.builder.OriginalInstanceTimeBuilder +import at.bitfire.synctools.mapping.calendar.builder.TitleBuilder +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import at.bitfire.synctools.storage.calendar.EventAndExceptions +import net.fortuna.ical4j.model.component.VEvent + +/** + * Maps + * + * - an iCalendar object that contains VEVENTs and exceptions ([AssociatedEvents]) to + * - Android event data rows (including exceptions → [EventAndExceptions]). + * + * The built fields must contain `null` values for empty fields so that they can be used for updates. + * + * _Migration note:_ Until a fields are built by [AndroidEventFieldBuilder]s, the rest is still + * generated by [LegacyAndroidEventBuilder2]. + */ +class AndroidEventBuilder( + // AndroidEvent-level fields + private val syncId: String, + private val eTag: String?, + private val scheduleTag: String?, + private val flags: Int, + + // for new builders + private val associatedEvents: AssociatedEvents, + + // only for legacy builder + private val androidCalendar: AndroidCalendar, + private val event: Event, + private val id: Long? +) { + + fun build(): EventAndExceptions? { + // start with values from new builders + val eventAndExceptions = buildNew() + if (eventAndExceptions == null) { + // builder has returned null to indicate that the main Entity must be discarded. + // Without a main Entity, EventAndExceptions can't be generated, so return null here. + return null + } + + // merge with legacy EventAndExceptions + val legacyEventAndExceptions = LegacyAndroidEventBuilder2( + androidCalendar, event, id, syncId, eTag, scheduleTag, flags + ).build() + + // ORIGINAL_INSTANCE_TIME must be set and the same for exceptions to be merged! + // So ORIGINAL_INSTANCE_TIME must always be provided by both the old and the new builder. + eventAndExceptions.mergeFrom(legacyEventAndExceptions) + + return eventAndExceptions + } + + private fun buildNew(): EventAndExceptions? { + val mainEvent = associatedEvents.main ?: fakeMainEvent(associatedEvents.exceptions) + val mayHaveExceptions = mainEvent.isRecurring() + + return EventAndExceptions( + main = buildEvent(from = mainEvent, main = mainEvent) ?: return null, + exceptions = + if (mayHaveExceptions) + associatedEvents.exceptions.mapNotNull { exception -> + buildEvent(from = exception, main = mainEvent) + } + else + emptyList() + ) + } + + @VisibleForTesting + internal fun buildEvent(from: VEvent, main: VEvent): Entity? { + val result = Entity(ContentValues()) + for (builder in getBuilders()) + if (!builder.build(from = from, main = main, to = result)) { + // discard entity when builder returns null + return null + } + return result + } + + fun getBuilders(): List = listOf( + OriginalInstanceTimeBuilder(), + TitleBuilder() + ) + + private fun fakeMainEvent(forExceptions: List): VEvent = TODO() + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2.kt index b8e22a43..278edc8f 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventBuilder2.kt @@ -13,6 +13,7 @@ import android.provider.CalendarContract.Colors import android.provider.CalendarContract.Events import android.provider.CalendarContract.ExtendedProperties import android.provider.CalendarContract.Reminders +import androidx.annotation.OpenForTesting import androidx.core.content.contentValuesOf import at.bitfire.ical4android.Event import at.bitfire.ical4android.ICalendar @@ -60,6 +61,7 @@ import java.util.logging.Logger * 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. */ +@Deprecated("Use AndroidEventBuilder instead") class LegacyAndroidEventBuilder2( private val calendar: AndroidCalendar, private val event: Event, @@ -129,7 +131,8 @@ class LegacyAndroidEventBuilder2( * * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source */ - private fun buildEventRow(recurrence: Event?): ContentValues { + @OpenForTesting + internal fun buildEventRow(recurrence: Event?): ContentValues { // start with object-level (AndroidEvent) fields val row = contentValuesOf( Events.CALENDAR_ID to calendar.id, @@ -168,25 +171,27 @@ class LegacyAndroidEventBuilder2( row.put(Events.ORIGINAL_SYNC_ID, syncId) row.put(Events.ORIGINAL_ALL_DAY, if (DateUtils.isDate(event.dtStart)) 1 else 0) - var recurrenceDate = from.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() + from.recurrenceId?.date?.let { recurrenceDate -> + var alignedRecurrenceDate = recurrenceDate + 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() + alignedRecurrenceDate = 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() + ) + alignedRecurrenceDate = zonedTime.toIcal4jDateTime() + } + row.put(Events.ORIGINAL_INSTANCE_TIME, alignedRecurrenceDate.time) } - row.put(Events.ORIGINAL_INSTANCE_TIME, recurrenceDate.time) } // UID, sequence @@ -194,7 +199,7 @@ class LegacyAndroidEventBuilder2( row.put(AndroidEvent2.COLUMN_SEQUENCE, from.sequence) // time fields - row.put(Events.DTSTART, dtStart.date.time) + row.put(Events.DTSTART, dtStart.date?.time) row.put(Events.ALL_DAY, if (allDay) 1 else 0) row.put(Events.EVENT_TIMEZONE, AndroidTimeUtils.storageTzId(dtStart)) @@ -318,7 +323,7 @@ class LegacyAndroidEventBuilder2( } AndroidTimeUtils.androidifyTimeZone(dtEnd, tzRegistry) - row.put(Events.DTEND, dtEnd.date.time) + row.put(Events.DTEND, dtEnd.date?.time) row.put(Events.EVENT_END_TIMEZONE, AndroidTimeUtils.storageTzId(dtEnd)) row.putNull(Events.DURATION) row.putNull(Events.RRULE) @@ -328,7 +333,7 @@ class LegacyAndroidEventBuilder2( } // text fields - row.put(Events.TITLE, from.summary) + // Events.TITLE done by TitleBuilder row.put(Events.EVENT_LOCATION, from.location) row.put(Events.DESCRIPTION, from.description) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidEventFieldBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidEventFieldBuilder.kt new file mode 100644 index 00000000..9a833485 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/AndroidEventFieldBuilder.kt @@ -0,0 +1,34 @@ +/* + * 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.builder + +import android.content.Entity +import net.fortuna.ical4j.model.component.VEvent + +interface AndroidEventFieldBuilder { + + /** + * Maps the given event into the provided [Entity]. + * + * 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 the referential equality operator can be used to see if a main event or an exception is built: + * + * ``` + * val buildsMainEvent = from === main + * ``` + * + * @param from event to map to + * @param main main event + * @param to destination object where built values are put into + * + * @return whether the resulting entity may be used; `false`: resulting entity should be discarded + */ + fun build(from: VEvent, main: VEvent, to: Entity): Boolean + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt new file mode 100644 index 00000000..c0150fe9 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilder.kt @@ -0,0 +1,89 @@ +/* + * 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.builder + +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +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.synctools.icalendar.isAllDay +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.component.VEvent +import java.time.ZonedDateTime +import java.util.logging.Logger + +class OriginalInstanceTimeBuilder: AndroidEventFieldBuilder { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun build(from: VEvent, main: VEvent, to: Entity): Boolean { + // Skip if this builder isn't building an exception. + if (from === main) { + to.entityValues.putNull(Events.ORIGINAL_INSTANCE_TIME) + return true + } + + /* + Discard resulting entity as invalid if + - the exception doesn't have a RECURRENCE-ID, or + - the main event doesn't have a DTSTART. + */ + val recurrenceDate = from.recurrenceId?.date + if (recurrenceDate == null) { + logger.warning("Ignoring exception without RECURRENCE-ID") + return false + } + + val mainStartDate = main.startDate?.date + if (mainStartDate == null) { + logger.warning("Ignoring exception because main event doesn't have DTSTART") + return false + } + + // align RECURRENCE-ID with value type (date/date-time) of main event's DTSTART + val alignedRecurrenceDate = alignRecurrenceId( + recurrenceDate = recurrenceDate, + mainStartDate = mainStartDate + ) + + val values = contentValuesOf( + Events.ORIGINAL_INSTANCE_TIME to alignedRecurrenceDate.time + ) + + to.entityValues.putAll(values) + return true + } + + private fun alignRecurrenceId(recurrenceDate: Date, mainStartDate: Date): Date { + if (mainStartDate.isAllDay() && recurrenceDate is DateTime) { + // main event is DATE, but RECURRENCE-ID is DATE-TIME → change RECURRENCE-ID to DATE + val localDate = recurrenceDate.toLocalDate() + return Date(localDate.toIcal4jDate()) + + } else if (mainStartDate is DateTime && recurrenceDate.isAllDay()) { + // main event is DATE-TIME, but RECURRENCE-ID is DATE → change RECURRENCE-ID to DATE-TIME + val localDate = recurrenceDate.toLocalDate() + // guess time and time zone from DTSTART + val zonedTime = ZonedDateTime.of( + localDate, + mainStartDate.toLocalTime(), + mainStartDate.requireZoneId() + ) + return zonedTime.toIcal4jDateTime() + } + + // no alignment needed + return recurrenceDate + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilder.kt new file mode 100644 index 00000000..f926aef5 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilder.kt @@ -0,0 +1,22 @@ +/* + * 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.builder + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.vcard4android.Utils.trimToNull +import net.fortuna.ical4j.model.component.VEvent + +class TitleBuilder: AndroidEventFieldBuilder { + + override fun build(from: VEvent, main: VEvent, to: Entity): Boolean { + val summary = from.summary?.value + to.entityValues.put(Events.TITLE, summary.trimToNull()) + return true + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/EventAndExceptions.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/EventAndExceptions.kt index b46ae523..1742cd77 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/EventAndExceptions.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/EventAndExceptions.kt @@ -7,6 +7,10 @@ package at.bitfire.synctools.storage.calendar import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2 +import java.util.LinkedList +import java.util.logging.Logger /** * Represents a set of local events (like [at.bitfire.synctools.storage.calendar.AndroidEvent2] values) @@ -18,4 +22,47 @@ import android.content.Entity data class EventAndExceptions( val main: Entity, val exceptions: List -) \ No newline at end of file +) { + + private val logger + get() = Logger.getLogger(javaClass.name) + + /** + * Merges the current object with another one. Only needed until [LegacyAndroidEventBuilder2] is dropped. + */ + @Deprecated("Remove when LegacyAndroidEventBuilder2 is dropped") + fun mergeFrom(other: EventAndExceptions) { + // merge main entity + merge(into = main, from = other.main) + + // merge exceptions by ORIGINAL_INSTANCE_TIME + val otherExceptions = LinkedList(other.exceptions) + for (exception in exceptions) { + val ourOriginalInstanceTime = exception.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + val otherException = otherExceptions.firstOrNull { + it.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME) == ourOriginalInstanceTime + } + if (otherException != null) { + // we found an exception in other with same instance time, merge + merge(into = exception, from = otherException) + + // remove from otherExceptions so that it won't be found again + otherExceptions.remove(otherException) + } else + logger.warning("Couldn't find other exception for ORIGINAL_INSTANCE_TIME=$ourOriginalInstanceTime") + } + + if (otherExceptions.isNotEmpty()) + logger.warning("${otherExceptions.size} leftover exception(s) couldn't be merged") + } + + private fun merge(into: Entity, from: Entity) { + // merge values + into.entityValues.putAll(from.entityValues) + + // merge sub-values + for (sub in from.subValues) + into.addSubValue(sub.uri, sub.values) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/OriginalInstanceTimeEqualityTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/OriginalInstanceTimeEqualityTest.kt new file mode 100644 index 00000000..1218be65 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/OriginalInstanceTimeEqualityTest.kt @@ -0,0 +1,174 @@ +/* + * 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.Events +import at.bitfire.ical4android.Event +import at.bitfire.synctools.icalendar.propertyListOf +import at.bitfire.synctools.storage.calendar.AndroidCalendar +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * This class tests with the new and the old builder generate the same RECURRENCE-IDs for given exception events. + * + * This is very important in order to merge the exception Entities generated by the new and old builders correctly. + */ +@Deprecated("Remove when LegacyAndroidEventBuilder2 is dropped") +@RunWith(RobolectricTestRunner::class) +class OriginalInstanceTimeEqualityTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val tzVienna = TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Vienna") + + + @Test + fun `Main event`() { + val main = VEvent(propertyListOf( + DtStart(), + RecurrenceId() + )) + verify(null, main, main, Event( + dtStart = DtStart(), + recurrenceId = RecurrenceId() + ), null) + } + + @Test + fun `Exception without RECURRENCE-ID, DTSTART is DATE-TIME`() { + val legacy = Event( + dtStart = DtStart() + ) + verify(null, + from = VEvent(), + main = VEvent(propertyListOf( + DtStart() + )), + legacy, legacy + ) + } + + + @Test + fun `RECURRENCE-ID is DATE, DTSTART is DATE`() { + val legacy = Event( + dtStart = DtStart(Date("20250812")), + recurrenceId = RecurrenceId(Date("20250812")) + ) + verify(1754956800000, + from = VEvent(propertyListOf( + RecurrenceId(Date("20250812")) + )), + main = VEvent(propertyListOf( + DtStart(Date("20250812")), + )), + legacy, legacy + ) + } + + @Test + fun `RECURRENCE-ID is DATE, DTSTART is DATE-TIME`() { + val legacy = Event( + dtStart = DtStart(DateTime("20250812T202912", tzVienna)), + recurrenceId = RecurrenceId(Date("20250812")) + ) + verify(1755023352000, + from = VEvent(propertyListOf( + RecurrenceId(Date("20250812")) + )), + main = VEvent(propertyListOf( + DtStart(DateTime("20250812T202912", tzVienna)), + )), + legacy, legacy + ) + } + + @Test + fun `RECURRENCE-ID is DATE-TIME, DTSTART is DATE`() { + val legacy = Event( + dtStart = DtStart(Date("20250812")), + recurrenceId = RecurrenceId(DateTime("20250812T202912", tzVienna)) + ) + verify(1754956800000, + from = VEvent(propertyListOf( + RecurrenceId(DateTime("20250812T202912", tzVienna)) + )), + main = VEvent(propertyListOf( + DtStart(Date("20250812")), + )), + legacy, legacy + ) + } + + @Test + fun `RECURRENCE-ID is DATE-TIME, DTSTART is DATE-TIME`() { + val legacy = Event( + dtStart = DtStart(DateTime("20250812T202912", tzVienna)), + recurrenceId = RecurrenceId(DateTime("20250812T202912", tzVienna)) + ) + verify(1755023352000, + from = VEvent(propertyListOf( + RecurrenceId(DateTime("20250812T202912", tzVienna)) + )), + main = VEvent(propertyListOf( + DtStart(DateTime("20250812T202912", tzVienna)), + )), + legacy, legacy + ) + } + + + // helpers + + private fun fromLegacyBuilder(event: Event, recurrence: Event?): Long? { + val values = LegacyAndroidEventBuilder2( + calendar = mockk(relaxed = true), + event = event, + id = null, + syncId = "sync-id", + eTag = null, + scheduleTag = null, + flags = 0 + ).buildEventRow(recurrence) + return values.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + } + + private fun fromNewBuilder(from: VEvent, main: VEvent): Long? { + val values = AndroidEventBuilder( + syncId = "sync-id", + eTag = null, + scheduleTag = null, + flags = 0, + associatedEvents = mockk(), + androidCalendar = mockk(), + event = mockk(), + id = null + ).buildEvent(from, main)?.entityValues + return values?.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + } + + private fun verify(expected: Long?, from: VEvent, main: VEvent, legacy: Event, recurrence: Event?) { + val legacy = fromLegacyBuilder(legacy, recurrence) + val modern = fromNewBuilder(from, main) + assertEquals("Legacy builder failed", expected, legacy) + assertEquals("New builder failed", expected, modern) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt new file mode 100644 index 00000000..b03c43a2 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/OriginalInstanceTimeBuilderTest.kt @@ -0,0 +1,103 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OriginalInstanceTimeBuilderTest { + + private val builder = OriginalInstanceTimeBuilder() + + @Test + fun `Skips main event`() { + val main = recurring() + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = main, + main = main, + to = result + )) + } + + @Test + fun `Sets ORIGINAL_INSTANCE_TIME (all-day main event, all-day exception)`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = recurring(time = Date("20250811")), + main = recurring(time = Date()), + to = result + )) + assertEquals( + 1754870400000, // Mon Aug 11 2025 00:00:00 GMT+0000 + result.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + ) + } + + @Test + fun `Sets ORIGINAL_INSTANCE_TIME (all-day main event, non-all-day exception)`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = recurring(time = DateTime(1754917615000)), // Mon Aug 11 2025 13:06:55 GMT+0000 + main = recurring(time = Date()), + to = result + )) + assertEquals( + 1754870400000, // Mon Aug 11 2025 00:00:00 GMT+0000 + result.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + ) + } + + @Test + fun `Sets ORIGINAL_INSTANCE_TIME (non-all-day main event, all-day exception)`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + main = recurring(time = DateTime(1754917615000)), // Mon Aug 11 2025 13:06:55 GMT+0000 + from = recurring(time = Date("20250812")), + to = result + )) + assertEquals( + 1755004015000, // 2025/08/12, but at 13:06:55 GMT+0000 + result.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME) + ) + } + + @Test + fun `Sets ORIGINAL_INSTANCE_TIME (non-all-day main event, non-all-day exception)`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = recurring(time = DateTime(1754917615000)), // Mon Aug 11 2025 13:06:55 GMT+0000 + main = recurring(time = DateTime()), + to = result + )) + assertEquals(1754917615000, result.entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME)) + } + + + private fun recurring(time: Date = Date()) = VEvent( + /* start = */ time, + /* end = */ time, + /* summary = */ "Some Event" + ).apply { + // add required fields for exception + properties += RecurrenceId(time) + properties += RRule("FREQ=DAILY;COUNT=5") + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilderTest.kt new file mode 100644 index 00000000..7d472bd2 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/TitleBuilderTest.kt @@ -0,0 +1,66 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.synctools.icalendar.propertyListOf +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.Summary +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 + +@RunWith(RobolectricTestRunner::class) +class TitleBuilderTest { + + private val builder = TitleBuilder() + + @Test + fun `SUMMARY is blank`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = VEvent(propertyListOf( + Summary(" ") + )), + main = VEvent(), + to = result + )) + assertTrue(result.entityValues.containsKey(Events.TITLE)) + assertNull(result.entityValues.get(Events.TITLE)) + } + + @Test + fun `SUMMARY is text`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = VEvent(propertyListOf( + Summary("Some Text ") + )), + main = VEvent(), + to = result + )) + assertEquals("Some Text", result.entityValues.getAsString(Events.TITLE)) + } + + @Test + fun `No SUMMARY`() { + val result = Entity(ContentValues()) + assertTrue(builder.build( + from = VEvent(), + main = VEvent(), + to = result + )) + assertTrue(result.entityValues.containsKey(Events.TITLE)) + assertNull(result.entityValues.get(Events.TITLE)) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/storage/EventAndExceptionsTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/storage/EventAndExceptionsTest.kt new file mode 100644 index 00000000..b34503ae --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/storage/EventAndExceptionsTest.kt @@ -0,0 +1,124 @@ +/* + * 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.storage + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.synctools.storage.calendar.EventAndExceptions +import at.bitfire.synctools.test.assertEntitiesEqual +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EventAndExceptionsTest { + + @Test + fun `merge main values`() { + val into = EventAndExceptions( + main = Entity(contentValuesOf( + "name1" to "value1", + "name2" to "value2" + )), + exceptions = emptyList() + ) + into.mergeFrom(EventAndExceptions( + main = Entity(contentValuesOf( + "name2" to null, + "name3" to 123 + )), + exceptions = emptyList() + )) + assertEntitiesEqual( + Entity(contentValuesOf( + "name1" to "value1", + "name2" to null, + "name3" to 123 + )), + into.main + ) + } + + @Test + fun `merge exception by ORIGINAL_INSTANCE_TIME`() { + val into = EventAndExceptions( + main = Entity(ContentValues()), + exceptions = listOf( + Entity(contentValuesOf( + "name1" to "value1", + "name3" to "value3", + Events.ORIGINAL_INSTANCE_TIME to 123 + )) + ) + ) + into.mergeFrom(EventAndExceptions( + main = Entity(ContentValues()), + exceptions = listOf( + Entity(contentValuesOf( + "name1" to "name1a", + "name2" to "value2", + Events.ORIGINAL_INSTANCE_TIME to 123 + )) + ) + )) + assertEntitiesEqual( + Entity(contentValuesOf( + "name1" to "name1a", + "name2" to "value2", + "name3" to "value3", + Events.ORIGINAL_INSTANCE_TIME to 123 + )), + into.exceptions.first() + ) + } + + @Test + fun `leftover other exception`() { + val into = EventAndExceptions( + main = Entity(ContentValues()), + exceptions = emptyList() + ) + into.mergeFrom(EventAndExceptions( + main = Entity(ContentValues()), + exceptions = listOf( + Entity(contentValuesOf( + "name1" to "value1", + Events.ORIGINAL_INSTANCE_TIME to 123 + )) + ) + )) + assertTrue(into.exceptions.isEmpty()) + } + + @Test + fun `missing other exception`() { + val into = EventAndExceptions( + main = Entity(ContentValues()), + exceptions = listOf( + Entity(contentValuesOf( + "name1" to "value1", + Events.ORIGINAL_INSTANCE_TIME to 123 + )) + ) + ) + into.mergeFrom(EventAndExceptions( + main = Entity(ContentValues()), + exceptions = emptyList() + )) + assertEntitiesEqual( + Entity(contentValuesOf( + "name1" to "value1", + Events.ORIGINAL_INSTANCE_TIME to 123 + )), + into.exceptions.first() + ) + } + +} \ No newline at end of file