Compare commits

...
Author SHA1 Message Date
makiolaj 650462143f Map provider overrides to their series, share the id-chunked query (#135) 2026-11-06 14:17:50 +01:00
makiolaj 9a5102bdf9 Round series stored with milliseconds before writing an occurrence (#135)
Also only fetch rows that recur, and skip the provider test without permission.
2026-11-06 13:47:21 +01:00
makiolaj dd9f652f02 Keep provider dates to whole seconds, test recurrence against a real provider (#135)
A DTSTART with milliseconds never matches the provider's first occurrence, so
completing it left the series in place.
2026-11-06 13:02:42 +01:00
makiolaj 9d8a6aecf4 Read the series rule from the tasks table for provider tasks (#135)
The instances view masks rrule/rdate with NULL, so on tasks.org's provider
(no is_recurring column) every series read as a one-off, and on every
provider the editor opened without its rule.
2026-11-06 12:56:48 +01:00
7 changed files with 434 additions and 65 deletions

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Fixed
- With tasks.org as the storage provider, recurring tasks are recognised as
recurring. Before, editing one removed its recurrence and ticking it off
closed the whole series. With tasks.org and OpenTasks alike, the editor now
shows the task's repeat rule instead of none, and an occurrence changed on
its own stays part of its series (#135).
## [1.3.0] - 2026-11-02
### Added
@@ -0,0 +1,198 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentValues
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.After
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* Recurrence against a real external provider (#135). Skipped unless one is
* installed and its permissions are granted, so it only runs where set up:
* `pm grant <app> org.tasks.permission.READ_TASKS` (and WRITE_TASKS).
*/
@RunWith(AndroidJUnit4::class)
class ExternalProviderRecurrenceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val resolver = ProviderResolver(AndroidProviderEnvironment(context)).apply {
storageMode = StorageMode.EXTERNAL
}
private val source = AndroidTasksDataSource(context, resolver)
private var listId = 0L
@Before
fun setUp() {
val provider = resolver.resolve()
assumeTrue("no external tasks provider, or not granted", provider != null && resolver.hasPermission(provider))
listId = source.createLocalList("Recurrence test", 0)
}
@After
fun tearDown() {
if (listId != 0L) source.deleteList(listId)
}
private fun dailyTask(): Long {
// Milliseconds and all, as the "start today" default gives.
val start = Clock.System.now() + 1.hours
return source.insertTask(
TaskForm(title = "Daily", listId = listId, start = start, due = start + 1.hours, rrule = "FREQ=DAILY"),
reminders = null,
)
}
private fun storedRule(taskId: Long): String? = context.contentResolver.query(
TasksContract.tasksUri(resolver.resolve()!!.authority),
arrayOf(Tasks.RRULE),
"${Tasks.ID} = ?",
arrayOf(taskId.toString()),
null,
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
@Test
fun aSeriesReadsAsRecurringWithItsRule() {
val id = dailyTask()
val listed = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }.minBy { it.occurrenceStart!! }
assertThat(listed.isRecurring).isTrue()
assertThat(listed.isOccurrence).isTrue()
assertThat(listed.recurrenceRule).isEqualTo("FREQ=DAILY")
val opened = source.task(id)!!
assertThat(opened.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun savingTheSeriesKeepsItsRule() {
val id = dailyTask()
val task = source.task(id)!!
source.updateSeries(
id,
task.occurrenceStart!!,
TaskForm(title = "Daily, renamed", listId = listId, start = task.start, due = task.due, rrule = task.recurrenceRule),
reminders = null,
)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
@Test
fun completingAnOccurrenceMovesTheSeriesOn() {
val id = dailyTask()
val task = source.task(id)!!
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
assertSeriesMovedOn(id, task.occurrenceStart!!)
}
@Test
fun aSeriesStoredWithMillisecondsStillMovesOn() {
// As builds before whole-second writes left it.
val start = Clock.System.now() + 1.hours
val id = context.contentResolver.insert(
TasksContract.tasksUri(resolver.resolve()!!.authority),
ContentValues().apply {
put(Tasks.LIST_ID, listId)
put(Tasks.TITLE, "Old daily")
put(Tasks.DTSTART, start.toEpochMilliseconds() / 1000 * 1000 + 345)
put(Tasks.DUE, (start + 1.hours).toEpochMilliseconds() / 1000 * 1000 + 345)
put(Tasks.TZ, "UTC")
put(Tasks.RRULE, "FREQ=DAILY")
},
)!!.lastPathSegment!!.toLong()
val task = source.task(id)!!
source.setCompletedInstance(id, task.occurrenceStart!!, completed = true)
assertSeriesMovedOn(id, task.occurrenceStart)
}
/** Edits the series' first occurrence on its own and returns the override that makes. */
private fun overrideOf(id: Long): Task {
val task = source.task(id)!!
source.updateInstance(
id,
task.occurrenceStart!!,
TaskForm(title = "Just this once", listId = listId, start = task.start, due = task.due),
reminders = null,
)
return source.tasks(TaskQuery(listId = listId)).single { it.seriesId == id && it.taskId != id }
}
@Test
fun anOverrideBelongsToItsSeries() {
val id = dailyTask()
val anchor = source.task(id)!!.occurrenceStart
val override = overrideOf(id)
assertThat(override.title).isEqualTo("Just this once")
assertThat(override.isOccurrence).isTrue()
assertThat(override.occurrenceStart).isEqualTo(anchor)
assertThat(override.recurrenceRule).isEqualTo("FREQ=DAILY")
assertThat(source.task(override.taskId, override.occurrenceStart)?.taskId).isEqualTo(override.taskId)
}
@Test
fun savingAllFromAnOverrideKeepsTheSeriesWhereItIs() {
val id = dailyTask()
val before = storedStart(id)
val override = overrideOf(id)
source.updateSeries(
id,
override.occurrenceStart!!,
TaskForm(title = "Renamed", listId = listId, start = override.start, due = override.due, rrule = override.recurrenceRule),
reminders = null,
)
assertThat(storedStart(id)).isEqualTo(before)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
@Test
fun deletingAnOverrideExcludesItsOccurrence() {
val id = dailyTask()
val override = overrideOf(id)
source.deleteTask(override.taskId)
// An EXDATE on the master, so a sync removes the occurrence and does not
// just drop the edit made to it.
assertThat(storedColumn(id, Tasks.EXDATE)).isNotNull()
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
assertThat(open.map { it.occurrenceStart }).doesNotContain(override.occurrenceStart)
assertThat(storedRule(id)).isEqualTo("FREQ=DAILY")
}
private fun storedStart(taskId: Long): Long? = storedColumn(taskId, Tasks.DTSTART)?.toLong()
private fun storedColumn(taskId: Long, column: String): String? = context.contentResolver.query(
TasksContract.tasksUri(resolver.resolve()!!.authority),
arrayOf(column),
"${Tasks.ID} = ?",
arrayOf(taskId.toString()),
null,
)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
private fun assertSeriesMovedOn(id: Long, completed: Instant) {
assertThat(storedRule(id)).isNotNull()
val open = source.tasks(TaskQuery(listId = listId)).filter { it.seriesId == id }
assertThat(open).isNotEmpty()
assertThat(open.minOf { it.occurrenceStart!! }).isGreaterThan(completed)
}
}
@@ -79,8 +79,10 @@ class AndroidTasksDataSource @Inject constructor(
override fun task(taskId: Long, occurrenceStart: Instant?): Task? {
if (occurrenceStart == null) return task(taskId)
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId)
val rows = queryInstances(OCCURRENCE_OF, arrayOf(taskId.toString(), taskId.toString()))
return rows.firstOrNull { it.occurrenceStart == occurrenceStart }
?: rows.firstOrNull { it.taskId == taskId && it.distanceFromCurrent == 0 }
?: rows.firstOrNull { it.taskId == taskId }
}
override fun subtasks(parentTaskId: Long): List<Task> =
@@ -111,19 +113,62 @@ class AndroidTasksDataSource @Inject constructor(
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
// provider versions (e.g. tasks.org's bundled OpenTasks has no
// `is_recurring` / `distance_from_current`). The by-name mapper reads
// whatever is present and nulls the rest; the repository re-sorts, so we
// don't depend on a provider sort column either.
// `is_recurring`). The by-name mapper reads whatever is present and
// nulls the rest; the repository re-sorts, so we don't depend on a
// provider sort column either.
val tasks = resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
// The view hides the rule (`null AS rrule`), so it is read from
// the tasks table — the master's, for an override — and handed
// to the mapper.
fun ruleOwner() = reader.getLong(Tasks.ORIGINAL_INSTANCE_ID) ?: reader.getLong(Instances.TASK_ID)
val owners = buildSet { while (c.moveToNext()) ruleOwner()?.let(::add) }
val rules = seriesRules(owners)
c.moveToPosition(-1)
buildList {
while (c.moveToNext()) add(TaskMapper.task(reader, ruleOwner()?.let(rules::get)))
}
} ?: emptyList()
if (tasks.isEmpty()) return tasks
val tags = categories(tasks.map { it.taskId })
return tasks.map { task -> tags[task.taskId]?.let { task.copy(tags = it) } ?: task }
}
/** The recurrence of each of [taskIds] that is a series, keyed by task id. */
private fun seriesRules(taskIds: Collection<Long>): Map<Long, SeriesRule> = queryByIds(
TasksContract.tasksUri(authority()),
arrayOf(Tasks.ID, Tasks.RRULE, Tasks.RDATE),
Tasks.ID,
taskIds,
filter = "${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL",
) { r -> r.getLong(Tasks.ID)?.let { id -> TaskMapper.seriesRule(r)?.let { id to it } } }.toMap()
/**
* Rows of [uri] whose [idColumn] is one of [ids], in chunks that stay under
* SQLite's bound-argument limit, each mapped by [read] (null rows dropped).
*/
private fun <T : Any> queryByIds(
uri: Uri,
projection: Array<String>,
idColumn: String,
ids: Collection<Long>,
filter: String? = null,
filterArgs: List<String> = emptyList(),
sortOrder: String? = null,
read: (ColumnReader) -> T?,
): List<T> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
val selection = buildString {
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
if (filter != null) append(" AND ($filter)")
}
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
@@ -194,7 +239,10 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
val occurrence = task(seriesId, occurrenceStart)
val occurrence = occurrence(seriesId, occurrenceStart) { r ->
r.getLong(Instances.INSTANCE_START)?.let(Instant::fromEpochMilliseconds) to
r.getLong(Instances.INSTANCE_DUE)?.let(Instant::fromEpochMilliseconds)
} ?: task(seriesId)?.let { it.start to it.due }
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
)?.use { c ->
@@ -206,10 +254,10 @@ class AndroidTasksDataSource @Inject constructor(
now == null -> null
anchor == null || was == null -> now.toEpochMilliseconds()
else -> anchor + (now - was).inWholeMilliseconds
}
}?.let(::providerMillis)
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) +
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) +
(Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due))
(Tasks.DTSTART to shifted(anchors?.first, occurrence?.first, form.start)) +
(Tasks.DUE to shifted(anchors?.second, occurrence?.second, form.due))
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
@@ -253,9 +301,10 @@ class AndroidTasksDataSource @Inject constructor(
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
val rule = TaskMapper.seriesRule(r)
RecurrenceSpec(
rrule = r.getString(Tasks.RRULE)?.ifBlank { null },
rdate = r.getString(Tasks.RDATE),
rrule = rule?.rrule,
rdate = rule?.rdate,
exdate = r.getString(Tasks.EXDATE),
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
@@ -275,7 +324,9 @@ class AndroidTasksDataSource @Inject constructor(
val values = mapOf<String, Any?>(Tasks.RRULE to rule)
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("end series $seriesId")
exceptionsFrom(seriesId, occurrenceStart).forEach(::deleteTask)
// Plain row deletes: through the instances URI each would also add an
// EXDATE past the rule's new end.
exceptionsFrom(seriesId, occurrenceStart).forEach { resolver.delete(taskUri(authority(), it), null, null) }
}
/** Override rows of [seriesId] standing for [from] or a later occurrence. */
@@ -357,19 +408,54 @@ class AndroidTasksDataSource @Inject constructor(
* WHERE clause naming it would throw instead of falling back.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
roundAnchors(taskId)
return occurrence(taskId, occurrenceStart) { it.getLong(Tasks.ID) }
}
/**
* [read] applied to the instance row of [taskId]'s occurrence at
* [occurrenceStart] — its own, or an override's that replaced it — matched in
* whole seconds, as the provider expands them.
*/
private fun <T> occurrence(taskId: Long, occurrenceStart: Instant, read: (ColumnReader) -> T): T? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
val wanted = providerMillis(occurrenceStart.toEpochMilliseconds())
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(taskId.toString(), taskId.toString()), null)?.use { c ->
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
}
val anchor = TaskMapper.occurrenceAnchor(reader) ?: continue
if (providerMillis(anchor.toEpochMilliseconds()) == wanted) return@use read(reader)
}
null
}
}
/**
* Rounds a task's DTSTART/DUE down to whole seconds if a build before
* [providerMillis] stored them with milliseconds — otherwise the provider
* cannot move the series past an occurrence written through its instance.
*/
private fun roundAnchors(taskId: Long) {
val uri = taskUri(authority(), taskId)
var master: Long? = null
val values = resolver.query(uri, arrayOf(Tasks.DTSTART, Tasks.DUE, Tasks.ORIGINAL_INSTANCE_ID), null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
val r = CursorColumnReader(c)
master = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
buildMap<String, Any?> {
for (column in listOf(Tasks.DTSTART, Tasks.DUE)) {
val millis = r.getLong(column) ?: continue
if (providerMillis(millis) != millis) put(column, providerMillis(millis))
}
}
}
if (!values.isNullOrEmpty() && resolver.update(uri, values.toContentValues(), null, null) == 0) {
throw TaskWriteFailedException("round task $taskId")
}
// Completing an override moves its master on, so the master's anchor matters too.
master?.takeIf { it != taskId }?.let(::roundAnchors)
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
val uri = TasksContract.propertiesUri(authority())
resolver.delete(
@@ -430,29 +516,19 @@ class AndroidTasksDataSource @Inject constructor(
}
/** Tags by task id for [taskIds], in stored order. */
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> {
val rows = taskIds.distinct().chunked(MAX_SQL_ARGS - 1).flatMap { chunk ->
val selection = "${Properties.MIMETYPE} = ? AND ${Properties.TASK_ID} IN (${chunk.joinToString(",") { "?" }})"
val args = (listOf(TasksContract.Category.MIMETYPE) + chunk.map(Long::toString)).toTypedArray()
resolver.query(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
selection,
args,
Properties.PROPERTY_ID,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val id = reader.getLong(Properties.TASK_ID) ?: continue
val name = reader.getString(TasksContract.Category.NAME) ?: continue
add(id to name)
}
}
} ?: emptyList()
}
return rows.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
}
private fun categories(taskIds: Collection<Long>): Map<Long, List<String>> = queryByIds(
TasksContract.propertiesUri(authority()),
arrayOf(Properties.TASK_ID, TasksContract.Category.NAME),
Properties.TASK_ID,
taskIds,
filter = "${Properties.MIMETYPE} = ?",
filterArgs = listOf(TasksContract.Category.MIMETYPE),
sortOrder = Properties.PROPERTY_ID,
) { r ->
val id = r.getLong(Properties.TASK_ID)
val name = r.getString(TasksContract.Category.NAME)
if (id == null || name == null) null else id to name
}.groupBy({ it.first }, { it.second }).mapValues { normalizeTags(it.value) }
/**
* Replaces [taskId]'s category rows in one batch, so a refused insert cannot
@@ -520,8 +596,20 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
/**
* An override goes through its instance, which the provider turns into an
* EXDATE on the master — the same as the Room store. Deleting its row alone
* leaves the master without one, so a sync would only drop the edit.
*/
override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null)
val authority = authority()
val isOverride = resolver.query(taskUri(authority, taskId), arrayOf(Tasks.ORIGINAL_INSTANCE_ID), null, null, null)
?.use { c -> c.moveToFirst() && !c.isNull(0) } == true
val instanceId = if (!isOverride) null else resolver.query(
TasksContract.instancesUri(authority), null, "${Instances.TASK_ID} = ?", arrayOf(taskId.toString()), null,
)?.use { c -> if (c.moveToFirst()) CursorColumnReader(c).getLong(Tasks.ID) else null }
if (instanceId != null) resolver.delete(TasksContract.instanceUri(authority, instanceId), null, null)
else resolver.delete(taskUri(authority, taskId), null, null)
}
override fun createLocalList(name: String, color: Int): Long {
@@ -594,5 +682,8 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/** An instance row of the task, or of an override standing in for one of its occurrences. */
private const val OCCURRENCE_OF = "${Instances.TASK_ID} = ? OR ${Tasks.ORIGINAL_INSTANCE_ID} = ?"
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -13,19 +13,20 @@ import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
object TaskMapper {
fun task(r: ColumnReader): Task {
/**
* Maps an `instances` row. [rule] is the series' rule set as stored on the
* master's `tasks` row: the dmfs instances view (DB 22 and 23) masks
* `rrule`/`rdate` with `NULL`, so the row alone cannot tell a series from a
* one-off on tasks.org's bundled provider (DB 22), which also lacks
* `is_recurring`. An override names its master in `original_instance_id`.
*/
fun task(r: ColumnReader, rule: SeriesRule? = null): Task {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
val rowId = r.getLong(Tasks.ID) ?: 0L
// Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
val recurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING)
val masterId = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
val recurring = rule != null || masterId != null || r.getBoolean(Instances.IS_RECURRING)
return Task(
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
@@ -51,11 +52,18 @@ object TaskMapper {
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null,
recurrenceRule = r.getString(Tasks.RRULE),
seriesId = if (recurring) masterId ?: r.getLong(Instances.TASK_ID) ?: rowId else null,
recurrenceRule = rule?.rrule,
)
}
/** The rule columns of a `tasks` row, or null when it does not recur. */
fun seriesRule(r: ColumnReader): SeriesRule? {
val rrule = r.getString(Tasks.RRULE)?.ifBlank { null }
val rdate = r.getString(Tasks.RDATE)?.ifBlank { null }
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
}
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
@@ -119,3 +127,6 @@ object TaskMapper {
owner = r.getString(Lists.OWNER),
)
}
/** A series' recurrence as stored on its master row. */
data class SeriesRule(val rrule: String?, val rdate: String?)
@@ -17,6 +17,14 @@ private fun Instant.forAllDay(allDay: Boolean): Instant =
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Whole seconds, as iCalendar stores times. The provider expands a series from
* second-precision instants, so a DTSTART carrying milliseconds never matches its
* own first occurrence: completing it detaches a copy but leaves the series where
* it was, and the same occurrence comes straight back.
*/
fun providerMillis(epochMillis: Long): Long = Math.floorDiv(epochMillis, 1000L) * 1000L
/**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
@@ -57,8 +65,8 @@ object TaskWriteMapper {
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
// remaining date against UTC — so a local-midnight instant lands on the
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()?.let(::providerMillis))
// DUE and DURATION are mutually exclusive. The provider's Validating
// processor evaluates the *merged* row (supplied values over the stored
// ones), so writing DUE onto a task that already carries a DURATION throws
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskMapperTest {
@@ -52,7 +53,7 @@ class TaskMapperTest {
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
TaskMapper.task(MapColumnReader(columns), SeriesRule(rrule = "FREQ=DAILY", rdate = null))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
@@ -84,24 +85,74 @@ class TaskMapperTest {
}
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
fun `a series is detected from its master rule when the view masks it (#135)`() {
// tasks.org's bundled provider (DB 22): `null AS rrule` in the instances
// view and no `is_recurring`, so only the tasks row knows it recurs.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
MapColumnReader(
mapOf(
Tasks.ID to 90L,
Instances.TASK_ID to 9L,
Instances.INSTANCE_START to 1_000L,
Instances.INSTANCE_ORIGINAL_TIME to 1_000L,
),
),
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
)
assertThat(task.isRecurring).isTrue()
assertThat(task.seriesId).isEqualTo(9L)
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(1_000L))
assertThat(task.isOccurrence).isTrue()
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun `the master rule fills the editor even where is_recurring exists`() {
// OpenTasks DB 23 flags the row but masks the rule all the same; without
// it the editor opens empty and saving the series drops the RRULE.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 90L, Instances.TASK_ID to 9L, Instances.IS_RECURRING to 1)),
SeriesRule(rrule = "FREQ=WEEKLY;BYDAY=MO", rdate = null),
)
assertThat(task.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@Test
fun `a tasks row without a rule is no series`() {
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "")))).isNull()
assertThat(TaskMapper.seriesRule(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z"))))
.isEqualTo(SeriesRule(rrule = null, rdate = "20260720T090000Z"))
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
MapColumnReader(mapOf(Tasks.ID to 1L)),
SeriesRule(rrule = null, rdate = "20260720T090000Z"),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `an override belongs to its master's series`() {
// An edited occurrence is its own task row, without a rule of its own.
val task = TaskMapper.task(
MapColumnReader(
mapOf(
Tasks.ID to 95L,
Instances.TASK_ID to 12L,
Tasks.ORIGINAL_INSTANCE_ID to 9L,
Instances.INSTANCE_ORIGINAL_TIME to 2_000L,
),
),
SeriesRule(rrule = "FREQ=DAILY", rdate = null),
)
assertThat(task.taskId).isEqualTo(12L)
assertThat(task.seriesId).isEqualTo(9L)
assertThat(task.occurrenceStart).isEqualTo(Instant.fromEpochMilliseconds(2_000L))
assertThat(task.recurrenceRule).isEqualTo("FREQ=DAILY")
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
@@ -102,14 +102,17 @@ class TaskWriteMapperTest {
}
@Test
fun `timed timestamps are written untouched`() {
fun `timed timestamps are kept to the second`() {
// A default start of "now" carries milliseconds; the provider expands a
// series in whole seconds, and a DTSTART between them stops completing an
// occurrence from moving the series on.
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_000L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_000L)
}
@Test