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 2932a6c8..1824dc62 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 @@ -515,7 +515,7 @@ class DmfsTaskBuilderTest ( }.let { result -> // Note: iCalendar does not allow COMPLETED to be all-day [RFC 5545 3.8.2.1] assertEquals(0, result.getAsInteger(TaskContract.Tasks.COMPLETED_IS_ALLDAY)) - Assert.assertEquals(now.toEpochMilli(), result.getAsLong(TaskContract.Tasks.COMPLETED)) + Assert.assertEquals(now.toTimestamp(), result.getAsLong(TaskContract.Tasks.COMPLETED)) } } 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) } 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..6687307e 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt @@ -75,6 +75,9 @@ object AndroidTimeUtils { /** * Same as [toInstant], but returns a UNIX timestamp (in milliseconds) instead of an [Instant]. + * Aligned to full seconds, since Android <12 has a bug. + * + * See [at.bitfire.synctools.storage.calendar.AndroidCalendarProvider.matchesExceptionsWithMilliseconds]. */ fun Temporal.toTimestamp(): Long = toInstant().epochSecond * 1000 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..63dcb6ee 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,18 +9,26 @@ 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 at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp 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 @@ -50,4 +58,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().toTimestamp() + assertContentValuesEqual(contentValuesOf( + Tasks.COMPLETED to expectedTimestamp, + Tasks.COMPLETED_IS_ALLDAY to 0 + ), result.entityValues) + } + }