From eef22853392a3ea83ad97d8ccea6b28c21afd357 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Tue, 12 May 2026 10:35:16 +0200 Subject: [PATCH 1/2] Extract time and recurrence field builders from DmfsTaskBuilder Extract AllDayBuilder, StartTimeBuilder, DueBuilder, DurationBuilder, and RecurrenceFieldsBuilder from the inline code in buildTask(). AllDayBuilder takes over the timezone resolution logic previously in DmfsTaskBuilder.getTimeZone(), which is now removed. --- .../mapping/tasks/DmfsTaskBuilder.kt | 77 +++------------ .../mapping/tasks/builder/AllDayBuilder.kt | 57 +++++++++++ .../mapping/tasks/builder/DueBuilder.kt | 21 ++++ .../mapping/tasks/builder/DurationBuilder.kt | 19 ++++ .../tasks/builder/RecurrenceFieldsBuilder.kt | 39 ++++++++ .../mapping/tasks/builder/StartTimeBuilder.kt | 21 ++++ .../tasks/builder/AllDayBuilderTest.kt | 95 +++++++++++++++++++ .../mapping/tasks/builder/DueBuilderTest.kt | 65 +++++++++++++ .../tasks/builder/DurationBuilderTest.kt | 49 ++++++++++ .../builder/RecurrenceFieldsBuilderTest.kt | 91 ++++++++++++++++++ .../tasks/builder/StartTimeBuilderTest.kt | 65 +++++++++++++ 11 files changed, 535 insertions(+), 64 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilder.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilder.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilder.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilder.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilder.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilderTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilderTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilderTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilderTest.kt diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index cd9ee81b..a109dd6b 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -10,8 +10,12 @@ import android.content.ContentValues import android.content.Entity import at.bitfire.ical4android.Task import at.bitfire.ical4android.UnknownProperty -import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.mapping.tasks.builder.AllDayBuilder import at.bitfire.synctools.mapping.tasks.builder.DmfsTaskFieldBuilder +import at.bitfire.synctools.mapping.tasks.builder.DueBuilder +import at.bitfire.synctools.mapping.tasks.builder.DurationBuilder +import at.bitfire.synctools.mapping.tasks.builder.RecurrenceFieldsBuilder +import at.bitfire.synctools.mapping.tasks.builder.StartTimeBuilder import at.bitfire.synctools.mapping.tasks.builder.TitleBuilder import at.bitfire.synctools.storage.BatchOperation.CpoBuilder import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_ETAG @@ -20,30 +24,21 @@ import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DA import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.storage.tasks.TasksBatchOperation import at.bitfire.synctools.util.AlarmTriggerCalculator -import at.bitfire.synctools.util.AndroidTimeUtils -import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.parameter.Email import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.parameter.TzId import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.immutable.ImmutableAction import net.fortuna.ical4j.model.property.immutable.ImmutableClazz import net.fortuna.ical4j.model.property.immutable.ImmutableStatus -import net.fortuna.ical4j.util.TimeZones import org.dmfs.tasks.contract.TaskContract.Properties import org.dmfs.tasks.contract.TaskContract.Property.Alarm import org.dmfs.tasks.contract.TaskContract.Property.Category import org.dmfs.tasks.contract.TaskContract.Property.Comment import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.Tasks -import java.time.ZoneId import java.util.Locale import java.util.logging.Level import java.util.logging.Logger @@ -65,14 +60,19 @@ class DmfsTaskBuilder( ) { private val fieldBuilders: Array = arrayOf( - TitleBuilder() + // time fields and recurrence + TitleBuilder(), + AllDayBuilder(), + StartTimeBuilder(), + DueBuilder(), + DurationBuilder(), + RecurrenceFieldsBuilder(), + // property sub-rows (still inline below via insertProperties) ) private val logger get() = Logger.getLogger(javaClass.name) - private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } - fun addRows(batch: TasksBatchOperation): Int { val builder = CpoBuilder.newInsert(taskList.tasksUri()) buildTask(builder, false) @@ -156,64 +156,13 @@ class DmfsTaskBuilder( else -> Tasks.STATUS_DEFAULT // == Tasks.STATUS_NEEDS_ACTION } builder.withValue(Tasks.STATUS, status) - - // Time related - val allDay = task.isAllDay() - if (allDay) { - builder .withValue(Tasks.IS_ALLDAY, 1) - .withValue(Tasks.TZ, null) - } else { - task.dtStart = task.dtStart?.normalizedDate()?.let { DtStart(it) } - task.due = task.due?.normalizedDate()?.let { Due(it) } - builder .withValue(Tasks.IS_ALLDAY, 0) - .withValue(Tasks.TZ, getTimeZone().id) - } builder .withValue(Tasks.CREATED, task.createdAt) .withValue(Tasks.LAST_MODIFIED, task.lastModified) - .withValue(Tasks.DTSTART, task.dtStart?.date?.toTimestamp()) - .withValue(Tasks.DUE, task.due?.date?.toTimestamp()) - .withValue(Tasks.DURATION, task.duration?.value) - - .withValue(Tasks.RDATE, - if (task.rDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.rDates, if (allDay) null else getTimeZone())) - .withValue(Tasks.RRULE, task.rRule?.value) - - .withValue(Tasks.EXDATE, - if (task.exDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.exDates, if (allDay) null else getTimeZone())) - logger.log(Level.FINE, "Built task object", builder.build()) } - fun getTimeZone(): TimeZone { - var tzId = task.dtStart?.let { dtStart -> - if (dtStart.isUtc) - TimeZones.UTC_ID - else - dtStart.getParameter(Parameter.TZID).getOrNull()?.value - } ?: - task.due?.let { due -> - if (due.isUtc) - TimeZones.UTC_ID - else - due.getParameter(Parameter.TZID).getOrNull()?.value - } ?: - ZoneId.systemDefault().id - - // 'Z' is not a valid timezone id, replace it by the UTC definition - if (tzId == "Z") tzId = TimeZones.UTC_ID - - val timeZone: TimeZone? = tzRegistry.getTimeZone(tzId) - return timeZone ?: throw NullPointerException("Could not find timezone '$tzId' in registry.") - } - fun insertProperties(batch: TasksBatchOperation, idxTask: Int?) { insertAlarms(batch, idxTask) insertCategories(batch, idxTask) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilder.kt new file mode 100644 index 00000000..d06254ca --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilder.kt @@ -0,0 +1,57 @@ +/* + * 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.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.util.TimeZones +import org.dmfs.tasks.contract.TaskContract.Tasks +import java.time.ZoneId +import kotlin.jvm.optionals.getOrNull + +class AllDayBuilder : DmfsTaskFieldBuilder { + + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + + override fun build(from: Task, to: Entity) { + val allDay = from.isAllDay() + if (allDay) { + to.entityValues.put(Tasks.IS_ALLDAY, 1) + to.entityValues.putNull(Tasks.TZ) + } else { + to.entityValues.put(Tasks.IS_ALLDAY, 0) + to.entityValues.put(Tasks.TZ, getTimeZone(from).id) + } + } + + fun getTimeZone(task: Task): TimeZone { + var tzId = task.dtStart?.let { dtStart -> + if (dtStart.isUtc) + TimeZones.UTC_ID + else + dtStart.getParameter(Parameter.TZID).getOrNull()?.value + } ?: + task.due?.let { due -> + if (due.isUtc) + TimeZones.UTC_ID + else + due.getParameter(Parameter.TZID).getOrNull()?.value + } ?: + ZoneId.systemDefault().id + + // 'Z' is not a valid timezone id, replace it by the UTC definition + if (tzId == "Z") tzId = TimeZones.UTC_ID + + val timeZone: TimeZone? = tzRegistry.getTimeZone(tzId) + return timeZone ?: throw NullPointerException("Could not find timezone '$tzId' in registry.") + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilder.kt new file mode 100644 index 00000000..74ef5fa6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilder.kt @@ -0,0 +1,21 @@ +/* + * 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.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp +import org.dmfs.tasks.contract.TaskContract.Tasks + +class DueBuilder : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + to.entityValues.put(Tasks.DUE, from.due?.normalizedDate()?.toTimestamp()) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilder.kt new file mode 100644 index 00000000..fbd49397 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilder.kt @@ -0,0 +1,19 @@ +/* + * 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.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import org.dmfs.tasks.contract.TaskContract.Tasks + +class DurationBuilder : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + to.entityValues.put(Tasks.DURATION, from.duration?.value) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilder.kt new file mode 100644 index 00000000..df5f4dcf --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilder.kt @@ -0,0 +1,39 @@ +/* + * 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.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import at.bitfire.synctools.util.AndroidTimeUtils +import org.dmfs.tasks.contract.TaskContract.Tasks + +class RecurrenceFieldsBuilder : DmfsTaskFieldBuilder { + + private val allDayBuilder = AllDayBuilder() + + override fun build(from: Task, to: Entity) { + val allDay = from.isAllDay() + val tz = if (allDay) null else allDayBuilder.getTimeZone(from) + + to.entityValues.put(Tasks.RRULE, from.rRule?.value) + + to.entityValues.put(Tasks.RDATE, + if (from.rDates.isEmpty()) + null + else + AndroidTimeUtils.recurrenceSetsToOpenTasksString(from.rDates, tz) + ) + + to.entityValues.put(Tasks.EXDATE, + if (from.exDates.isEmpty()) + null + else + AndroidTimeUtils.recurrenceSetsToOpenTasksString(from.exDates, tz) + ) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilder.kt new file mode 100644 index 00000000..7bd8505f --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilder.kt @@ -0,0 +1,21 @@ +/* + * 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.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp +import org.dmfs.tasks.contract.TaskContract.Tasks + +class StartTimeBuilder : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + to.entityValues.put(Tasks.DTSTART, from.dtStart?.normalizedDate()?.toTimestamp()) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt new file mode 100644 index 00000000..882cf059 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt @@ -0,0 +1,95 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@RunWith(RobolectricTestRunner::class) +class AllDayBuilderTest { + + private val builder = AllDayBuilder() + + @Test + fun `No DTSTART and no DUE - treated as all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 1, + Tasks.TZ to null + ), result.entityValues) + } + + @Test + fun `DTSTART is DATE - all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(dtStart = DtStart(LocalDate.of(2025, 1, 15))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 1, + Tasks.TZ to null + ), result.entityValues) + } + + @Test + fun `DTSTART is DATE-TIME - not all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(dtStart = DtStart(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to "Etc/UTC" + ), result.entityValues) + } + + @Test + fun `DUE is DATE - all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(due = Due(LocalDate.of(2025, 1, 15))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 1, + Tasks.TZ to null + ), result.entityValues) + } + + @Test + fun `DUE is DATE-TIME - not all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(due = Due(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to "Etc/UTC" + ), result.entityValues) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilderTest.kt new file mode 100644 index 00000000..8236294e --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DueBuilderTest.kt @@ -0,0 +1,65 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import net.fortuna.ical4j.model.property.Due +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@RunWith(RobolectricTestRunner::class) +class DueBuilderTest { + + private val builder = DueBuilder() + + @Test + fun `No DUE`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DUE to null + ), result.entityValues) + } + + @Test + fun `DUE is DATE`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(due = Due(LocalDate.of(2025, 1, 15))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DUE to 1736899200000L // 2025-01-15 00:00:00 UTC + ), result.entityValues) + } + + @Test + fun `DUE is DATE-TIME (UTC)`() { + val result = Entity(ContentValues()) + val ts = ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC) + builder.build( + from = Task(due = Due(ts)), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DUE to ts.toInstant().toEpochMilli() + ), result.entityValues) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilderTest.kt new file mode 100644 index 00000000..01c513d6 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DurationBuilderTest.kt @@ -0,0 +1,49 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import net.fortuna.ical4j.model.property.Duration +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DurationBuilderTest { + + private val builder = DurationBuilder() + + @Test + fun `No DURATION`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DURATION to null + ), result.entityValues) + } + + @Test + fun `DURATION is set`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(duration = Duration(null, "PT2H")), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DURATION to "PT2H" + ), result.entityValues) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilderTest.kt new file mode 100644 index 00000000..c47d6a13 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RecurrenceFieldsBuilderTest.kt @@ -0,0 +1,91 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal + +@RunWith(RobolectricTestRunner::class) +class RecurrenceFieldsBuilderTest { + + private val builder = RecurrenceFieldsBuilder() + + @Test + fun `No recurrence fields`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.RRULE to null, + Tasks.RDATE to null, + Tasks.EXDATE to null + ), result.entityValues) + } + + @Test + fun `RRULE is set`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.rRule = RRule("FREQ=DAILY;COUNT=10") + }, + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.RRULE to "FREQ=DAILY;COUNT=10", + Tasks.RDATE to null, + Tasks.EXDATE to null + ), result.entityValues) + } + + @Test + fun `RDATE all-day dates`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.rDates += RDate(DateList(LocalDate.of(2025, 1, 15))) + }, + to = result + ) + // All-day task: rDates formatted without timezone + val rdate = result.entityValues.getAsString(Tasks.RDATE) + assert(rdate != null) { "RDATE should be set" } + } + + @Test + fun `EXDATE all-day dates`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.rRule = RRule("FREQ=DAILY;COUNT=10") + it.exDates += ExDate(DateList(LocalDate.of(2025, 1, 15))) + }, + to = result + ) + val exdate = result.entityValues.getAsString(Tasks.EXDATE) + assert(exdate != null) { "EXDATE should be set" } + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilderTest.kt new file mode 100644 index 00000000..b69b026f --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/StartTimeBuilderTest.kt @@ -0,0 +1,65 @@ +/* + * 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.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import net.fortuna.ical4j.model.property.DtStart +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@RunWith(RobolectricTestRunner::class) +class StartTimeBuilderTest { + + private val builder = StartTimeBuilder() + + @Test + fun `No DTSTART`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DTSTART to null + ), result.entityValues) + } + + @Test + fun `DTSTART is DATE`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(dtStart = DtStart(LocalDate.of(2025, 1, 15))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DTSTART to 1736899200000L // 2025-01-15 00:00:00 UTC + ), result.entityValues) + } + + @Test + fun `DTSTART is DATE-TIME (UTC)`() { + val result = Entity(ContentValues()) + val ts = ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC) + builder.build( + from = Task(dtStart = DtStart(ts)), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.DTSTART to ts.toInstant().toEpochMilli() + ), result.entityValues) + } + +} From b1a3e587243b85bef2d64b96a97c5abb8d0852c1 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Tue, 12 May 2026 15:40:25 +0200 Subject: [PATCH 2/2] Remove obsolete timezone tests from DmfsTaskBuilderTest and add timezone handling tests to AllDayBuilderTest --- .../mapping/tasks/DmfsTaskBuilderTest.kt | 28 --------- .../tasks/builder/AllDayBuilderTest.kt | 57 +++++++++++++++++++ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt index d97b841c..2932a6c8 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt @@ -63,7 +63,6 @@ class DmfsTaskBuilderTest ( private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! private val tzVienna = tzRegistry.getTimeZone("Europe/Vienna")!! private val tzChicago = tzRegistry.getTimeZone("America/Chicago")!! - private val tzDefault = tzRegistry.getTimeZone(ZoneId.systemDefault().id)!! private val testAccount = Account(javaClass.name, TaskContract.LOCAL_ACCOUNT_TYPE) @@ -845,33 +844,6 @@ class DmfsTaskBuilderTest ( } - // other methods - - @Test - fun testGetTimeZone_noDateOrDateTime() { - val builder = DmfsTaskBuilder(taskList!!, Task(), 0, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0) - assertEquals(tzDefault, builder.getTimeZone()) - } - - @Test - fun testGetTimeZone_dtstart_with_date_and_no_time() { - val task = Task() - val builder = DmfsTaskBuilder(taskList!!, task, 0, "410c19d7-df79-4d65-8146-40b7bec5923b", null, 0) - val dmfsTask = DmfsTask(taskList!!, task, "410c19d7-df79-4d65-8146-40b7bec5923b", null, 0) - dmfsTask.task!!.dtStart = DtStart(LocalDate.of(2015, 1, 1)) - assertEquals(tzDefault, builder.getTimeZone()) - } - - @Test - fun testGetTimeZone_dtstart_with_time() { - val task = Task() - val builder = DmfsTaskBuilder(taskList!!, task, 0, "9468a4cf-0d5b-4379-a704-12f1f84100ba", null, 0) - val dmfsTask = DmfsTask(taskList!!, task, "9dc64544-1816-4f04-b952-e894164467f6", null, 0) - dmfsTask.task!!.dtStart = DtStart(LocalDate.of(2015, 1, 1).atStartOfDay(tzVienna.toZoneId())) - assertEquals(tzVienna, builder.getTimeZone()) - } - - // helpers private fun buildTask(taskBuilder: Task.() -> Unit): ContentValues { diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt index 882cf059..9690a674 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AllDayBuilderTest.kt @@ -9,11 +9,13 @@ package at.bitfire.synctools.mapping.tasks.builder import android.content.ContentValues import android.content.Entity import androidx.core.content.contentValuesOf +import at.bitfire.DefaultTimezoneRule import at.bitfire.ical4android.Task import at.bitfire.synctools.test.assertContentValuesEqual import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -25,6 +27,9 @@ import java.time.ZonedDateTime @RunWith(RobolectricTestRunner::class) class AllDayBuilderTest { + @get:Rule + val defaultTimezone = DefaultTimezoneRule("Europe/Berlin") + private val builder = AllDayBuilder() @Test @@ -92,4 +97,56 @@ class AllDayBuilderTest { ), result.entityValues) } + @Test + fun `DTSTART is DATE-TIME with named timezone - not all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(dtStart = DtStart(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, defaultTimezone.defaultZoneId))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to defaultTimezone.defaultZoneId.id + ), result.entityValues) + } + + @Test + fun `DTSTART is floating DATE-TIME - not all-day, uses system default timezone`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(dtStart = DtStart(LocalDateTime.of(2025, 1, 15, 10, 0, 0))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to defaultTimezone.defaultZoneId.id + ), result.entityValues) + } + + @Test + fun `DUE is DATE-TIME with named timezone (no DTSTART) - not all-day`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(due = Due(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, defaultTimezone.defaultZoneId))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to defaultTimezone.defaultZoneId.id + ), result.entityValues) + } + + @Test + fun `DUE is floating DATE-TIME (no DTSTART) - not all-day, uses system default timezone`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(due = Due(LocalDateTime.of(2025, 1, 15, 10, 0, 0))), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.IS_ALLDAY to 0, + Tasks.TZ to defaultTimezone.defaultZoneId.id + ), result.entityValues) + } + }