diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 38cb6cad..6695a749 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -16,6 +16,7 @@ import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.impl.TestJtxCollection import at.bitfire.ical4android.impl.testProdId import at.bitfire.synctools.icalendar.ICalendarParser +import at.bitfire.synctools.icalendar.recurrenceId import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject @@ -27,6 +28,12 @@ import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Trigger +import net.fortuna.ical4j.model.property.immutable.ImmutableAction import org.junit.After import org.junit.Assert import org.junit.Assume @@ -853,6 +860,72 @@ class JtxICalObjectTest { } + @Test + fun getICalendarFormat_recurringVToDo_exceptionsMustHaveUidAndRecurrenceId() { + // Prepare data object with recurring VTODO + exception + val uid = "recurring-vtodo-uid@test" + val mainObject = JtxICalObject(collection!!).apply { + component = Component.VTODO.name + this.uid = uid + summary = "Recurring task" + dtstart = 1744970400000L // 2025-04-18T12:00:00Z + rrule = "FREQ=MONTHLY;UNTIL=20270417T110000Z" + } + + val recurId = "20250518T120000Z" + val exceptionObject = JtxICalObject(collection!!).apply { + component = Component.VTODO.name + this.uid = uid + summary = "Recurring task (exception)" + this.recurid = recurId + } + mainObject.recurInstances += exceptionObject + + // Generate iCalendar VTODO from data object + val ical = mainObject.getICalendarFormat(testProdId) + assertNotNull(ical) + + // Verify VTODOs + val vtodos = ical!!.getComponents(Component.VTODO.name) + assertEquals(2, vtodos.size) + assertTrue("VTODOs must have same UID", vtodos.all { it.uid.get().value == uid }) + + // Verify RECURRENCE-ID of exception + val exceptions = vtodos.filter { it.getProperty>(Property.RECURRENCE_ID).isPresent } + assertEquals(1, exceptions.size) + val exception = exceptions.first() + assertEquals(recurId, exception.recurrenceId?.value) + } + + @Test + fun getICalendarFormat_VToDo_AlarmHasProperties() { + val obj = JtxICalObject(collection!!).apply { + component = Component.VTODO.name + uid = "vtodo-alarm-test@test" + summary = "Task with alarm" + alarms += at.bitfire.ical4android.JtxICalObject.Alarm( + action = JtxContract.JtxAlarm.AlarmAction.DISPLAY.name, + triggerRelativeDuration = "-PT15M" + ) + } + + // Generate iCalendar VTODO from data object + val ical = obj.getICalendarFormat(testProdId) + assertNotNull(ical) + + // Extract VALARM + val vtodos = ical!!.getComponents(Component.VTODO.name) + assertEquals(1, vtodos.size) + val valarms = vtodos[0].getComponents(net.fortuna.ical4j.model.Component.VALARM) + assertEquals(1, valarms.size) + + // Verify VALARM properties + val valarm = valarms.first() + assertEquals(ImmutableAction.DISPLAY, valarm.getProperty(Property.ACTION).get()) + assertEquals("-PT15M", valarm.getProperty(Property.TRIGGER).get().value) + } + + /** * This function takes a file asserts if the ICalendar is the same before and after processing with getIncomingIcal and getOutgoingIcal * @param filename the filename to be processed diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 0d52f7b7..4d0a2dc5 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -11,6 +11,7 @@ import android.content.ContentValues import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Base64 +import androidx.annotation.VisibleForTesting import androidx.core.content.contentValuesOf import at.bitfire.ical4android.ICalendar.Companion.withUserAgents import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate @@ -183,7 +184,8 @@ open class JtxICalObject( var alarms: MutableList = mutableListOf() var unknown: MutableList = mutableListOf() - private var recurInstances: MutableList = mutableListOf() + @VisibleForTesting + internal var recurInstances: MutableList = mutableListOf() @@ -668,78 +670,88 @@ open class JtxICalObject( calComponent.propertyList = addProperties(calComponent.propertyList) // Need to re-set the immutable list ical += calComponent + // add alarm components alarms.forEach { alarm -> + val alarmProps = mutableListOf() + alarm.action?.let { + alarmProps += when (it) { + JtxContract.JtxAlarm.AlarmAction.DISPLAY.name -> ImmutableAction.DISPLAY + JtxContract.JtxAlarm.AlarmAction.AUDIO.name -> ImmutableAction.AUDIO + JtxContract.JtxAlarm.AlarmAction.EMAIL.name -> ImmutableAction.EMAIL + else -> return@let + } + } + + if (alarm.triggerRelativeDuration != null) { + alarmProps += Trigger().apply { + try { + val dur = java.time.Duration.parse(alarm.triggerRelativeDuration) + this.duration = dur - val vAlarm = VAlarm() - vAlarm.propertyList.apply { - alarm.action?.let { - when (it) { - JtxContract.JtxAlarm.AlarmAction.DISPLAY.name -> add(ImmutableAction.DISPLAY) - JtxContract.JtxAlarm.AlarmAction.AUDIO.name -> add(ImmutableAction.AUDIO) - JtxContract.JtxAlarm.AlarmAction.EMAIL.name -> add(ImmutableAction.EMAIL) - else -> return@let + // Add the RELATED parameter if present + alarm.triggerRelativeTo?.let { + if (it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name) + this += Related.START + if (it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name) + this += Related.END + } + } catch (e: DateTimeParseException) { + logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) } } - if(alarm.triggerRelativeDuration != null) { - add(Trigger().apply { - try { - val dur = java.time.Duration.parse(alarm.triggerRelativeDuration) - this.duration = dur - - // Add the RELATED parameter if present - alarm.triggerRelativeTo?.let { - if(it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name) - this += Related.START - if(it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name) - this += Related.END + } else if (alarm.triggerTime != null) { + alarmProps += Trigger().apply { + try { + when { + alarm.triggerTimezone == ZoneOffset.UTC.id || + alarm.triggerTimezone.isNullOrEmpty() -> + this.date = Instant.ofEpochMilli(alarm.triggerTime!!) + + else -> { + this.date = ZonedDateTime.ofInstant( + Instant.ofEpochMilli(alarm.triggerTime!!), + ZoneId.of(alarm.triggerTimezone) + ).toInstant() } - } catch (e: DateTimeParseException) { - logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) } - }) - - } else if (alarm.triggerTime != null) { - add(Trigger().apply { - try { - when { - alarm.triggerTimezone == ZoneOffset.UTC.id || - alarm.triggerTimezone.isNullOrEmpty() -> - this.date = Instant.ofEpochMilli(alarm.triggerTime!!) - else -> { - this.date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(alarm.triggerTime!!), ZoneId.of(alarm.triggerTimezone)).toInstant() - } - } - } catch (e: ParseException) { - logger.log(Level.WARNING, "TriggerTime could not be parsed.", e) - }}) + } catch (e: ParseException) { + logger.log(Level.WARNING, "TriggerTime could not be parsed.", e) + } } - alarm.summary?.let { add(Summary(it)) } - alarm.repeat?.let { add(Repeat().apply { value = it }) } - alarm.duration?.let { add(Duration().apply { + } + + alarm.summary?.let { alarmProps += Summary(it) } + alarm.repeat?.let { alarmProps += Repeat().apply { value = it } } + alarm.duration?.let { + alarmProps += Duration().apply { try { val dur = java.time.Duration.parse(it) this.duration = dur } catch (e: DateTimeParseException) { logger.log(Level.WARNING, "Could not parse duration as Duration.", e) } - }) } - alarm.description?.let { add(Description(it)) } - alarm.attach?.let { add(Attach().apply { value = it }) } - alarm.other?.let { addAll(JtxContract.getXPropertyListFromJson(it).all) } - + } } - calComponent.componentList.add(vAlarm) - } + alarm.description?.let { alarmProps += Description(it) } + alarm.attach?.let { alarmProps += Attach().apply { value = it } } + alarm.other?.let { alarmProps += JtxContract.getXPropertyListFromJson(it).all } + // add VALARM to VTODO/VJOURNAL component + val vAlarm = VAlarm(PropertyList(alarmProps)) + calComponent += vAlarm + } + // add a iCalendar component for each exception recurInstances.forEach { recurInstance -> + // create a fresh component of the correct type and add to iCalendar val recurCalComponent = when (recurInstance.component) { JtxContract.JtxICalObject.Component.VTODO.name -> VToDo(true /* generates DTSTAMP */) JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */) else -> return null } ical += recurCalComponent - recurInstance.addProperties(recurCalComponent.propertyList) + // assign properties (UID, RECURRENCE-ID, modified SUMMARY etc.) from the exception + recurCalComponent.propertyList = recurInstance.addProperties(recurCalComponent.propertyList) } ICalendar.softValidate(ical)