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

Commit c7b9fba

Browse files
authored
CompletedBuilder: Fix LocalDateTime cannot be cast to Instant (#424)
* Add test for COMPLETED without timezone not throwing ClassCastException * Use normalized date and timestamp for COMPLETED field * Fix test determinism * Don't lose milliseconds in toTimestamp util * Fix timestamp alignment for Android <12 bug in toTimestamp()
1 parent 614026c commit c7b9fba

4 files changed

Lines changed: 33 additions & 2 deletions

File tree

lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilderTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ class DmfsTaskBuilderTest (
515515
}.let { result ->
516516
// Note: iCalendar does not allow COMPLETED to be all-day [RFC 5545 3.8.2.1]
517517
assertEquals(0, result.getAsInteger(TaskContract.Tasks.COMPLETED_IS_ALLDAY))
518-
Assert.assertEquals(now.toEpochMilli(), result.getAsLong(TaskContract.Tasks.COMPLETED))
518+
Assert.assertEquals(now.toTimestamp(), result.getAsLong(TaskContract.Tasks.COMPLETED))
519519
}
520520
}
521521

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ package at.bitfire.synctools.mapping.tasks.builder
88

99
import android.content.Entity
1010
import at.bitfire.ical4android.Task
11+
import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate
12+
import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp
1113
import org.dmfs.tasks.contract.TaskContract.Tasks
1214

1315
class CompletedBuilder : DmfsTaskFieldBuilder {
1416

1517
override fun build(from: Task, to: Entity) {
1618
// COMPLETED must always be a DATE-TIME
17-
to.entityValues.put(Tasks.COMPLETED, from.completedAt?.date?.toEpochMilli())
19+
to.entityValues.put(Tasks.COMPLETED, from.completedAt?.normalizedDate()?.toTimestamp())
1820
to.entityValues.put(Tasks.COMPLETED_IS_ALLDAY, 0)
1921
}
2022

lib/src/main/kotlin/at/bitfire/synctools/util/AndroidTimeUtils.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ object AndroidTimeUtils {
7575

7676
/**
7777
* Same as [toInstant], but returns a UNIX timestamp (in milliseconds) instead of an [Instant].
78+
* Aligned to full seconds, since Android <12 has a bug.
79+
*
80+
* See [at.bitfire.synctools.storage.calendar.AndroidCalendarProvider.matchesExceptionsWithMilliseconds].
7881
*/
7982
fun Temporal.toTimestamp(): Long =
8083
toInstant().epochSecond * 1000

lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CompletedBuilderTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,26 @@ package at.bitfire.synctools.mapping.tasks.builder
99
import android.content.ContentValues
1010
import android.content.Entity
1111
import androidx.core.content.contentValuesOf
12+
import at.bitfire.DefaultTimezoneRule
1213
import at.bitfire.ical4android.Task
1314
import at.bitfire.synctools.test.assertContentValuesEqual
15+
import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp
1416
import net.fortuna.ical4j.model.property.Completed
1517
import org.dmfs.tasks.contract.TaskContract.Tasks
18+
import org.junit.Rule
1619
import org.junit.Test
1720
import org.junit.runner.RunWith
1821
import org.robolectric.RobolectricTestRunner
1922
import java.time.Instant
23+
import java.time.LocalDateTime
24+
import java.time.ZoneId
2025

2126
@RunWith(RobolectricTestRunner::class)
2227
class CompletedBuilderTest {
2328

29+
@get:Rule
30+
val tzRule = DefaultTimezoneRule("Europe/Berlin")
31+
2432
private val builder = CompletedBuilder()
2533

2634
@Test
@@ -50,4 +58,22 @@ class CompletedBuilderTest {
5058
), result.entityValues)
5159
}
5260

61+
@Test
62+
fun `COMPLETED is floating LocalDateTime`() {
63+
// COMPLETED without timezone (floating) must not crash with ClassCastException
64+
// A floating COMPLETED is represented as a string without 'Z' (e.g. "20240601T120000")
65+
val result = Entity(ContentValues())
66+
builder.build(
67+
from = Task(completedAt = Completed("20240601T120000")),
68+
to = result
69+
)
70+
// floating date-time is interpreted in system default timezone
71+
val expectedTimestamp = LocalDateTime.of(2024, 6, 1, 12, 0, 0)
72+
.atZone(ZoneId.systemDefault()).toInstant().toTimestamp()
73+
assertContentValuesEqual(contentValuesOf(
74+
Tasks.COMPLETED to expectedTimestamp,
75+
Tasks.COMPLETED_IS_ALLDAY to 0
76+
), result.entityValues)
77+
}
78+
5379
}

0 commit comments

Comments
 (0)