Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit f9b8b55

Browse files
authored
[Jtx Board] Adapt to new immutable ical4j.x PropertyList scheme (#440)
* Fix recurring VTODO exception handling * Refactor VALARM property handling to use immutable collections * Improve VTODO test assertion with specific count check
1 parent 7c4abc5 commit f9b8b55

2 files changed

Lines changed: 135 additions & 50 deletions

File tree

lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.test.platform.app.InstrumentationRegistry
1616
import at.bitfire.ical4android.impl.TestJtxCollection
1717
import at.bitfire.ical4android.impl.testProdId
1818
import at.bitfire.synctools.icalendar.ICalendarParser
19+
import at.bitfire.synctools.icalendar.recurrenceId
1920
import at.bitfire.synctools.test.GrantPermissionOrSkipRule
2021
import at.techbee.jtx.JtxContract
2122
import at.techbee.jtx.JtxContract.JtxICalObject
@@ -27,6 +28,12 @@ import junit.framework.TestCase.assertNull
2728
import junit.framework.TestCase.assertTrue
2829
import net.fortuna.ical4j.model.Calendar
2930
import net.fortuna.ical4j.model.Property
31+
import net.fortuna.ical4j.model.component.VAlarm
32+
import net.fortuna.ical4j.model.component.VToDo
33+
import net.fortuna.ical4j.model.property.Action
34+
import net.fortuna.ical4j.model.property.RecurrenceId
35+
import net.fortuna.ical4j.model.property.Trigger
36+
import net.fortuna.ical4j.model.property.immutable.ImmutableAction
3037
import org.junit.After
3138
import org.junit.Assert
3239
import org.junit.Assume
@@ -853,6 +860,72 @@ class JtxICalObjectTest {
853860
}
854861

855862

863+
@Test
864+
fun getICalendarFormat_recurringVToDo_exceptionsMustHaveUidAndRecurrenceId() {
865+
// Prepare data object with recurring VTODO + exception
866+
val uid = "recurring-vtodo-uid@test"
867+
val mainObject = JtxICalObject(collection!!).apply {
868+
component = Component.VTODO.name
869+
this.uid = uid
870+
summary = "Recurring task"
871+
dtstart = 1744970400000L // 2025-04-18T12:00:00Z
872+
rrule = "FREQ=MONTHLY;UNTIL=20270417T110000Z"
873+
}
874+
875+
val recurId = "20250518T120000Z"
876+
val exceptionObject = JtxICalObject(collection!!).apply {
877+
component = Component.VTODO.name
878+
this.uid = uid
879+
summary = "Recurring task (exception)"
880+
this.recurid = recurId
881+
}
882+
mainObject.recurInstances += exceptionObject
883+
884+
// Generate iCalendar VTODO from data object
885+
val ical = mainObject.getICalendarFormat(testProdId)
886+
assertNotNull(ical)
887+
888+
// Verify VTODOs
889+
val vtodos = ical!!.getComponents<VToDo>(Component.VTODO.name)
890+
assertEquals(2, vtodos.size)
891+
assertTrue("VTODOs must have same UID", vtodos.all { it.uid.get().value == uid })
892+
893+
// Verify RECURRENCE-ID of exception
894+
val exceptions = vtodos.filter { it.getProperty<RecurrenceId<*>>(Property.RECURRENCE_ID).isPresent }
895+
assertEquals(1, exceptions.size)
896+
val exception = exceptions.first()
897+
assertEquals(recurId, exception.recurrenceId?.value)
898+
}
899+
900+
@Test
901+
fun getICalendarFormat_VToDo_AlarmHasProperties() {
902+
val obj = JtxICalObject(collection!!).apply {
903+
component = Component.VTODO.name
904+
uid = "vtodo-alarm-test@test"
905+
summary = "Task with alarm"
906+
alarms += at.bitfire.ical4android.JtxICalObject.Alarm(
907+
action = JtxContract.JtxAlarm.AlarmAction.DISPLAY.name,
908+
triggerRelativeDuration = "-PT15M"
909+
)
910+
}
911+
912+
// Generate iCalendar VTODO from data object
913+
val ical = obj.getICalendarFormat(testProdId)
914+
assertNotNull(ical)
915+
916+
// Extract VALARM
917+
val vtodos = ical!!.getComponents<VToDo>(Component.VTODO.name)
918+
assertEquals(1, vtodos.size)
919+
val valarms = vtodos[0].getComponents<VAlarm>(net.fortuna.ical4j.model.Component.VALARM)
920+
assertEquals(1, valarms.size)
921+
922+
// Verify VALARM properties
923+
val valarm = valarms.first()
924+
assertEquals(ImmutableAction.DISPLAY, valarm.getProperty<Action>(Property.ACTION).get())
925+
assertEquals("-PT15M", valarm.getProperty<Trigger>(Property.TRIGGER).get().value)
926+
}
927+
928+
856929
/**
857930
* This function takes a file asserts if the ICalendar is the same before and after processing with getIncomingIcal and getOutgoingIcal
858931
* @param filename the filename to be processed

lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.content.ContentValues
1111
import android.net.Uri
1212
import android.os.ParcelFileDescriptor
1313
import android.util.Base64
14+
import androidx.annotation.VisibleForTesting
1415
import androidx.core.content.contentValuesOf
1516
import at.bitfire.ical4android.ICalendar.Companion.withUserAgents
1617
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
@@ -183,7 +184,8 @@ open class JtxICalObject(
183184
var alarms: MutableList<Alarm> = mutableListOf()
184185
var unknown: MutableList<Unknown> = mutableListOf()
185186

186-
private var recurInstances: MutableList<JtxICalObject> = mutableListOf()
187+
@VisibleForTesting
188+
internal var recurInstances: MutableList<JtxICalObject> = mutableListOf()
187189

188190

189191

@@ -668,78 +670,88 @@ open class JtxICalObject(
668670
calComponent.propertyList = addProperties(calComponent.propertyList) // Need to re-set the immutable list
669671
ical += calComponent
670672

673+
// add alarm components
671674
alarms.forEach { alarm ->
675+
val alarmProps = mutableListOf<Property>()
676+
alarm.action?.let {
677+
alarmProps += when (it) {
678+
JtxContract.JtxAlarm.AlarmAction.DISPLAY.name -> ImmutableAction.DISPLAY
679+
JtxContract.JtxAlarm.AlarmAction.AUDIO.name -> ImmutableAction.AUDIO
680+
JtxContract.JtxAlarm.AlarmAction.EMAIL.name -> ImmutableAction.EMAIL
681+
else -> return@let
682+
}
683+
}
684+
685+
if (alarm.triggerRelativeDuration != null) {
686+
alarmProps += Trigger().apply {
687+
try {
688+
val dur = java.time.Duration.parse(alarm.triggerRelativeDuration)
689+
this.duration = dur
672690

673-
val vAlarm = VAlarm()
674-
vAlarm.propertyList.apply {
675-
alarm.action?.let {
676-
when (it) {
677-
JtxContract.JtxAlarm.AlarmAction.DISPLAY.name -> add(ImmutableAction.DISPLAY)
678-
JtxContract.JtxAlarm.AlarmAction.AUDIO.name -> add(ImmutableAction.AUDIO)
679-
JtxContract.JtxAlarm.AlarmAction.EMAIL.name -> add(ImmutableAction.EMAIL)
680-
else -> return@let
691+
// Add the RELATED parameter if present
692+
alarm.triggerRelativeTo?.let {
693+
if (it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name)
694+
this += Related.START
695+
if (it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name)
696+
this += Related.END
697+
}
698+
} catch (e: DateTimeParseException) {
699+
logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e)
681700
}
682701
}
683-
if(alarm.triggerRelativeDuration != null) {
684-
add(Trigger().apply {
685-
try {
686-
val dur = java.time.Duration.parse(alarm.triggerRelativeDuration)
687-
this.duration = dur
688-
689-
// Add the RELATED parameter if present
690-
alarm.triggerRelativeTo?.let {
691-
if(it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name)
692-
this += Related.START
693-
if(it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name)
694-
this += Related.END
702+
} else if (alarm.triggerTime != null) {
703+
alarmProps += Trigger().apply {
704+
try {
705+
when {
706+
alarm.triggerTimezone == ZoneOffset.UTC.id ||
707+
alarm.triggerTimezone.isNullOrEmpty() ->
708+
this.date = Instant.ofEpochMilli(alarm.triggerTime!!)
709+
710+
else -> {
711+
this.date = ZonedDateTime.ofInstant(
712+
Instant.ofEpochMilli(alarm.triggerTime!!),
713+
ZoneId.of(alarm.triggerTimezone)
714+
).toInstant()
695715
}
696-
} catch (e: DateTimeParseException) {
697-
logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e)
698716
}
699-
})
700-
701-
} else if (alarm.triggerTime != null) {
702-
add(Trigger().apply {
703-
try {
704-
when {
705-
alarm.triggerTimezone == ZoneOffset.UTC.id ||
706-
alarm.triggerTimezone.isNullOrEmpty() ->
707-
this.date = Instant.ofEpochMilli(alarm.triggerTime!!)
708-
else -> {
709-
this.date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(alarm.triggerTime!!), ZoneId.of(alarm.triggerTimezone)).toInstant()
710-
}
711-
}
712-
} catch (e: ParseException) {
713-
logger.log(Level.WARNING, "TriggerTime could not be parsed.", e)
714-
}})
717+
} catch (e: ParseException) {
718+
logger.log(Level.WARNING, "TriggerTime could not be parsed.", e)
719+
}
715720
}
716-
alarm.summary?.let { add(Summary(it)) }
717-
alarm.repeat?.let { add(Repeat().apply { value = it }) }
718-
alarm.duration?.let { add(Duration().apply {
721+
}
722+
723+
alarm.summary?.let { alarmProps += Summary(it) }
724+
alarm.repeat?.let { alarmProps += Repeat().apply { value = it } }
725+
alarm.duration?.let {
726+
alarmProps += Duration().apply {
719727
try {
720728
val dur = java.time.Duration.parse(it)
721729
this.duration = dur
722730
} catch (e: DateTimeParseException) {
723731
logger.log(Level.WARNING, "Could not parse duration as Duration.", e)
724732
}
725-
}) }
726-
alarm.description?.let { add(Description(it)) }
727-
alarm.attach?.let { add(Attach().apply { value = it }) }
728-
alarm.other?.let { addAll(JtxContract.getXPropertyListFromJson(it).all) }
729-
733+
}
730734
}
731-
calComponent.componentList.add(vAlarm)
732-
}
735+
alarm.description?.let { alarmProps += Description(it) }
736+
alarm.attach?.let { alarmProps += Attach().apply { value = it } }
737+
alarm.other?.let { alarmProps += JtxContract.getXPropertyListFromJson(it).all }
733738

739+
// add VALARM to VTODO/VJOURNAL component
740+
val vAlarm = VAlarm(PropertyList(alarmProps))
741+
calComponent += vAlarm
742+
}
734743

744+
// add a iCalendar component for each exception
735745
recurInstances.forEach { recurInstance ->
746+
// create a fresh component of the correct type and add to iCalendar
736747
val recurCalComponent = when (recurInstance.component) {
737748
JtxContract.JtxICalObject.Component.VTODO.name -> VToDo(true /* generates DTSTAMP */)
738749
JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */)
739750
else -> return null
740751
}
741752
ical += recurCalComponent
742-
recurInstance.addProperties(recurCalComponent.propertyList)
753+
// assign properties (UID, RECURRENCE-ID, modified SUMMARY etc.) from the exception
754+
recurCalComponent.propertyList = recurInstance.addProperties(recurCalComponent.propertyList)
743755
}
744756

745757
ICalendar.softValidate(ical)

0 commit comments

Comments
 (0)