Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit 19ffc2f

Browse files
authored
[DmfsTask] implement time field handlers (#437)
* Implement StartTimeHandler with unit test * Implement DueHandler with unit test * Implement DurationHandler with unit test * Cache TimeZoneRegistry in TaskTimeField
1 parent 1fb67fb commit 19ffc2f

8 files changed

Lines changed: 312 additions & 42 deletions

File tree

lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskProcessor.kt

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,21 @@ package at.bitfire.synctools.mapping.tasks
99
import android.content.ContentValues
1010
import at.bitfire.ical4android.Task
1111
import at.bitfire.ical4android.UnknownProperty
12-
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
1312
import at.bitfire.synctools.mapping.tasks.handler.AlarmsHandler
13+
import at.bitfire.synctools.mapping.tasks.handler.DueHandler
14+
import at.bitfire.synctools.mapping.tasks.handler.DurationHandler
1415
import at.bitfire.synctools.mapping.tasks.handler.DmfsTaskFieldHandler
1516
import at.bitfire.synctools.mapping.tasks.handler.DmfsTaskPropertyHandler
1617
import at.bitfire.synctools.mapping.tasks.handler.SequenceHandler
18+
import at.bitfire.synctools.mapping.tasks.handler.StartTimeHandler
1719
import at.bitfire.synctools.mapping.tasks.handler.TitleHandler
1820
import at.bitfire.synctools.mapping.tasks.handler.UidHandler
1921
import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA
2022
import at.bitfire.synctools.storage.tasks.DmfsTaskList
2123
import at.bitfire.synctools.util.AndroidTimeUtils
22-
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
2324
import net.fortuna.ical4j.model.parameter.RelType
2425
import net.fortuna.ical4j.model.property.Clazz
2526
import net.fortuna.ical4j.model.property.Completed
26-
import net.fortuna.ical4j.model.property.DtStart
27-
import net.fortuna.ical4j.model.property.Due
28-
import net.fortuna.ical4j.model.property.Duration
2927
import net.fortuna.ical4j.model.property.ExDate
3028
import net.fortuna.ical4j.model.property.Geo
3129
import net.fortuna.ical4j.model.property.Organizer
@@ -57,6 +55,9 @@ class DmfsTaskProcessor(
5755
UidHandler(),
5856
TitleHandler(),
5957
SequenceHandler(),
58+
StartTimeHandler(),
59+
DueHandler(),
60+
DurationHandler(),
6061
)
6162

6263
private val propertyHandlers: Map<String, DmfsTaskPropertyHandler> = mapOf(
@@ -117,46 +118,9 @@ class DmfsTaskProcessor(
117118

118119
val allDay = (values.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0
119120

120-
val tzID = values.getAsString(Tasks.TZ)
121-
val tz = tzID?.let {
122-
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
123-
tzRegistry.getTimeZone(it)
124-
}
125-
126121
values.getAsLong(Tasks.CREATED)?.let { to.createdAt = it }
127122
values.getAsLong(Tasks.LAST_MODIFIED)?.let { to.lastModified = it }
128123

129-
values.getAsLong(Tasks.DTSTART)?.let { dtStart ->
130-
val instant = Instant.ofEpochMilli(dtStart)
131-
to.dtStart =
132-
if (allDay)
133-
DtStart(instant.toLocalDate())
134-
else {
135-
if (tz == null)
136-
DtStart(instant)
137-
else
138-
DtStart(instant.atZone(tz.toZoneId()))
139-
}
140-
}
141-
142-
values.getAsLong(Tasks.DUE)?.let { due ->
143-
val instant = Instant.ofEpochMilli(due)
144-
to.due =
145-
if (allDay)
146-
Due(instant.toLocalDate())
147-
else {
148-
if (tz == null)
149-
Due(instant)
150-
else
151-
Due(instant.atZone(tz.toZoneId()))
152-
}
153-
}
154-
155-
values.getAsString(Tasks.DURATION)?.let { duration ->
156-
val fixedDuration = AndroidTimeUtils.parseDuration(duration)
157-
to.duration = Duration(fixedDuration)
158-
}
159-
160124
values.getAsString(Tasks.RDATE)?.let { rdateStr ->
161125
AndroidTimeUtils.androidStringToRecurrenceSet(rdateStr, allDay) { dates -> RDate(dates) }?.let { to.rDates += it }
162126
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import at.bitfire.ical4android.Task
11+
import net.fortuna.ical4j.model.property.Due
12+
import org.dmfs.tasks.contract.TaskContract.Tasks
13+
14+
class DueHandler : DmfsTaskFieldHandler {
15+
16+
override fun process(from: ContentValues, to: Task) {
17+
val epochMillis = from.getAsLong(Tasks.DUE) ?: return
18+
19+
val allDay = (from.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0
20+
val tzId = from.getAsString(Tasks.TZ)
21+
22+
to.due = Due(TaskTimeField(epochMillis, tzId, allDay).toTemporal())
23+
}
24+
25+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import at.bitfire.ical4android.Task
11+
import at.bitfire.synctools.util.AndroidTimeUtils
12+
import net.fortuna.ical4j.model.property.Duration
13+
import org.dmfs.tasks.contract.TaskContract.Tasks
14+
15+
class DurationHandler : DmfsTaskFieldHandler {
16+
17+
override fun process(from: ContentValues, to: Task) {
18+
from.getAsString(Tasks.DURATION)?.let { durationStr ->
19+
to.duration = Duration(AndroidTimeUtils.parseDuration(durationStr))
20+
}
21+
}
22+
23+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import at.bitfire.ical4android.Task
11+
import net.fortuna.ical4j.model.property.DtStart
12+
import org.dmfs.tasks.contract.TaskContract.Tasks
13+
14+
class StartTimeHandler : DmfsTaskFieldHandler {
15+
16+
override fun process(from: ContentValues, to: Task) {
17+
val epochMillis = from.getAsLong(Tasks.DTSTART) ?: return
18+
19+
val allDay = (from.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0
20+
val tzId = from.getAsString(Tasks.TZ)
21+
22+
to.dtStart = DtStart(TaskTimeField(epochMillis, tzId, allDay).toTemporal())
23+
}
24+
25+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
10+
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
11+
import java.time.Instant
12+
import java.time.temporal.Temporal
13+
14+
/**
15+
* Converts a task timestamp (epoch milliseconds) together with its timezone and all-day flag
16+
* into the appropriate [Temporal] type for use in iCalendar properties.
17+
*
18+
* Analogous to [at.bitfire.synctools.mapping.calendar.handler.AndroidTimeField] for calendar events.
19+
*
20+
* @param timestamp epoch milliseconds (value of [org.dmfs.tasks.contract.TaskContract.Tasks.DTSTART] or [org.dmfs.tasks.contract.TaskContract.Tasks.DUE])
21+
* @param tzId value of [org.dmfs.tasks.contract.TaskContract.Tasks.TZ]:
22+
* `null` for all-day tasks storage; timezone ID (e.g. `"UTC"`, `"Europe/Berlin"`)
23+
* for non-all-day tasks.
24+
* @param allDay whether [org.dmfs.tasks.contract.TaskContract.Tasks.IS_ALLDAY] is non-zero
25+
*/
26+
class TaskTimeField(
27+
private val timestamp: Long,
28+
private val tzId: String?,
29+
private val allDay: Boolean,
30+
) {
31+
32+
private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() }
33+
34+
/**
35+
* Converts the stored timestamp to the correct [Temporal] representation:
36+
* - `allDay = true` → [java.time.LocalDate] (interpreted at UTC midnight)
37+
* - `allDay = false`, no/unknown timezone → [Instant] (UTC)
38+
* - `allDay = false`, known timezone → [java.time.ZonedDateTime]
39+
*/
40+
fun toTemporal(): Temporal {
41+
val instant = Instant.ofEpochMilli(timestamp)
42+
43+
if (allDay)
44+
return instant.toLocalDate()
45+
46+
val tz = tzId?.let { tzRegistry.getTimeZone(it) }
47+
48+
return if (tz == null)
49+
instant
50+
else
51+
instant.atZone(tz.toZoneId())
52+
}
53+
54+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import androidx.core.content.contentValuesOf
11+
import at.bitfire.ical4android.Task
12+
import net.fortuna.ical4j.model.property.Due
13+
import org.dmfs.tasks.contract.TaskContract.Tasks
14+
import org.junit.Assert.assertEquals
15+
import org.junit.Assert.assertNull
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.RobolectricTestRunner
19+
import java.time.Instant
20+
import java.time.LocalDate
21+
import java.time.ZoneId
22+
import java.time.ZonedDateTime
23+
24+
@RunWith(RobolectricTestRunner::class)
25+
class DueHandlerTest {
26+
27+
private val handler = DueHandler()
28+
29+
@Test
30+
fun `No DUE leaves due null`() {
31+
val task = Task()
32+
handler.process(ContentValues(), task)
33+
assertNull(task.due)
34+
}
35+
36+
@Test
37+
fun `All-day due date`() {
38+
val task = Task()
39+
handler.process(contentValuesOf(
40+
Tasks.DUE to 1592697600000L, // 2020-06-21 00:00:00 UTC
41+
Tasks.IS_ALLDAY to 1,
42+
), task)
43+
assertEquals(Due(LocalDate.of(2020, 6, 21)), task.due)
44+
}
45+
46+
@Test
47+
fun `Non-all-day due with timezone`() {
48+
val task = Task()
49+
handler.process(contentValuesOf(
50+
Tasks.DUE to 1592733600000L, // 2020-06-21 10:00:00 UTC = 12:00:00 Europe/Vienna
51+
Tasks.TZ to "Europe/Vienna",
52+
), task)
53+
val expected = ZonedDateTime.of(2020, 6, 21, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"))
54+
assertEquals(Due(expected), task.due)
55+
}
56+
57+
@Test
58+
fun `Non-all-day due without timezone (UTC Instant)`() {
59+
val task = Task()
60+
handler.process(contentValuesOf(
61+
Tasks.DUE to 1592733600000L, // 2020-06-21 10:00:00 UTC
62+
), task)
63+
assertEquals(Due(Instant.ofEpochMilli(1592733600000L)), task.due)
64+
}
65+
66+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import androidx.core.content.contentValuesOf
11+
import at.bitfire.ical4android.Task
12+
import at.bitfire.synctools.util.AndroidTimeUtils
13+
import net.fortuna.ical4j.model.property.Duration
14+
import org.dmfs.tasks.contract.TaskContract.Tasks
15+
import org.junit.Assert.assertEquals
16+
import org.junit.Assert.assertNull
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
21+
@RunWith(RobolectricTestRunner::class)
22+
class DurationHandlerTest {
23+
24+
private val handler = DurationHandler()
25+
26+
@Test
27+
fun `No DURATION leaves duration null`() {
28+
val task = Task()
29+
handler.process(ContentValues(), task)
30+
assertNull(task.duration)
31+
}
32+
33+
@Test
34+
fun `DURATION PT1H is mapped correctly`() {
35+
val task = Task()
36+
handler.process(contentValuesOf(Tasks.DURATION to "PT1H"), task)
37+
assertEquals(Duration(AndroidTimeUtils.parseDuration("PT1H")), task.duration)
38+
}
39+
40+
@Test
41+
fun `DURATION P1D is mapped correctly`() {
42+
val task = Task()
43+
handler.process(contentValuesOf(Tasks.DURATION to "P1D"), task)
44+
assertEquals(Duration(AndroidTimeUtils.parseDuration("P1D")), task.duration)
45+
}
46+
47+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.tasks.handler
8+
9+
import android.content.ContentValues
10+
import androidx.core.content.contentValuesOf
11+
import at.bitfire.ical4android.Task
12+
import net.fortuna.ical4j.model.property.DtStart
13+
import org.dmfs.tasks.contract.TaskContract.Tasks
14+
import org.junit.Assert.assertEquals
15+
import org.junit.Assert.assertNull
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.RobolectricTestRunner
19+
import java.time.Instant
20+
import java.time.LocalDate
21+
import java.time.ZoneId
22+
import java.time.ZonedDateTime
23+
24+
@RunWith(RobolectricTestRunner::class)
25+
class StartTimeHandlerTest {
26+
27+
private val handler = StartTimeHandler()
28+
29+
@Test
30+
fun `No DTSTART leaves dtStart null`() {
31+
val task = Task()
32+
handler.process(ContentValues(), task)
33+
assertNull(task.dtStart)
34+
}
35+
36+
@Test
37+
fun `All-day start time`() {
38+
val task = Task()
39+
handler.process(contentValuesOf(
40+
Tasks.DTSTART to 1592697600000L, // 2020-06-21 00:00:00 UTC
41+
Tasks.IS_ALLDAY to 1,
42+
), task)
43+
assertEquals(DtStart(LocalDate.of(2020, 6, 21)), task.dtStart)
44+
}
45+
46+
@Test
47+
fun `Non-all-day start time with timezone`() {
48+
val task = Task()
49+
handler.process(contentValuesOf(
50+
Tasks.DTSTART to 1592733600000L, // 2020-06-21 10:00:00 UTC = 12:00:00 Europe/Vienna
51+
Tasks.TZ to "Europe/Vienna",
52+
), task)
53+
val expected = ZonedDateTime.of(2020, 6, 21, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"))
54+
assertEquals(DtStart(expected), task.dtStart)
55+
}
56+
57+
@Test
58+
fun `Non-all-day start time without timezone (UTC Instant)`() {
59+
val task = Task()
60+
handler.process(contentValuesOf(
61+
Tasks.DTSTART to 1592733600000L, // 2020-06-21 10:00:00 UTC
62+
), task)
63+
assertEquals(DtStart(Instant.ofEpochMilli(1592733600000L)), task.dtStart)
64+
}
65+
66+
}

0 commit comments

Comments
 (0)