From 3b2d26a30db09ce1ecadea01670557613539f399 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 22 May 2026 10:53:47 +0200 Subject: [PATCH 1/4] Add test for COMPLETED without timezone not throwing ClassCastException --- .../tasks/builder/CompletedBuilderTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt index 8c73802b..4232d5bb 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt @@ -17,6 +17,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId @RunWith(RobolectricTestRunner::class) class CompletedBuilderTest { @@ -50,4 +52,22 @@ class CompletedBuilderTest { ), result.entityValues) } + @Test + fun `COMPLETED is floating LocalDateTime`() { + // COMPLETED without timezone (floating) must not crash with ClassCastException + // A floating COMPLETED is represented as a string without 'Z' (e.g. "20240601T120000") + val result = Entity(ContentValues()) + builder.build( + from = Task(completedAt = Completed("20240601T120000")), + to = result + ) + // floating date-time is interpreted in system default timezone + val expectedTimestamp = LocalDateTime.of(2024, 6, 1, 12, 0, 0) + .atZone(ZoneId.systemDefault()).toInstant().epochSecond * 1000 + assertContentValuesEqual(contentValuesOf( + Tasks.COMPLETED to expectedTimestamp, + Tasks.COMPLETED_IS_ALLDAY to 0 + ), result.entityValues) + } + } From d7255b72f3691904a4b366dbceb8b18f07641aed Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 22 May 2026 10:54:10 +0200 Subject: [PATCH 2/4] Use normalized date and timestamp for COMPLETED field --- .../synctools/mapping/tasks/builder/CompletedBuilder.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilder.kt index b8f6578e..87d018d1 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilder.kt @@ -8,13 +8,15 @@ 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 CompletedBuilder : DmfsTaskFieldBuilder { override fun build(from: Task, to: Entity) { // COMPLETED must always be a DATE-TIME - to.entityValues.put(Tasks.COMPLETED, from.completedAt?.date?.toEpochMilli()) + to.entityValues.put(Tasks.COMPLETED, from.completedAt?.normalizedDate()?.toTimestamp()) to.entityValues.put(Tasks.COMPLETED_IS_ALLDAY, 0) } From 66283a898f915eb1d82cd2381c3787778e2ed229 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 22 May 2026 11:10:22 +0200 Subject: [PATCH 3/4] Fix test determinism --- .../mapping/tasks/builder/CompletedBuilderTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt index 4232d5bb..86892bd8 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt @@ -9,20 +9,24 @@ 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.Completed import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId @RunWith(RobolectricTestRunner::class) class CompletedBuilderTest { + @get:Rule + val tzRule = DefaultTimezoneRule("Europe/Berlin") + private val builder = CompletedBuilder() @Test @@ -63,7 +67,7 @@ class CompletedBuilderTest { ) // floating date-time is interpreted in system default timezone val expectedTimestamp = LocalDateTime.of(2024, 6, 1, 12, 0, 0) - .atZone(ZoneId.systemDefault()).toInstant().epochSecond * 1000 + .atZone(tzRule.defaultZoneId).toInstant().toEpochMilli() assertContentValuesEqual(contentValuesOf( Tasks.COMPLETED to expectedTimestamp, Tasks.COMPLETED_IS_ALLDAY to 0 From a9ecfe72325516c503a3ab2d4bf71b1669da673f Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 22 May 2026 11:24:55 +0200 Subject: [PATCH 4/4] Don't lose milliseconds in toTimestamp util --- .../main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt index 3caff1b3..97999254 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -77,7 +77,7 @@ object AndroidTimeUtils { * Same as [toInstant], but returns a UNIX timestamp (in milliseconds) instead of an [Instant]. */ fun Temporal.toTimestamp(): Long = - toInstant().epochSecond * 1000 + toInstant().toEpochMilli() /** * Converts this [Temporal] to a [ZonedDateTime] that is created from the timestamp returned by