Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<VToDo>(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<RecurrenceId<*>>(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<VToDo>(Component.VTODO.name)
assertEquals(1, vtodos.size)
val valarms = vtodos[0].getComponents<VAlarm>(net.fortuna.ical4j.model.Component.VALARM)
assertEquals(1, valarms.size)

// Verify VALARM properties
val valarm = valarms.first()
assertEquals(ImmutableAction.DISPLAY, valarm.getProperty<Action>(Property.ACTION).get())
assertEquals("-PT15M", valarm.getProperty<Trigger>(Property.TRIGGER).get().value)
}
Comment thread
rfc2822 marked this conversation as resolved.


/**
* 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
Expand Down
112 changes: 62 additions & 50 deletions lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -183,7 +184,8 @@ open class JtxICalObject(
var alarms: MutableList<Alarm> = mutableListOf()
var unknown: MutableList<Unknown> = mutableListOf()

private var recurInstances: MutableList<JtxICalObject> = mutableListOf()
@VisibleForTesting
internal var recurInstances: MutableList<JtxICalObject> = mutableListOf()



Expand Down Expand Up @@ -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<Property>()
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)
}
}
Comment thread
rfc2822 marked this conversation as resolved.
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)
}
Comment thread
rfc2822 marked this conversation as resolved.
}) }
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)
Expand Down
Loading