Skip to content
Open
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 @@ -10,10 +10,12 @@ import android.content.Entity
import at.bitfire.synctools.icalendar.AssociatedComponents
import at.bitfire.synctools.mapping.jtx.handler.DescriptionHandler
import at.bitfire.synctools.mapping.jtx.handler.JtxFieldHandler
import at.bitfire.synctools.mapping.jtx.handler.RecurrenceFieldsHandler
import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions
import at.techbee.jtx.JtxContract
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.component.CalendarComponent
import net.fortuna.ical4j.model.property.RDate
import net.fortuna.ical4j.model.component.VJournal
import net.fortuna.ical4j.model.component.VToDo
import net.fortuna.ical4j.model.property.ProdId
Expand All @@ -29,7 +31,8 @@ class JtxObjectHandler(
private val prodId: ProdId
) {
private val fieldHandlers: Array<JtxFieldHandler> = arrayOf(
DescriptionHandler()
DescriptionHandler(),
RecurrenceFieldsHandler()
)
Comment thread
ArnyminerZ marked this conversation as resolved.

/**
Expand All @@ -53,8 +56,8 @@ class JtxObjectHandler(
main = jtxObjectAndExceptions.main
)

val rRules = main.getProperties<RRule<*>>(Property.RRULE)
val exceptions: List<CalendarComponent> = if (rRules.isNotEmpty()) {
val isRecurring = main.containsProperty<RRule<*>>(Property.RRULE) || main.containsProperty<RDate<*>>(Property.RDATE)
val exceptions: List<CalendarComponent> = if (isRecurring) {
// add exceptions to recurring main jtx object
jtxObjectAndExceptions.exceptions.map { exception ->
mapJtxObject(
Expand Down Expand Up @@ -110,6 +113,10 @@ class JtxObjectHandler(
return JtxContract.JtxICalObject.Component.valueOf(componentValue)
}

private fun <T: Property> CalendarComponent.containsProperty(name: String): Boolean {
return getProperties<T>(name).isNotEmpty()
}

/**
* Result of the [mapToCalendarComponents] operation.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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.jtx.handler

import android.content.Entity
import at.bitfire.synctools.icalendar.plusAssign
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.ParameterList
import net.fortuna.ical4j.model.component.CalendarComponent
import net.fortuna.ical4j.model.parameter.TzId
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.DateListProperty
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RDate
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.temporal.Temporal
import java.util.logging.Level
import java.util.logging.Logger

/**
* Handler for recurrence-related fields of a jtx Board content provider data row.
*/
class RecurrenceFieldsHandler : JtxFieldHandler {

private val logger
get() = Logger.getLogger(javaClass.name)

override fun process(from: Entity, main: Entity, to: CalendarComponent) {
if (from === main) {
processMain(from, to)
} else {
processException(from, to)
}
}

private fun processMain(from: Entity, to: CalendarComponent) {
val values = from.entityValues
val dtstartTimezone = values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE)

values.getAsString(JtxContract.JtxICalObject.RRULE)?.takeIf { it.isNotBlank() }?.let { rruleString ->
try {
to += RRule<Temporal>(rruleString)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't parse RRULE, ignoring", e)
}
}

values.getAsString(JtxContract.JtxICalObject.RDATE)?.takeIf { it.isNotBlank() }?.let { rdateString ->
try {
val timestamps = JtxContract.getLongListFromString(rdateString)
if (timestamps.isNotEmpty()) {
to += timestampsToProperty(timestamps, dtstartTimezone) { RDate(it) }
}
Comment thread
ArnyminerZ marked this conversation as resolved.
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't parse RDATE, ignoring", e)
}
}

values.getAsString(JtxContract.JtxICalObject.EXDATE)?.takeIf { it.isNotBlank() }?.let { exdateString ->
try {
val timestamps = JtxContract.getLongListFromString(exdateString)
if (timestamps.isNotEmpty()) {
to += timestampsToProperty(timestamps, dtstartTimezone) { ExDate(it) }
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't parse EXDATE, ignoring", e)
}
}
}

private fun processException(from: Entity, to: CalendarComponent) {
val values = from.entityValues
val recurid = values.getAsString(JtxContract.JtxICalObject.RECURID) ?: return
val recuridTimezone = values.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE)

try {
to += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty() || recuridTimezone == ZoneOffset.UTC.id) {
RecurrenceId<Temporal>(recurid)
} else {
RecurrenceId<Temporal>(ParameterList(listOf(TzId(recuridTimezone))), recurid)
}
Comment thread
ArnyminerZ marked this conversation as resolved.
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't parse RECURID, ignoring", e)
}
}

