Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
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 @@ -9,23 +9,21 @@ package at.bitfire.synctools.mapping.tasks
import android.content.ContentValues
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
import at.bitfire.synctools.mapping.tasks.handler.AlarmsHandler
import at.bitfire.synctools.mapping.tasks.handler.DueHandler
import at.bitfire.synctools.mapping.tasks.handler.DurationHandler
import at.bitfire.synctools.mapping.tasks.handler.DmfsTaskFieldHandler
import at.bitfire.synctools.mapping.tasks.handler.DmfsTaskPropertyHandler
import at.bitfire.synctools.mapping.tasks.handler.SequenceHandler
import at.bitfire.synctools.mapping.tasks.handler.StartTimeHandler
import at.bitfire.synctools.mapping.tasks.handler.TitleHandler
import at.bitfire.synctools.mapping.tasks.handler.UidHandler
import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA
import at.bitfire.synctools.storage.tasks.DmfsTaskList
import at.bitfire.synctools.util.AndroidTimeUtils
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.property.Clazz
import net.fortuna.ical4j.model.property.Completed
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.Due
import net.fortuna.ical4j.model.property.Duration
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.Geo
import net.fortuna.ical4j.model.property.Organizer
Expand Down Expand Up @@ -57,6 +55,9 @@ class DmfsTaskProcessor(
UidHandler(),
TitleHandler(),
SequenceHandler(),
StartTimeHandler(),
DueHandler(),
DurationHandler(),
)

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

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

val tzID = values.getAsString(Tasks.TZ)
val tz = tzID?.let {
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
tzRegistry.getTimeZone(it)
}

values.getAsLong(Tasks.CREATED)?.let { to.createdAt = it }
values.getAsLong(Tasks.LAST_MODIFIED)?.let { to.lastModified = it }

values.getAsLong(Tasks.DTSTART)?.let { dtStart ->
val instant = Instant.ofEpochMilli(dtStart)
to.dtStart =
if (allDay)
DtStart(instant.toLocalDate())
else {
if (tz == null)
DtStart(instant)
else
DtStart(instant.atZone(tz.toZoneId()))
}
}

values.getAsLong(Tasks.DUE)?.let { due ->
val instant = Instant.ofEpochMilli(due)
to.due =
if (allDay)
Due(instant.toLocalDate())
else {
if (tz == null)
Due(instant)
else
Due(instant.atZone(tz.toZoneId()))
}
}

values.getAsString(Tasks.DURATION)?.let { duration ->
val fixedDuration = AndroidTimeUtils.parseDuration(duration)
to.duration = Duration(fixedDuration)
}

