fix(store): three defects the instrumented suite found on first run

- forking an occurrence copied the master's alarm row id and hit the
  primary key; replaceForTask now clears it
- a sub-second DTSTART made lib-recur emit the anchor and its truncated
  self, doubling a series' first occurrence; floor to the second, which
  is all RFC 5545 DATE-TIME carries
- room-testing needs kotlinx-serialization 1.8+, but consistent
  resolution pinned androidTest to the app's 1.7.3

Tests: 52 pass on device.
This commit is contained in:
2026-08-13 17:23:31 +02:00
parent 1d4fe5b301
commit 90140112bb
6 changed files with 68 additions and 8 deletions
@@ -22,10 +22,14 @@ interface TaskAlarmDao {
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
/** Set the task's only reminder, or clear it with `null`. */
/**
* Set the task's only reminder, or clear it with `null`. The row that lands is
* always a new one — the id is cleared so an alarm lifted off another task
* (forking an occurrence copies the master's) inserts instead of colliding.
*/
@Transaction
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
deleteForTask(taskId)
alarm?.let { insert(it.copy(taskId = taskId)) }
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
}
}
@@ -9,6 +9,7 @@ import java.time.ZoneId
import java.util.TimeZone
import kotlin.time.Instant
private const val MILLIS_PER_SECOND = 1000L
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
@@ -117,10 +118,17 @@ object RecurrenceExpander {
return TimeZone.getTimeZone(stored ?: floatingZone)
}
/** All-day series are date-anchored: pin the anchor to UTC midnight, as it is stored. */
/**
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
* stored. A timed one is floored to the second, because RFC 5545 DATE-TIME
* has no sub-second field — carrying millis in makes lib-recur emit the raw
* anchor *and* its truncated self, doubling the first occurrence, and mints
* `RECURRENCE-ID`s no other client could address.
*/
private fun anchorMillis(spec: RecurrenceSpec): Long {
val millis = spec.anchor.toEpochMilliseconds()
return if (!spec.isAllDay) millis else Math.floorDiv(millis, MILLIS_PER_DAY) * MILLIS_PER_DAY
val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND
return Math.floorDiv(millis, unit) * unit
}
private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching {