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 @@ -47,6 +47,7 @@ import net.fortuna.ical4j.model.property.ProdId
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.util.LinkedList
import java.util.UUID
Expand Down Expand Up @@ -166,6 +167,8 @@ class AndroidEventHandler(
return if (originalAllDay) {
// .. as date, without time
ExDate(DateList(LocalDate.from(date)))
} else if (date is Instant) {
ExDate(DateList(date))
} else {
// .. as ZonedDateTime, with time and TZ param
ExDate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package at.bitfire.synctools.mapping.calendar.handler

import at.bitfire.synctools.util.AndroidTimeUtils
import at.bitfire.synctools.util.AndroidTimeUtils.isUtcTzId
import net.fortuna.ical4j.util.TimeZones
import java.time.DateTimeException
import java.time.Instant
Expand Down Expand Up @@ -49,16 +50,16 @@ class AndroidTimeField(
val tzId = timeZone
?: ZoneId.systemDefault().id // safe fallback (should never be used/needed because the calendar provider requires EVENT_TIMEZONE)

val timezone = if (tzId == AndroidTimeUtils.TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID) {
ZoneOffset.UTC
} else {
try {
ZoneId.of(tzId)
} catch (_: DateTimeException) {
ZoneId.of(defaultTzId)
} catch (_: ZoneRulesException) {
ZoneId.of(defaultTzId)
}
if (isUtcTzId(tzId)) {
return instant
}

val timezone = try {
ZoneId.of(tzId)
} catch (_: DateTimeException) {
ZoneId.of(defaultTzId)
} catch (_: ZoneRulesException) {
ZoneId.of(defaultTzId)
}

return ZonedDateTime.ofInstant(instant, timezone)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import android.provider.CalendarContract.Events
import at.bitfire.ical4android.util.TimeApiExtensions.abs
import at.bitfire.synctools.icalendar.plusAssign
import at.bitfire.synctools.util.AndroidTimeUtils
import at.bitfire.synctools.util.AndroidTimeUtils.toZonedDateTime
import net.fortuna.ical4j.model.component.VEvent
import net.fortuna.ical4j.model.property.DtEnd
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime

/**
* Maps a potentially present [Events.DURATION] to a VEvent [DtEnd] property.
Expand Down Expand Up @@ -62,8 +62,13 @@ class DurationHandler: AndroidEventFieldHandler {
allDay = false
).toTemporal()

val start = startDateTime.toZonedDateTime()
val end = start + duration
val end = when (startDateTime) {
is Instant -> startDateTime + duration
is ZonedDateTime -> startDateTime + duration
else -> {
error("Unsupported Temporal type: ${startDateTime::class.qualifiedName}")
}
}

to += DtEnd(end)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.Entity
import android.provider.CalendarContract.Events
import at.bitfire.synctools.icalendar.DatePropertyTzMapper
import at.bitfire.synctools.icalendar.plusAssign
import at.bitfire.synctools.util.AndroidTimeUtils.isUtcTzId
import net.fortuna.ical4j.model.component.VEvent
import net.fortuna.ical4j.model.property.RecurrenceId
import java.time.Instant
Expand All @@ -32,17 +33,21 @@ class OriginalInstanceTimeHandler: AndroidEventFieldHandler {
to += if (originalAllDay) {
RecurrenceId(LocalDate.ofInstant(instant, ZoneOffset.UTC))
} else {
val zoneId = getMainEventZoneId(main)
RecurrenceId(ZonedDateTime.ofInstant(instant, zoneId))
val mainTzId = main.entityValues.getAsString(Events.EVENT_TIMEZONE)
if (isUtcTzId(mainTzId)) {
RecurrenceId(instant)
} else {
val zoneId = getZoneId(mainTzId)
RecurrenceId(ZonedDateTime.ofInstant(instant, zoneId))
}
}
}
}

private fun getMainEventZoneId(main: Entity): ZoneId? {
val mainTzId = main.entityValues.getAsString(Events.EVENT_TIMEZONE)
val mainTimezone = DatePropertyTzMapper.systemTzId(mainTzId)
return if (mainTimezone != null) {
ZoneId.of(mainTimezone)
private fun getZoneId(tzId: String?): ZoneId? {
val timezone = DatePropertyTzMapper.systemTzId(tzId)
return if (timezone != null) {
ZoneId.of(timezone)
} else {
ZoneId.systemDefault()
}
Expand Down
91 changes: 66 additions & 25 deletions lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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.RDate
import net.fortuna.ical4j.util.TimeZones
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
Expand Down Expand Up @@ -159,7 +160,11 @@ object AndroidTimeUtils {
val limiter = dbStr.indexOf(RECURRENCE_LIST_TZID_SEPARATOR)
if (limiter != -1) { // TZID given
val tzId = dbStr.take(limiter)
zoneId = ZoneId.of(tzId).takeIf { it != ZoneOffset.UTC }
zoneId = if (isUtcTzId(tzId)) {
ZoneOffset.UTC
} else {
ZoneId.of(tzId)
}
datesStr = dbStr.substring(limiter + 1)
} else {
zoneId = null
Expand All @@ -170,30 +175,7 @@ object AndroidTimeUtils {
val dates = datesStr
.splitToSequence(RECURRENCE_LIST_VALUE_SEPARATOR)
.map { dateString ->
if (zoneId == null) {
if (dateString.contains('T')) {
val instant = TemporalAdapter.parse<Instant>(dateString, CalendarDateFormat.UTC_DATE_TIME_FORMAT).temporal
if (allDay) {
instant.toLocalDate()
} else {
instant
}
} else {
val localDate = TemporalAdapter.parse<LocalDate>(dateString, CalendarDateFormat.DATE_FORMAT).temporal
if (allDay) {
localDate
} else {
localDate.atStartOfDay(ZoneOffset.UTC).toInstant()
}
}
} else {
val localDateTime = TemporalAdapter.parse<LocalDateTime>(dateString, CalendarDateFormat.FLOATING_DATE_TIME_FORMAT).temporal
if (allDay) {
localDateTime.toLocalDate()
} else {
localDateTime.atZone(zoneId)
}
}
parseDateString(dateString, zoneId, allDay)
}
.filterNot { date ->
// filter excluded date
Expand Down Expand Up @@ -223,6 +205,62 @@ object AndroidTimeUtils {
return dateListProperty
}

private fun parseDateString(dateString: String, zoneId: ZoneId?, allDay: Boolean): Temporal {
val isUtcFormat = dateString.endsWith('Z')
val isDateTimeFormat = dateString.contains('T')

return when {
isUtcFormat -> {
val instant = parseUtcDateTime(dateString)
if (allDay) {
instant.toLocalDate()
} else {
instant
}
}
isDateTimeFormat -> {
val localDateTime = parseDateTime(dateString)
val isUtc = zoneId == ZoneOffset.UTC

when {
allDay -> localDateTime.toLocalDate()
isUtc -> localDateTime.toInstant(ZoneOffset.UTC)
zoneId != null -> localDateTime.atZone(zoneId)
else -> error("Floating DATE-TIME is not supported: $dateString")
}
}
else -> {
val localDate = parseDate(dateString)
if (allDay) {
localDate
} else {
localDate.atStartOfDay(ZoneOffset.UTC).toInstant()
}
}
}
}

private fun parseUtcDateTime(dateString: String): Instant {
return TemporalAdapter.parse<Instant>(
dateString,
CalendarDateFormat.UTC_DATE_TIME_FORMAT
).temporal
}

private fun parseDate(dateString: String): LocalDate {
return TemporalAdapter.parse<LocalDate>(
dateString,
CalendarDateFormat.DATE_FORMAT
).temporal
}

private fun parseDateTime(dateString: String): LocalDateTime {
return TemporalAdapter.parse<LocalDateTime>(
dateString,
CalendarDateFormat.FLOATING_DATE_TIME_FORMAT
).temporal
}

/**
* Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to
* a formatted string which OpenTasks can process.
Expand Down Expand Up @@ -332,4 +370,7 @@ object AndroidTimeUtils {
return TemporalAmountAdapter.parse(durationStr).duration
}

fun isUtcTzId(tzId: String?): Boolean {
return tzId == TZID_UTC || tzId == TimeZones.UTC_ID || tzId == TimeZones.IBM_UTC_ID
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import at.bitfire.synctools.icalendar.dtStart
import at.bitfire.synctools.icalendar.recurrenceId
import at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import at.bitfire.synctools.util.AndroidTimeUtils
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
import net.fortuna.ical4j.model.property.DtStart
Expand All @@ -30,6 +31,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.Instant
import java.time.ZonedDateTime
import java.time.temporal.Temporal
import kotlin.jvm.optionals.getOrNull
Expand Down Expand Up @@ -146,6 +148,40 @@ class AndroidEventHandlerTest {
assertTrue(result.exceptions.isEmpty())
}

@Test
fun `mapToVEvents rewrites cancelled exception using UTC to EXDATE`() {
val result = handler.mapToVEvents(
eventAndExceptions = EventAndExceptions(
main = Entity(contentValuesOf(
Events.TITLE to "Recurring all-day event with cancelled exception",
Events.DTSTART to 1594056600000L,
Events.EVENT_TIMEZONE to "UTC",
Events.ALL_DAY to 0,
Events.RRULE to "FREQ=DAILY;COUNT=10"
)),
exceptions = listOf(
Entity(contentValuesOf(
Events.ORIGINAL_INSTANCE_TIME to 1594143000000L,
Events.ORIGINAL_ALL_DAY to 0,
Events.DTSTART to 1594143000000L,
Events.ALL_DAY to 0,
Events.EVENT_TIMEZONE to "UTC",
Events.STATUS to Events.STATUS_CANCELED
))
)
)
).associatedEvents
val main = result.main!!
assertEquals("Recurring all-day event with cancelled exception", main.summary.value)
assertEquals(DtStart(dateTimeValue("20200706T173000Z")), main.dtStart<Instant>())
assertEquals("FREQ=DAILY;COUNT=10", main.getProperty<RRule<*>>(Property.RRULE).getOrNull()?.value)
assertEquals(
ExDate<Temporal>("20200707T173000Z"),
main.getRequiredProperty<ExDate<*>>(Property.EXDATE)
)
assertTrue(result.exceptions.isEmpty())
}

@Test
fun `mapToVEvents ignores cancelled exception without RECURRENCE-ID`() {
val result = handler.mapToVEvents(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import net.fortuna.ical4j.util.TimeZones
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.time.Instant
import java.time.ZoneOffset

class AndroidTimeFieldTest {
Expand Down Expand Up @@ -54,29 +55,29 @@ class AndroidTimeFieldTest {
}

@Test
fun `toTemporal with Android UTC timezone ID returns UTC ZonedDateTime`() {
fun `toTemporal with Android UTC timezone ID returns Instant`() {
val androidTimeField = AndroidTimeField(
timestamp = 1760521619000, // Wed Oct 15 2025 09:46:59 GMT+0000
timestamp = 1760521619000,
timeZone = AndroidTimeUtils.TZID_UTC,
allDay = false,
)

val result = androidTimeField.toTemporal()

assertEquals(dateTimeValue("20251015T094659", ZoneOffset.UTC), result)
assertEquals(Instant.ofEpochMilli(1760521619000), result)
}

@Test
fun `toTemporal with JVM UTC timezone ID returns UTC ZonedDateTime`() {
fun `toTemporal with JVM UTC timezone ID returns Instant`() {
val androidTimeField = AndroidTimeField(
timestamp = 1760521619000, // Wed Oct 15 2025 09:46:59 GMT+0000
timestamp = 1760521619000,
timeZone = TimeZones.UTC_ID,
allDay = false,
)

val result = androidTimeField.toTemporal()

assertEquals(dateTimeValue("20251015T094659", ZoneOffset.UTC), result)
assertEquals(Instant.ofEpochMilli(1760521619000), result)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
Expand Down Expand Up @@ -147,6 +149,25 @@ class DurationHandlerTest {
assertNull(result.duration)
}

@Test
fun `Non-all-day event with UTC time zone`() {
val result = VEvent()
val entity = Entity(contentValuesOf(
Events.ALL_DAY to 0,
Events.DTSTART to 1761433200000L,
Events.EVENT_TIMEZONE to "UTC",
Events.DURATION to "PT1H"
))

handler.process(entity, entity, result)

assertEquals(
DtEnd(Instant.ofEpochMilli(1761433200000L) + Duration.ofHours(1)),
result.dtEnd<Instant>()
)
assertNull(result.duration)
}


// skip conditions

Expand Down
Loading
Loading