values.getAsString(Tasks.RDATE)?.let { rdateStr ->
AndroidTimeUtils.androidStringToRecurrenceSet(rdateStr, allDay) { dates -> RDate(dates) }?.let { to.rDates += it }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import at.bitfire.ical4android.Task
import net.fortuna.ical4j.model.property.Due
import org.dmfs.tasks.contract.TaskContract.Tasks

class DueHandler : DmfsTaskFieldHandler {

override fun process(from: ContentValues, to: Task) {
val epochMillis = from.getAsLong(Tasks.DUE) ?: return

val allDay = (from.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0
val tzId = from.getAsString(Tasks.TZ)

to.due = Due(TaskTimeField(epochMillis, tzId, allDay).toTemporal())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import at.bitfire.ical4android.Task
import at.bitfire.synctools.util.AndroidTimeUtils
import net.fortuna.ical4j.model.property.Duration
import org.dmfs.tasks.contract.TaskContract.Tasks

class DurationHandler : DmfsTaskFieldHandler {

override fun process(from: ContentValues, to: Task) {
from.getAsString(Tasks.DURATION)?.let { durationStr ->
to.duration = Duration(AndroidTimeUtils.parseDuration(durationStr))
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import at.bitfire.ical4android.Task
import net.fortuna.ical4j.model.property.DtStart
import org.dmfs.tasks.contract.TaskContract.Tasks

class StartTimeHandler : DmfsTaskFieldHandler {

override fun process(from: ContentValues, to: Task) {
val epochMillis = from.getAsLong(Tasks.DTSTART) ?: return

val allDay = (from.getAsInteger(Tasks.IS_ALLDAY) ?: 0) != 0
val tzId = from.getAsString(Tasks.TZ)

to.dtStart = DtStart(TaskTimeField(epochMillis, tzId, allDay).toTemporal())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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.tasks.handler

import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
import java.time.Instant
import java.time.temporal.Temporal

/**
* Converts a task timestamp (epoch milliseconds) together with its timezone and all-day flag
* into the appropriate [Temporal] type for use in iCalendar properties.
*
* Analogous to [at.bitfire.synctools.mapping.calendar.handler.AndroidTimeField] for calendar events.
*
* @param timestamp epoch milliseconds (value of [org.dmfs.tasks.contract.TaskContract.Tasks.DTSTART] or [org.dmfs.tasks.contract.TaskContract.Tasks.DUE])
* @param tzId value of [org.dmfs.tasks.contract.TaskContract.Tasks.TZ]:
* `null` for all-day tasks storage; timezone ID (e.g. `"UTC"`, `"Europe/Berlin"`)
* for non-all-day tasks.
* @param allDay whether [org.dmfs.tasks.contract.TaskContract.Tasks.IS_ALLDAY] is non-zero
*/
class TaskTimeField(
private val timestamp: Long,
private val tzId: String?,
private val allDay: Boolean,
) {

private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() }

/**
* Converts the stored timestamp to the correct [Temporal] representation:
* - `allDay = true` → [java.time.LocalDate] (interpreted at UTC midnight)
* - `allDay = false`, no/unknown timezone → [Instant] (UTC)
* - `allDay = false`, known timezone → [java.time.ZonedDateTime]
*/
fun toTemporal(): Temporal {
val instant = Instant.ofEpochMilli(timestamp)

if (allDay)
return instant.toLocalDate()

val tz = tzId?.let { tzRegistry.getTimeZone(it) }

return if (tz == null)
instant
else
instant.atZone(tz.toZoneId())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Task
import net.fortuna.ical4j.model.property.Due
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime

@RunWith(RobolectricTestRunner::class)
class DueHandlerTest {

private val handler = DueHandler()

@Test
fun `No DUE leaves due null`() {
val task = Task()
handler.process(ContentValues(), task)
assertNull(task.due)
}

@Test
fun `All-day due date`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DUE to 1592697600000L, // 2020-06-21 00:00:00 UTC
Tasks.IS_ALLDAY to 1,
), task)
assertEquals(Due(LocalDate.of(2020, 6, 21)), task.due)
}

@Test
fun `Non-all-day due with timezone`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DUE to 1592733600000L, // 2020-06-21 10:00:00 UTC = 12:00:00 Europe/Vienna
Tasks.TZ to "Europe/Vienna",
), task)
val expected = ZonedDateTime.of(2020, 6, 21, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"))
assertEquals(Due(expected), task.due)
}

@Test
fun `Non-all-day due without timezone (UTC Instant)`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DUE to 1592733600000L, // 2020-06-21 10:00:00 UTC
), task)
assertEquals(Due(Instant.ofEpochMilli(1592733600000L)), task.due)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Task
import at.bitfire.synctools.util.AndroidTimeUtils
import net.fortuna.ical4j.model.property.Duration
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class DurationHandlerTest {

private val handler = DurationHandler()

@Test
fun `No DURATION leaves duration null`() {
val task = Task()
handler.process(ContentValues(), task)
assertNull(task.duration)
}

@Test
fun `DURATION PT1H is mapped correctly`() {
val task = Task()
handler.process(contentValuesOf(Tasks.DURATION to "PT1H"), task)
assertEquals(Duration(AndroidTimeUtils.parseDuration("PT1H")), task.duration)
}

@Test
fun `DURATION P1D is mapped correctly`() {
val task = Task()
handler.process(contentValuesOf(Tasks.DURATION to "P1D"), task)
assertEquals(Duration(AndroidTimeUtils.parseDuration("P1D")), task.duration)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.tasks.handler

import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Task
import net.fortuna.ical4j.model.property.DtStart
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime

@RunWith(RobolectricTestRunner::class)
class StartTimeHandlerTest {

private val handler = StartTimeHandler()

@Test
fun `No DTSTART leaves dtStart null`() {
val task = Task()
handler.process(ContentValues(), task)
assertNull(task.dtStart)
}

@Test
fun `All-day start time`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DTSTART to 1592697600000L, // 2020-06-21 00:00:00 UTC
Tasks.IS_ALLDAY to 1,
), task)
assertEquals(DtStart(LocalDate.of(2020, 6, 21)), task.dtStart)
}

@Test
fun `Non-all-day start time with timezone`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DTSTART to 1592733600000L, // 2020-06-21 10:00:00 UTC = 12:00:00 Europe/Vienna
Tasks.TZ to "Europe/Vienna",
), task)
val expected = ZonedDateTime.of(2020, 6, 21, 12, 0, 0, 0, ZoneId.of("Europe/Vienna"))
assertEquals(DtStart(expected), task.dtStart)
}

@Test
fun `Non-all-day start time without timezone (UTC Instant)`() {
val task = Task()
handler.process(contentValuesOf(
Tasks.DTSTART to 1592733600000L, // 2020-06-21 10:00:00 UTC
), task)
assertEquals(DtStart(Instant.ofEpochMilli(1592733600000L)), task.dtStart)
}

}
Loading