private fun <T: DateListProperty<*>> timestampsToProperty(
timestamps: List<Long>,
dtstartTimezone: String?,
factory: (DateList<*>) -> T
): T {
val dateList = timestampsToDateList(timestamps, dtstartTimezone)
val property = factory(dateList)
when (val first = dateList.dates.firstOrNull()) {
is ZonedDateTime -> property.add<T>(TzId(first.zone.id))
is LocalDate -> property.add<T>(Value.DATE)
}
return property
}

private fun timestampsToDateList(timestamps: List<Long>, dtstartTimezone: String?): DateList<*> =
when {
dtstartTimezone == TZ_ALLDAY ->
DateList(timestamps.map {
LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)
})
dtstartTimezone == ZoneOffset.UTC.id ->
DateList(timestamps.map {
Instant.ofEpochMilli(it)
})
dtstartTimezone.isNullOrEmpty() ->
DateList(timestamps.map {
LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())
})
else -> {
val zone = try {
ZoneId.of(dtstartTimezone)
} catch (e: Exception) {
logger.log(Level.WARNING, "Invalid DTSTART_TIMEZONE '$dtstartTimezone', falling back to UTC", e)
null
}
if (zone != null) {
DateList(timestamps.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), zone)
})
} else {
DateList(timestamps.map {
Instant.ofEpochMilli(it)
})
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.ZoneOffset

@RunWith(RobolectricTestRunner::class)
class JtxObjectHandlerTest {
Expand Down Expand Up @@ -97,4 +98,70 @@ class JtxObjectHandlerTest {
assertTrue(result.generatedUid)
assertNotNull(result.uid)
}

@Test
fun `exceptions are mapped for recurring task with RRULE`() {
val exception = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.RECURID to "20260516T120000Z",
JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id
))
val jtxObjectAndExceptions = JtxObjectAndExceptions(
main = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.UID to "uid",
JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5"
)),
exceptions = listOf(exception)
)

val result = handler.mapToCalendarComponents(jtxObjectAndExceptions)

assertEquals(1, result.associatedComponents.exceptions.size)
}

@Test
fun `exceptions are mapped for recurring task with RDATE only`() {
// Regression test: exceptions must be included even when recurrence is expressed
// via RDATE alone (no RRULE). Previously only RRULE was checked.
val exception = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.RECURID to "20260516T120000Z",
JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id
))
val jtxObjectAndExceptions = JtxObjectAndExceptions(
main = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.UID to "uid",
JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id,
JtxContract.JtxICalObject.RDATE to "1779451200000" // 2026-05-22T12:00:00Z
)),
exceptions = listOf(exception)
)

val result = handler.mapToCalendarComponents(jtxObjectAndExceptions)

assertEquals(1, result.associatedComponents.exceptions.size)
}

@Test
fun `exceptions are dropped for non-recurring task`() {
val exception = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.RECURID to "20260516T120000Z",
JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id
))
val jtxObjectAndExceptions = JtxObjectAndExceptions(
main = Entity(contentValuesOf(
JtxContract.JtxICalObject.COMPONENT to "VTODO",
JtxContract.JtxICalObject.UID to "uid"
// no RRULE, no RDATE
)),
exceptions = listOf(exception)
)

val result = handler.mapToCalendarComponents(jtxObjectAndExceptions)

assertEquals(0, result.associatedComponents.exceptions.size)
}
}
Loading
Loading