From a330b7d9c739d222488fb4d6ca780765b58a455f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 23 May 2026 12:39:46 +0200 Subject: [PATCH 1/3] Fix recurring VTODO exception handling --- .../bitfire/ical4android/JtxICalObjectTest.kt | 41 +++++++++++++++++++ .../at/bitfire/ical4android/JtxICalObject.kt | 10 +++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 38cb6cad..65056e82 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,8 @@ 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.VToDo +import net.fortuna.ical4j.model.property.RecurrenceId import org.junit.After import org.junit.Assert import org.junit.Assume @@ -853,6 +856,44 @@ 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) + assertTrue("Expected at least 2 VTODOs (main + exception)", vtodos.size == 2) + 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) + } + + /** * 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..6dfd5a2c 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() @@ -731,15 +733,17 @@ open class JtxICalObject( calComponent.componentList.add(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) From fbd80cbba0d1b23099877fb97735517abf7cfce3 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 23 May 2026 13:18:00 +0200 Subject: [PATCH 2/3] Refactor VALARM property handling to use immutable collections --- .../bitfire/ical4android/JtxICalObjectTest.kt | 32 ++++++ .../at/bitfire/ical4android/JtxICalObject.kt | 102 ++++++++++-------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 65056e82..c8c55f07 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -28,8 +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 @@ -893,6 +897,34 @@ class JtxICalObjectTest { 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 diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 6dfd5a2c..4d0a2dc5 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -670,67 +670,75 @@ 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 From 27f88a7a28548eff14be5cba49a659f7bed97b99 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 23 May 2026 13:57:15 +0200 Subject: [PATCH 3/3] Improve VTODO test assertion with specific count check --- .../kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index c8c55f07..6695a749 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -887,7 +887,7 @@ class JtxICalObjectTest { // Verify VTODOs val vtodos = ical!!.getComponents(Component.VTODO.name) - assertTrue("Expected at least 2 VTODOs (main + exception)", vtodos.size == 2) + assertEquals(2, vtodos.size) assertTrue("VTODOs must have same UID", vtodos.all { it.uid.get().value == uid }) // Verify RECURRENCE-ID of exception