Skip to content

Commit 2cc77a5

Browse files
authored
Always generate DTEND for compatibility (#157)
* 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 * Tests for explicit negative durations * Comments * Update tests * Test comments
1 parent 6e12858 commit 2cc77a5

File tree

5 files changed

+161
-49
lines changed

5 files changed

+161
-49
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: 15 additions & 11 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,30 +17,33 @@ 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 {
2734

2835
override fun process(from: Entity, main: Entity, to: VEvent) {
2936
val values = from.entityValues
3037

31-
/* Skip if:
32-
- DTEND is set – we don't need to process DURATION anymore.
33-
- DURATION is not set – then usually DTEND is set; however it's also OK to have neither DTEND nor DURATION in a VEVENT. */
38+
/* Skip if DTEND is set and/or DURATION is not set. In both cases EndTimeHandler is
39+
responsible for generating the DTEND property. */
3440
if (values.getAsLong(Events.DTEND) != null)
3541
return
36-
val durStr = values.getAsString(Events.DURATION) ?: return
37-
val duration = AndroidTimeUtils.parseDuration(durStr)
42+
val durationStr = values.getAsString(Events.DURATION) ?: return
3843

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
44+
// parse duration and invert in case of negative value (events can't go back in time)
45+
val parsedDuration = AndroidTimeUtils.parseDuration(durationStr)
46+
val duration = parsedDuration.abs()
4347

4448
/* Some servers have problems with DURATION. For maximum compatibility, we always generate DTEND instead of DURATION.
4549
(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: 37 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,21 @@ 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 for generating the DTEND property
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). See also:
49+
https://github.com/bitfireAT/davx5-ose/issues/1859 */
50+
val tsEnd = tsEndOrNull
51+
?.takeUnless { it < tsStart } // only use DTEND if it's not before DTSTART
52+
?: calculateFromDefault(tsStart, allDay) // always provide DTEND for compatibility
3653

3754
// DATE or DATE-TIME according to allDay
3855
val end = AndroidTimeField(
@@ -46,4 +63,16 @@ class EndTimeHandler(
4663
to.properties += DtEnd(end)
4764
}
4865

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

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

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ class DurationHandlerTest {
4343
assertNull(result.duration)
4444
}
4545

46+
@Test
47+
fun `All-day event with negative all-day duration`() {
48+
val result = VEvent()
49+
val entity = Entity(contentValuesOf(
50+
Events.ALL_DAY to 1,
51+
Events.DTSTART to 1592733600000L, // 21/06/2020 10:00 UTC
52+
Events.DURATION to "P-4D"
53+
))
54+
handler.process(entity, entity, result)
55+
assertEquals(DtEnd(Date("20200625")), result.endDate)
56+
assertNull(result.duration)
57+
}
58+
4659
@Test
4760
fun `All-day event with non-all-day duration`() {
4861
val result = VEvent()
@@ -56,6 +69,19 @@ class DurationHandlerTest {
5669
assertNull(result.duration)
5770
}
5871

72+
@Test
73+
fun `All-day event with negative non-all-day duration`() {
74+
val result = VEvent()
75+
val entity = Entity(contentValuesOf(
76+
Events.ALL_DAY to 1,
77+
Events.DTSTART to 1760486400000L, // Wed Oct 15 2025 00:00:00 GMT+0000
78+
Events.DURATION to "PT-24H"
79+
))
80+
handler.process(entity, entity, result)
81+
assertEquals(DtEnd(Date("20251016")), result.endDate)
82+
assertNull(result.duration)
83+
}
84+
5985
@Test
6086
fun `Non-all-day event with all-day duration`() {
6187
val result = VEvent()
@@ -72,56 +98,72 @@ class DurationHandlerTest {
7298
}
7399

74100
@Test
75-
fun `Non-all-day event with non-all-day duration`() {
101+
fun `Non-all-day event with negative all-day duration`() {
76102
val result = VEvent()
77103
val entity = Entity(contentValuesOf(
78104
Events.ALL_DAY to 0,
79105
Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200
80106
Events.EVENT_TIMEZONE to "Europe/Vienna",
81-
Events.DURATION to "P24H"
107+
Events.DURATION to "P-1D",
82108
))
83109
// DST transition at 03:00, clock is set back to 02:00 → P1D = PT25H
84110
handler.process(entity, entity, result)
85-
assertEquals(DtEnd(DateTime("20251027T000000", tzVienna)), result.endDate)
111+
assertEquals(DtEnd(DateTime("20251027T010000", tzVienna)), result.endDate)
86112
assertNull(result.duration)
87113
}
88114

89-
90-
// skip conditions
91-
92115
@Test
93-
fun `Skip if DTSTART is not set`() {
116+
fun `Non-all-day event with non-all-day duration`() {
94117
val result = VEvent()
95118
val entity = Entity(contentValuesOf(
96-
Events.DURATION to "PT1H"
119+
Events.ALL_DAY to 0,
120+
Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200
121+
Events.EVENT_TIMEZONE to "Europe/Vienna",
122+
Events.DURATION to "PT24H"
97123
))
124+
// DST transition at 03:00, clock is set back to 02:00 → PT24H goes one hour back
98125
handler.process(entity, entity, result)
126+
assertEquals(DtEnd(DateTime("20251027T000000", tzVienna)), result.endDate)
99127
assertNull(result.duration)
100128
}
101129

102130
@Test
103-
fun `Skip if DURATION is negative all-day duration`() {
131+
fun `Non-all-day event with negative non-all-day duration`() {
104132
val result = VEvent()
105133
val entity = Entity(contentValuesOf(
134+
Events.ALL_DAY to 0,
106135
Events.DTSTART to 1761433200000L, // Sun Oct 26 2025 01:00:00 GMT+0200
107136
Events.EVENT_TIMEZONE to "Europe/Vienna",
108-
Events.DURATION to "P-1D"
137+
Events.DURATION to "PT-24H" // will be converted to PT24H
109138
))
139+
// DST transition at 03:00, clock is set back to 02:00 → PT24H goes one hour back
110140
handler.process(entity, entity, result)
111-
assertNull(result.endDate)
141+
assertEquals(DtEnd(DateTime("20251027T000000", tzVienna)), result.endDate)
112142
assertNull(result.duration)
113143
}
114144

145+
146+
// skip conditions
147+
115148
@Test
116-
fun `Skip if DURATION is zero non-all-day duration`() {
149+
fun `Skip if DTSTART is not set`() {
117150
val result = VEvent()
118151
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"
152+
Events.DURATION to "PT1H"
153+
))
154+
handler.process(entity, entity, result)
155+
assertNull(result.duration)
156+
}
157+
158+
@Test
159+
fun `Skip if DTEND and DURATION are set`() {
160+
val result = VEvent()
161+
val entity = Entity(contentValuesOf(
162+
Events.DTSTART to 1761433200000L,
163+
Events.DTEND to 1761433200000L,
164+
Events.DURATION to "P1D"
122165
))
123166
handler.process(entity, entity, result)
124-
assertNull(result.endDate)
125167
assertNull(result.duration)
126168
}
127169

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)