Skip to content

Commit 8610de7

Browse files
committed
Always generate DTEND for compatibility
- Update `AndroidTimeField` to handle null time zones - Add `calculateFromDefault` method in `EndTimeHandler` - Modify `DurationHandler` to skip if DTEND is present
1 parent 6e12858 commit 8610de7

File tree

5 files changed

+103
-48
lines changed

5 files changed

+103
-48
lines changed

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/AndroidTimeField.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import java.time.ZoneId
1717
* Converts timestamps from the [android.provider.CalendarContract.Events.DTSTART] or [android.provider.CalendarContract.Events.DTEND]
1818
* fields into other representations.
1919
*
20-
* @param timestamp value of the DTSTART/DTEND field (timestamp in milliseconds)
21-
* @param timeZone value of the respective timezone field ([android.provider.CalendarContract.Events.EVENT_TIMEZONE] / [android.provider.CalendarContract.Events.EVENT_END_TIMEZONE])
22-
* @param allDay whether [android.provider.CalendarContract.Events.ALL_DAY] is non-null and not zero
20+
* @param timestamp value of the Android `DTSTART`/`DTEND` field (timestamp in milliseconds)
21+
* @param timeZone value of the respective Android timezone field or `null` for system default time zone
22+
* @param allDay whether Android `ALL_DAY` field is non-null and not zero
2323
*/
2424
class AndroidTimeField(
2525
private val timestamp: Long,

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandler.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package at.bitfire.synctools.mapping.calendar.handler
88

99
import android.content.Entity
1010
import android.provider.CalendarContract.Events
11+
import at.bitfire.ical4android.util.TimeApiExtensions.abs
1112
import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate
1213
import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime
1314
import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime
@@ -16,11 +17,17 @@ import net.fortuna.ical4j.model.DateTime
1617
import net.fortuna.ical4j.model.TimeZoneRegistry
1718
import net.fortuna.ical4j.model.component.VEvent
1819
import net.fortuna.ical4j.model.property.DtEnd
19-
import java.time.Duration
2020
import java.time.Instant
21-
import java.time.Period
2221
import java.time.ZoneOffset
2322

23+
/**
24+
* Maps a potentially present [Events.DURATION] to a VEvent [DtEnd] property.
25+
*
26+
* Does nothing when:
27+
*
28+
* - [Events.DTEND] is present / not null (because DTEND then takes precedence over DURATION), and/or
29+
* - [Events.DURATION] is null / not present.
30+
*/
2431
class DurationHandler(
2532
private val tzRegistry: TimeZoneRegistry
2633
): AndroidEventFieldHandler {
@@ -33,13 +40,11 @@ class DurationHandler(
3340
- DURATION is not set – then usually DTEND is set; however it's also OK to have neither DTEND nor DURATION in a VEVENT. */
3441
if (values.getAsLong(Events.DTEND) != null)
3542
return
36-
val durStr = values.getAsString(Events.DURATION) ?: return
37-
val duration = AndroidTimeUtils.parseDuration(durStr)
43+
val durationStr = values.getAsString(Events.DURATION) ?: return
44+
val parsedDuration = AndroidTimeUtils.parseDuration(durationStr)
3845

39-
// Skip in case of zero or negative duration (analogous to DTEND being before DTSTART).
40-
if ((duration is Duration && (duration.isZero || duration.isNegative)) ||
41-
(duration is Period && (duration.isZero || duration.isNegative)))
42-
return
46+
// invert in case of negative duration (events can't go back in time)
47+
val duration = parsedDuration.abs()
4348

4449
/* Some servers have problems with DURATION. For maximum compatibility, we always generate DTEND instead of DURATION.
4550
(After all, the constraint that non-recurring events have a DTEND while recurring events use DURATION is Android-specific.)

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandler.kt

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@ package at.bitfire.synctools.mapping.calendar.handler
88

99
import android.content.Entity
1010
import android.provider.CalendarContract.Events
11+
import androidx.annotation.VisibleForTesting
1112
import net.fortuna.ical4j.model.TimeZoneRegistry
1213
import net.fortuna.ical4j.model.component.VEvent
1314
import net.fortuna.ical4j.model.property.DtEnd
15+
import java.time.Duration
16+
import java.time.Instant
1417
import java.util.logging.Logger
1518

19+
/**
20+
* Maps a potentially present [Events.DTEND] to a VEvent [DtEnd] property.
21+
*
22+
* If [Events.DTEND] is null / not present:
23+
*
24+
* - If [Events.DURATION] is present / not null, [DurationHandler] is responsible for generating the VEvent's [DtEnd].
25+
* - If [Events.DURATION] is null / not present, this class is responsible for generating the VEvent's [DtEnd].
26+
*/
1627
class EndTimeHandler(
1728
private val tzRegistry: TimeZoneRegistry
1829
): AndroidEventFieldHandler {
@@ -24,15 +35,20 @@ class EndTimeHandler(
2435
val values = from.entityValues
2536
val allDay = (values.getAsInteger(Events.ALL_DAY) ?: 0) != 0
2637

27-
// Skip if DTEND is not set – then usually DURATION is set; however it's also OK to have neither DTEND nor DURATION in a VEVENT.
28-
val tsEnd = values.getAsLong(Events.DTEND) ?: return
29-
30-
// Also skip if DTEND is not after DTSTART (not allowed in iCalendar)
38+
// Skip if DTSTART is not present (not allowed in iCalendar)
3139
val tsStart = values.getAsLong(Events.DTSTART) ?: return
32-
if (tsEnd <= tsStart) {
33-
logger.warning("Ignoring DTEND=$tsEnd that is not after DTSTART=$tsStart")
34-
return
35-
}
40+
41+
val tsEndOrNull = values.getAsLong(Events.DTEND)
42+
val durationStr = values.getAsString(Events.DURATION)
43+
44+
if (tsEndOrNull == null && durationStr != null) // DTEND not present, but DURATION is present:
45+
return // DurationHandler is responsible
46+
47+
/* Make sure that there's always a DTEND for compatibility. While it's allowed in RFC 5545
48+
to omit DTEND, this causes problems with some servers (notably iCloud). */
49+
val tsEnd = tsEndOrNull
50+
?.takeUnless { it < tsStart } // only use DTEND if it's not before DTSTART
51+
?: calculateFromDefault(tsStart, allDay) // for compatibility
3652

3753
// DATE or DATE-TIME according to allDay
3854
val end = AndroidTimeField(
@@ -46,4 +62,16 @@ class EndTimeHandler(
4662
to.properties += DtEnd(end)
4763
}
4864

65+
@VisibleForTesting
66+
internal fun calculateFromDefault(tsStart: Long, allDay: Boolean): Long =
67+
if (allDay) {
68+
// all-day: default duration is PT1D; all-day events are always in UTC time zone
69+
val start = Instant.ofEpochMilli(tsStart)
70+
val end = start + Duration.ofDays(1)
71+
end.toEpochMilli()
72+
} else {
73+
// non-all-day: default duration is PT0S; end time = start time
74+
tsStart
75+
}
76+
4977
}

lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/DurationHandlerTest.kt

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -100,28 +100,13 @@ class DurationHandlerTest {
100100
}
101101

102102
@Test
103-
fun `Skip if DURATION is negative all-day duration`() {
103+
fun `Skip if DTEND is set`() {
104104
val result = VEvent()
105105
val entity = Entity(contentValuesOf(
106-
Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200
107-
Events.EVENT_TIMEZONE to "Europe/Vienna",
108-
Events.DURATION to "P-1D"
109-
))
110-
handler.process(entity, entity, result)
111-
assertNull(result.endDate)
112-
assertNull(result.duration)
113-
}
114-
115-
@Test
116-
fun `Skip if DURATION is zero non-all-day duration`() {
117-
val result = VEvent()
118-
val entity = Entity(contentValuesOf(
119-
Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200
120-
Events.EVENT_TIMEZONE to "Europe/Vienna",
121-
Events.DURATION to "PT0S"
106+
Events.DTSTART to 1761433200000L,
107+
Events.DTEND to 1761433200000L
122108
))
123109
handler.process(entity, entity, result)
124-
assertNull(result.endDate)
125110
assertNull(result.duration)
126111
}
127112

lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/handler/EndTimeHandlerTest.kt

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import org.junit.Assume
2121
import org.junit.Test
2222
import org.junit.runner.RunWith
2323
import org.robolectric.RobolectricTestRunner
24+
import java.time.OffsetDateTime
2425
import java.time.ZoneId
26+
import java.time.ZoneOffset
2527

2628
@RunWith(RobolectricTestRunner::class)
2729
class EndTimeHandlerTest {
@@ -45,6 +47,17 @@ class EndTimeHandlerTest {
4547
assertEquals(DtEnd(Date("20200621")), result.endDate)
4648
}
4749

50+
@Test
51+
fun `All-day event with empty DTEND`() {
52+
val result = VEvent()
53+
val entity = Entity(contentValuesOf(
54+
Events.ALL_DAY to 1,
55+
Events.DTSTART to 1592697600000L // 21/06/2020; DTSTART is required for DTEND to be processed
56+
))
57+
handler.process(entity, entity, result)
58+
assertEquals(DtEnd(Date("20200622")), result.endDate)
59+
}
60+
4861
@Test
4962
fun `Non-all-day event with end timezone`() {
5063
val result = VEvent()
@@ -88,38 +101,62 @@ class EndTimeHandlerTest {
88101
assertEquals(defaultTz, (result.endDate?.date as? DateTime)?.timeZone)
89102
}
90103

91-
92-
// skip conditions
93-
94104
@Test
95-
fun `Skip if DTEND is not after DTSTART`() {
105+
fun `Non-all-day event with empty DTEND`() {
96106
val result = VEvent()
97107
val entity = Entity(contentValuesOf(
98-
Events.DTSTART to 1592733500000L,
99-
Events.DTEND to 1592733500000L
108+
Events.ALL_DAY to 0,
109+
Events.DTSTART to 1592733600000L, // 21/06/2020 12:00 +0200; DTSTART is required for DTEND to be processed
110+
Events.EVENT_TIMEZONE to "Europe/Vienna" // will be used as end time zone
100111
))
101112
handler.process(entity, entity, result)
102-
assertNull(result.endDate)
113+
assertEquals(DtEnd(DateTime("20200621T120000", tzVienna)), result.endDate)
103114
}
104115

116+
117+
// skip conditions
118+
105119
@Test
106-
fun `Skip if DTEND is not set`() {
120+
fun `Skip if DTSTART is not set`() {
107121
val result = VEvent()
108122
val entity = Entity(contentValuesOf(
109-
Events.DTSTART to 1592733500000L
123+
Events.DTEND to 1592733500000L
110124
))
111125
handler.process(entity, entity, result)
112126
assertNull(result.endDate)
113127
}
114128

115129
@Test
116-
fun `Skip if DTSTART is not set`() {
130+
fun `Skip if DURATION is set`() {
117131
val result = VEvent()
118132
val entity = Entity(contentValuesOf(
119-
Events.DTEND to 1592733500000L
133+
Events.DTSTART to 1592733500000L,
134+
Events.DURATION to "PT1H"
120135
))
121136
handler.process(entity, entity, result)
122137
assertNull(result.endDate)
123138
}
124139

140+
141+
// calculateFromDefault
142+
143+
@Test
144+
fun `calculateFromDefault (all-day)`() {
145+
val start = OffsetDateTime.of(2025, 12, 5, 0, 0, 0, 0, ZoneOffset.UTC)
146+
val tsStart = start.toInstant().toEpochMilli()
147+
148+
val result = handler.calculateFromDefault(tsStart, allDay = true)
149+
150+
val expectedEnd = OffsetDateTime.of(2025, 12, 6, 0, 0, 0, 0, ZoneOffset.UTC)
151+
val expectedEndTs = expectedEnd.toInstant().toEpochMilli()
152+
assertEquals(expectedEndTs, result)
153+
}
154+
155+
@Test
156+
fun `calculateFromDefault (non-all-day)`() {
157+
val start = System.currentTimeMillis()
158+
val result = handler.calculateFromDefault(start, allDay = false)
159+
assertEquals(start, result)
160+
}
161+
125162
}

0 commit comments

Comments
 (0)