Recurring tasks keep their rule with tasks.org and OpenTasks (#135) (#142)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

This commit is contained in:
Jean-Luc Makiola committed 2026-11-09 18:27:58 +01:00
1 parent 68495a936c
commit 109eda82b2
11 files changed
+706 -107

No files matched your search

@@ -7,9 +7,12 @@ import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.database.sqlite.SQLiteException
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
@@ -73,14 +76,20 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun task(taskId: Long): Task? {
val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString()))
return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull()
roundLegacyAnchors()
val rows = instanceRows(resolver.query(TasksContract.instancesUri(authority()), null, "${Instances.TASK_ID} = ?", arrayOf(taskId.toString()), null))
return (rows.firstOrNull { it.task.distanceFromCurrent == 0 } ?: rows.firstOrNull())?.let { enrich(listOf(it)).single() }
}
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)
roundLegacyAnchors()
val rows = instanceRows(queryOccurrences(taskId))
val chosen = rows.firstOrNull { it.anchor == occurrenceStart }
?: rows.firstOrNull { it.anchor?.let { a -> sameSecond(a, occurrenceStart) } == true }
?: rows.firstOrNull { it.task.taskId == taskId && it.task.distanceFromCurrent == 0 }
?: rows.firstOrNull { it.task.taskId == taskId }
return chosen?.let { enrich(listOf(it)).single() }
}
override fun subtasks(parentTaskId: Long): List<Task> =
@@ -107,23 +116,74 @@ class AndroidTasksDataSource @Inject constructor(
}
} ?: emptyList()
/**
* All columns and no sort: the instances view differs across provider
* versions (tasks.org's has no `is_recurring`), the by-name mapper nulls what
* is missing, and the repository re-sorts.
*/
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
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.
val tasks = resolver.query(uri, null, selection, args, null)
?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.task(reader)) }
} ?: emptyList()
if (tasks.isEmpty()) return tasks
roundLegacyAnchors()
return instanceTasks(resolver.query(TasksContract.instancesUri(authority()), null, selection, args, null))
}
/** Maps [cursor]'s instance rows, with the rule each series keeps on its `tasks` row, and their tags. */
private fun instanceTasks(cursor: Cursor?): List<Task> = enrich(instanceRows(cursor))
/** [cursor]'s instance rows, before their rules and tags are looked up. */
private fun instanceRows(cursor: Cursor?): List<InstanceRow> {
var flagged = false
val rows = cursor?.use { c ->
flagged = c.getColumnIndex(Instances.IS_RECURRING) >= 0
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.instance(reader)) }
}.orEmpty()
// Where the provider flags recurrence, only flagged rows need their rule looked up.
return if (flagged) rows.map { if (it.task.isRecurring) it else it.copy(ruleOwner = NO_RULE) } else rows
}
/** [rows] completed with the rule each series keeps on its `tasks` row, and their tags. */
private fun enrich(rows: List<InstanceRow>): List<Task> {
if (rows.isEmpty()) return emptyList()
val rules = seriesRules(rows.map { it.ruleOwner }.filter { it != NO_RULE })
val tasks = rows.map { TaskMapper.withRule(it, rules[it.ruleOwner]) }
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 +254,7 @@ 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) { TaskMapper.instance(it).task } ?: task(seriesId)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
)?.use { c ->
@@ -202,14 +262,14 @@ class AndroidTasksDataSource @Inject constructor(
val r = CursorColumnReader(c)
r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE)
}
fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when {
now == null -> null
anchor == null || was == null -> now.toEpochMilliseconds()
else -> anchor + (now - was).inWholeMilliseconds
}
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))
val base = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
// Without an anchor to shift, the form's own value stands — already pinned for an all-day series.
fun shifted(column: String, anchor: Long?, was: Instant?, now: Instant?): Any? =
if (now == null || anchor == null || was == null) base[column]
else providerMillis(anchor + (now - was).inWholeMilliseconds)
val values = base +
(Tasks.DTSTART to shifted(Tasks.DTSTART, anchors?.first, occurrence?.start, form.start)) +
(Tasks.DUE to shifted(Tasks.DUE, anchors?.second, occurrence?.due, 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 +313,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),
@@ -290,7 +351,7 @@ class AndroidTasksDataSource @Inject constructor(
buildList {
while (c.moveToNext()) {
val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue
if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add)
if (providerMillis(original) >= providerMillis(from.toEpochMilliseconds())) r.getLong(Tasks.ID)?.let(::add)
}
}
} ?: emptyList()
@@ -302,7 +363,10 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
val hit = instanceFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
hit.overrideId?.let { return updateOverride(it, form, reminders) }
if (reminders == null) return writeInstance(taskId, hit.instanceId, form)
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row, so
// setting the alarm beforehand is the only way to carry it across — and
@@ -311,15 +375,26 @@ class AndroidTasksDataSource @Inject constructor(
val seriesReminders = alarmRows(taskId).map { it.second }
writeReminders(taskId, reminders)
try {
writeInstance(taskId, occurrenceStart, form)
writeInstance(taskId, hit.instanceId, form)
} finally {
setReminders(taskId, seriesReminders)
}
}
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
/**
* An occurrence that already has its own row is written there directly. The
* fork-and-restore dance in [updateInstance] would put its old tags and
* reminders back over the new ones.
*/
private fun updateOverride(overrideId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), overrideId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update override $overrideId")
setCategories(overrideId, form.tags)
writeReminders(overrideId, reminders)
}
private fun writeInstance(taskId: Long, instanceId: Long, form: TaskForm) {
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId)
// The fork copies the series' properties onto the override, so the tags
@@ -346,28 +421,136 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/** An occurrence's instance row, and the override row behind it if it has one. */
private class InstanceHit(val instanceId: Long, val overrideId: Long?)
/**
* The provider's instance row id for one occurrence.
* The provider's instance row for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
* rather than carried around above the data layer.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
private fun instanceFor(taskId: Long, occurrenceStart: Instant): InstanceHit? {
roundLegacyAnchors()
return occurrence(taskId, occurrenceStart) { r ->
r.getLong(Tasks.ID)?.let { id ->
InstanceHit(id, r.getLong(Instances.TASK_ID)?.takeIf { r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null })
}
}
}
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? = instanceFor(taskId, occurrenceStart)?.instanceId
/**
* [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. The anchor is matched in
* Kotlin because `instance_original_time` is missing on older schemas.
*/
private fun <T> occurrence(taskId: Long, occurrenceStart: Instant, read: (ColumnReader) -> T): T? =
queryOccurrences(taskId)?.use { c ->
val reader = CursorColumnReader(c)
// An exact match first: a row of the same second is a fallback, not a peer.
var near: Int? = null
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
val anchor = TaskMapper.occurrenceAnchor(reader) ?: continue
if (anchor == occurrenceStart) return@use read(reader)
if (near == null && sameSecond(anchor, occurrenceStart)) near = c.position
}
near?.let { c.moveToPosition(it); read(reader) }
}
/**
* Instance rows of [taskId] and of the overrides standing in for its
* occurrences. A provider whose view lacks `original_instance_id` gets the
* task's own rows only, rather than a failed query.
*/
private fun queryOccurrences(taskId: Long): Cursor? {
val authority = authority()
val uri = TasksContract.instancesUri(authority)
val id = taskId.toString()
if (ownRowsOnlyFor != authority) {
try {
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(id, id), null)
} catch (_: SQLiteException) {
ownRowsOnlyFor = authority
} catch (_: IllegalArgumentException) {
ownRowsOnlyFor = authority
}
}
return resolver.query(uri, null, "${Instances.TASK_ID} = ?", arrayOf(id), null)
}
/** The provider whose instances view turned out to lack `original_instance_id`. */
@Volatile
private var ownRowsOnlyFor: String? = null
@Volatile
private var anchorsRoundedFor: String? = null
private val roundingLock = Any()
/**
* Rounds the DTSTART/DUE a build before [providerMillis] stored with
* milliseconds, which the provider cannot move past a completed occurrence.
* Once per provider, before the first read: concurrent readers wait for it.
* Written as the rows' sync adapter, since the server only ever had whole
* seconds, so nothing is dirtied or re-uploaded. Overrides need nothing: the
* provider forks them at its own whole-second instance times.
*
* Never fails a read. A row the provider refuses is left as it was and tried
* again on the next start; only a pass that rounded everything is remembered.
*/
private fun roundLegacyAnchors() {
val authority = authority()
if (anchorsRoundedFor == authority) return
synchronized(roundingLock) {
if (anchorsRoundedFor == authority) return
val prefs = context.getSharedPreferences(MIGRATIONS, Context.MODE_PRIVATE)
val key = "anchors_rounded:$authority"
val done = prefs.getBoolean(key, false) || runCatching { roundAll(authority) }
.onFailure { Log.w(TAG, "Rounding legacy anchors failed", it) }
.getOrDefault(false)
if (done) prefs.edit().putBoolean(key, true).apply()
anchorsRoundedFor = authority
}
}
/** True when every stale row was rounded. */
private fun roundAll(authority: String): Boolean {
val columns = listOf(Tasks.DTSTART, Tasks.DUE)
val ops = resolver.query(
TasksContract.tasksUri(authority),
arrayOf(Tasks.ID, Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE) + columns,
columns.joinToString(" OR ") { "$it % 1000 != 0" },
null,
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList<ContentProviderOperation> {
while (c.moveToNext()) {
val id = r.getLong(Tasks.ID) ?: continue
val values = buildMap<String, Any?> {
for (column in columns) {
val millis = r.getLong(column) ?: continue
if (providerMillis(millis) != millis) put(column, providerMillis(millis))
}
}
val name = r.getString(Tasks.ACCOUNT_NAME)
val type = r.getString(Tasks.ACCOUNT_TYPE)
val uri = if (name.isNullOrEmpty() || type.isNullOrEmpty()) taskUri(authority, id)
else TasksContract.asSyncAdapter(taskUri(authority, id), name, type)
add(ContentProviderOperation.newUpdate(uri).withValues(values.toContentValues()).build())
}
}
null
}
} ?: return false
if (ops.isEmpty()) return true
val batch = runCatching { resolver.applyBatch(authority, ArrayList(ops)) }.getOrNull()
if (batch != null) return batch.all { (it.count ?: 0) > 0 }
// One refused row fails the whole batch; row by row, the rest still go.
return ops.map { op -> runCatching { resolver.applyBatch(authority, arrayListOf(op)).single().count ?: 0 }.getOrDefault(0) > 0 }
.all { it }
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
@@ -430,29 +613,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
@@ -489,7 +662,7 @@ class AndroidTasksDataSource @Inject constructor(
/** Through the instances URI, like [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCancelled(taskId, cancelled)
?: return plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
@@ -508,22 +681,42 @@ class AndroidTasksDataSource @Inject constructor(
/**
* Through the instances URI, which is what makes the provider fork an override
* rather than close the series. No instance row for the anchor means the task
* is not a series after all — the plain write is then the right one.
* rather than close the series. No instance row for the anchor on a task that
* is not a series means the plain write is the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return setCompleted(taskId, completed)
?: return plainUnlessSeries(taskId, occurrenceStart) { setCompleted(taskId, completed) }
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
/**
* [write] for a task that turned out not to be a series. A series whose
* occurrence was not found fails instead: the plain write would close all of it.
*/
private fun plainUnlessSeries(taskId: Long, occurrenceStart: Instant, write: () -> Unit) {
if (seriesRules(listOf(taskId)).isNotEmpty()) {
throw TaskWriteFailedException("instance $taskId@$occurrenceStart: no such occurrence")
}
write()
}
override fun deleteTask(taskId: Long) {
resolver.delete(taskUri(authority(), taskId), null, null)
}
/**
* Through its instance, which the provider turns into an EXDATE on the master,
* as the Room store does. Deleting the row alone leaves the master without one.
*/
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) {
val instanceId = instanceIdFor(taskId, occurrenceStart) ?: return deleteTask(taskId)
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
override fun createLocalList(name: String, color: Int): Long {
val uri = TasksContract.asSyncAdapter(
TasksContract.listsUri(authority()),
@@ -594,5 +787,18 @@ 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} = ?"
private fun sameSecond(a: Instant, b: Instant): Boolean =
providerMillis(a.toEpochMilliseconds()) == providerMillis(b.toEpochMilliseconds())
private const val MIGRATIONS = "provider_migrations"
private const val TAG = "AndroidTasksDataSource"
/** No rule to look up: the provider says the row does not recur. */
private const val NO_RULE = -1L
/** SQLite's default bound-argument limit on older Android releases. */
private const val MAX_SQL_ARGS = 999
@@ -67,6 +67,7 @@ class ModeRoutingTasksDataSource(
active().setCancelledInstance(taskId, occurrenceStart, cancelled)
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
override fun deleteOverride(taskId: Long, occurrenceStart: Instant) = active().deleteOverride(taskId, occurrenceStart)
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color)
override fun deleteList(listId: Long) = active().deleteList(listId)
@@ -13,20 +13,23 @@ 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, without its series' rule: 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`. [withRule] completes it with the rule read from
* the master's `tasks` row. An override names its master in
* `original_instance_id`.
*/
fun instance(r: ColumnReader): InstanceRow {
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)
return Task(
val masterId = r.getLong(Tasks.ORIGINAL_INSTANCE_ID)
val recurring = masterId != null || r.getBoolean(Instances.IS_RECURRING)
val anchor = occurrenceAnchor(r)
val task = Task(
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(),
@@ -47,13 +50,31 @@ object TaskMapper {
accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID),
isRecurring = recurring,
occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
occurrenceStart = if (recurring) anchor else null,
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,
)
return InstanceRow(task, ruleOwner = masterId ?: task.taskId, anchor = anchor)
}
/** [row]'s task as an occurrence of the series [rule] belongs to, or as it is without one. */
fun withRule(row: InstanceRow, rule: SeriesRule?): Task {
if (rule == null) return row.task
return row.task.copy(
isRecurring = true,
occurrenceStart = row.task.occurrenceStart ?: row.anchor,
seriesId = row.task.seriesId ?: row.ruleOwner,
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)
}
/**
@@ -119,3 +140,12 @@ 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?)
/**
* An instance row's task before its series' rule is applied. [ruleOwner] is the
* task whose `tasks` row carries that rule: the master, for an override.
*/
data class InstanceRow(val task: Task, val ruleOwner: Long, val anchor: Instant?)
@@ -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
@@ -124,13 +124,17 @@ interface TasksDataSource {
*
* Implementations fall back to [setCompleted] when the row turns out not to
* be a series master — an override, or a plain task the caller happened to
* hand an anchor for — so the routing above cannot get this wrong.
* hand an anchor for. A series whose occurrence cannot be found fails with
* [TaskWriteFailedException] instead: the plain write would close all of it.
*/
fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean)
/** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */
fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean)
fun deleteTask(taskId: Long)
/** Delete the override [taskId] standing for one occurrence, excluding that occurrence from its series. */
fun deleteOverride(taskId: Long, occurrenceStart: Instant) = deleteTask(taskId)
fun createLocalList(name: String, color: Int): Long
/** Rename and recolour [listId]. */
@@ -241,7 +241,7 @@ class TasksRepositoryImpl @Inject constructor(
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
// occurrence has no row to delete.
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
if (current.taskId != seriesId) dataSource.deleteOverride(current.taskId, occurrence)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
@@ -189,7 +189,8 @@ class ProviderWriter internal constructor(
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
// An override deletes itself into an EXDATE on its master.
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
val occurrence = current.recurrenceId
if (current.masterId != null && occurrence != null) store.deleteOverride(taskId, occurrence) else store.deleteWithSubtasks(taskId)
return true
}