Review fixes for advancing series and provider occurrences (#135, #137) (#147)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

This commit is contained in:
Jean-Luc Makiola committed 2026-11-09 19:03:41 +01:00
1 parent 4b1294341a
commit 7afa2f41f0
13 files changed
+526 -65

No files matched your search

@@ -120,9 +120,11 @@ class AndroidTasksDataSource @Inject constructor(
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() }
if (chosen != null) return enrich(listOf(chosen)).single()
tickedCopy(taskId, occurrenceStart)?.let { copy -> return task(copy)?.copy(occurrenceStart = occurrenceStart) }
// A plain task has one occurrence, whatever it was addressed by. A series
// answers null rather than another occurrence a write would then land on.
return if (seriesRules(listOf(taskId)).isEmpty()) task(taskId) else null
}
override fun subtasks(parentTaskId: Long): List<Task> =
@@ -506,15 +508,25 @@ class AndroidTasksDataSource @Inject constructor(
if (ownRowsOnlyFor != authority) {
try {
return resolver.query(uri, null, OCCURRENCE_OF, arrayOf(id, id), null)
} catch (_: SQLiteException) {
ownRowsOnlyFor = authority
} catch (_: IllegalArgumentException) {
ownRowsOnlyFor = authority
} catch (e: SQLiteException) {
rememberIfMissingColumn(authority, e)
} catch (e: IllegalArgumentException) {
rememberIfMissingColumn(authority, e)
}
}
return resolver.query(uri, null, "${Instances.TASK_ID} = ?", arrayOf(id), null)
}
/** Only a view without the column is remembered; any other failure falls back for this query alone. */
private fun rememberIfMissingColumn(authority: String, e: Exception) {
val message = e.message.orEmpty()
if (Tasks.ORIGINAL_INSTANCE_ID in message || "no such column" in message || "Invalid column" in message) {
ownRowsOnlyFor = authority
} else {
Log.w(TAG, "Occurrence query failed, reading the task's own rows", e)
}
}
/** The provider whose instances view turned out to lack `original_instance_id`. */
@Volatile
private var ownRowsOnlyFor: String? = null
@@ -556,7 +568,8 @@ class AndroidTasksDataSource @Inject constructor(
val ops = resolver.query(
TasksContract.tasksUri(authority),
arrayOf(Tasks.ID, Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE) + columns,
columns.joinToString(" OR ") { "$it % 1000 != 0" },
// Only series: the provider moves nothing else, so nothing else needs rounding.
"(${columns.joinToString(" OR ") { "$it % 1000 != 0" }}) AND (${Tasks.RRULE} IS NOT NULL OR ${Tasks.RDATE} IS NOT NULL)",
null,
null,
)?.use { c ->
@@ -688,16 +701,21 @@ class AndroidTasksDataSource @Inject constructor(
}
override fun setCancelled(taskId: Long, cancelled: Boolean) {
if (!cancelled && movedBack(taskId, null)) return
val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("cancel task $taskId")
}
/** Through the instances URI, like [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) {
if (!cancelled && movedBack(taskId, occurrenceStart)) return
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
?: return tickedCopy(taskId, occurrenceStart)?.let { setCancelled(it, cancelled) }
?: plainUnlessSeries(taskId, occurrenceStart) { setCancelled(taskId, cancelled) }
val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
val rows = rememberingCopy(taskId, occurrenceStart, closing = cancelled) {
resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null)
}
if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId")
}
@@ -707,6 +725,7 @@ class AndroidTasksDataSource @Inject constructor(
)
override fun setCompleted(taskId: Long, completed: Boolean) {
if (!completed && movedBack(taskId, null)) return
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
@@ -718,14 +737,105 @@ class AndroidTasksDataSource @Inject constructor(
* is not a series means the plain write is the right one.
*/
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) {
if (!completed && movedBack(taskId, occurrenceStart)) return
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: return plainUnlessSeries(taskId, occurrenceStart) { setCompleted(taskId, completed) }
?: return tickedCopy(taskId, occurrenceStart)?.let { setCompleted(it, completed) }
?: 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)
val rows = rememberingCopy(taskId, occurrenceStart, closing = completed) {
resolver.update(uri, values.toContentValues(), null, null)
}
if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId")
}
/**
* Runs [write], and when it closed an occurrence the provider then detached
* into a task of its own, remembers that copy, so the occurrence can still be
* read and reopened, and how the series moved, so reopening can move it back.
*/
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
if (!closing) return write()
val authority = authority()
val lastId = runCatching { lastTaskId(authority) }.getOrNull() ?: return write()
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
return write().also { rows ->
if (rows > 0) {
runCatching {
detachedSince(authority, lastId)?.let { copy ->
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
}
}.onFailure { Log.w(TAG, "Looking up the detached copy failed", it) }
}
}
}
/**
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
* with no occurrence, the tick that left the copy [taskId]: the series goes
* back where it was and the copy goes, as long as the series is still where
* the tick moved it and the copy has nothing filed under it. False when it
* cannot, so the caller reopens the copy as a task of its own.
*/
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
val authority = authority()
val (key, tick) = occurrenceStart?.let { at -> ticks.key(authority, taskId, at).let { k -> ticks.get(k)?.let { k to it } } }
?: ticks.forCopy(authority, taskId)
?: return false
val series = tick.seriesId ?: return false
val before = tick.before ?: return false
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
val ops = arrayListOf(
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
)
resolver.applyBatch(authority, ops)
ticks.remove(key)
true
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
/** The columns a tick moves a series by, or null when [taskId] is no series. */
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
if (seriesRules(listOf(taskId)).isEmpty()) return null
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
SERIES_STATE.indices.associate { i ->
SERIES_STATE[i] to when (c.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> c.getLong(i)
else -> c.getString(i)
}
}
}
}
private fun lastTaskId(authority: String): Long =
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
?.use { c -> if (c.moveToFirst()) c.getLong(0) else 0L } ?: 0L
/** The one plain task the provider created after [afterId], or null when it was not exactly one. */
private fun detachedSince(authority: String, afterId: Long): Long? =
resolver.query(TasksContract.tasksUri(authority), null, "${Tasks.ID} > ?", arrayOf(afterId.toString()), null)
?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
val plain = r.getString(Tasks.RRULE) == null && r.getString(Tasks.RDATE) == null &&
r.getLong(Tasks.ORIGINAL_INSTANCE_ID) == null
if (plain) r.getLong(Tasks.ID)?.let(::add)
}
}
}?.singleOrNull()
private val ticks = TickedCopies { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) }
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
ticks.get(ticks.key(authority(), taskId, occurrenceStart))?.copyId
/**
* [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.
@@ -825,11 +935,16 @@ private const val LIST_ORDER = "provider_list_order"
/** 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 TICKED_COPIES = "provider_ticked_copies"
/** What a tick changes on a series, and an undo changes back. */
private val SERIES_STATE = arrayOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
)
private const val TAG = "AndroidTasksDataSource"
/** No rule to look up: the provider says the row does not recur. */
@@ -25,6 +25,12 @@ private fun Instant.forAllDay(allDay: Boolean): Instant =
*/
fun providerMillis(epochMillis: Long): Long = Math.floorDiv(epochMillis, 1000L) * 1000L
/** [at] cut to its whole second, as [providerMillis] does. */
fun wholeSecond(at: Instant): Instant = Instant.fromEpochMilliseconds(providerMillis(at.toEpochMilliseconds()))
/** True when [a] and [b] fall in the same whole second. */
fun sameSecond(a: Instant, b: Instant): Boolean = wholeSecond(a) == wholeSecond(b)
/**
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
* so it unit-tests on the JVM; [AndroidTasksDataSource] converts the map to
@@ -68,7 +68,8 @@ class TasksRepositoryImpl @Inject constructor(
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
dataSource.task(taskId, occurrenceStart)?.let { task ->
val owner = if (task.seriesId == null) resolvedRow(task, taskId) else taskId
// A series' subtasks hang off its master, also when this occurrence is an override.
val owner = task.seriesId ?: resolvedRow(task, taskId)
TaskDetail(
task = task,
subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner },
@@ -173,7 +174,9 @@ class TasksRepositoryImpl @Inject constructor(
) = withContext(io) {
// Re-read just before writing: it settles the conflict check *and* tells
// us which URI to write through.
// An occurrence that is gone is not written through to its series.
val current = dataSource.task(taskId, occurrenceStart)
?: if (occurrenceStart != null) throw TaskWriteFailedException("update task $taskId@$occurrenceStart: gone") else null
// Conflict-safe overwrite: bail if the provider's last_modified moved
// since the form captured it (external sync / another app). A null
// baseline means "force / overwrite anyway".
@@ -234,6 +237,8 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) =
withContext(io) {
val current = dataSource.task(taskId, occurrenceStart)
// An occurrence that is gone leaves nothing to delete; its series stays.
if (current == null && occurrenceStart != null) return@withContext
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(resolvedRow(current, taskId))
@@ -0,0 +1,84 @@
package de.jeanlucmakiola.agendula.data.tasks
import android.content.SharedPreferences
import org.json.JSONObject
import kotlin.time.Instant
/**
* What ticking a provider occurrence left: the copy the provider detached, and
* the series' columns before and after the tick, so an undo can move it back.
*/
internal class Tick(
val copyId: Long,
val seriesId: Long?,
val before: Map<String, Any?>?,
val after: Map<String, Any?>?,
val at: Long,
)
/** The provider keeps no record of its ticks, so they are kept here for [HORIZON_MS]. */
internal class TickedCopies(private val prefs: () -> SharedPreferences) {
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
fun get(key: String): Tick? = prefs().getString(key, null)?.let(::decode)
/** The tick that left [copyId], with its key. */
fun forCopy(authority: String, copyId: Long): Pair<String, Tick>? =
prefs().all.entries.firstNotNullOfOrNull { (key, value) ->
if (!key.startsWith("$authority|")) return@firstNotNullOfOrNull null
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
}
fun put(key: String, tick: Tick) {
val stale = prefs().all.filter { (_, value) ->
val at = (value as? String)?.let(::decode)?.at ?: 0L
tick.at - at > HORIZON_MS
}.keys
prefs().edit().apply {
stale.forEach(::remove)
putString(key, encode(tick))
}.apply()
}
fun remove(key: String) {
prefs().edit().remove(key).apply()
}
private fun encode(tick: Tick): String = JSONObject().apply {
put("copy", tick.copyId)
put("at", tick.at)
tick.seriesId?.let { put("series", it) }
tick.before?.let { put("before", columns(it)) }
tick.after?.let { put("after", columns(it)) }
}.toString()
private fun columns(values: Map<String, Any?>) = JSONObject().apply {
values.forEach { (column, value) -> put(column, value ?: JSONObject.NULL) }
}
private fun decode(raw: String): Tick? = runCatching {
val json = JSONObject(raw)
Tick(
copyId = json.getLong("copy"),
seriesId = if (json.has("series")) json.getLong("series") else null,
before = json.optJSONObject("before")?.let(::values),
after = json.optJSONObject("after")?.let(::values),
at = json.getLong("at"),
)
}.getOrNull()
private fun values(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { column ->
when (val value = json.get(column)) {
JSONObject.NULL -> null
is Number -> value.toLong()
else -> value.toString()
}
}
companion object {
/** As long as the own store keeps a tick undoable. */
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
}
}
@@ -114,7 +114,18 @@ interface AdvancedOccurrenceDao {
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id IN (:masterIds)")
fun settle(masterIds: List<Long>)
/** Records too old to matter: their occurrence is long gone from every list and reminder. */
@Query("DELETE FROM advanced_occurrences WHERE master_id = :masterId AND anchor < :before")
fun pruneBefore(masterId: Long, before: Instant)
/** Settles [masterId]'s moves to before [until], the anchor the server now holds. */
@Query("UPDATE advanced_occurrences SET settled = 1 WHERE master_id = :masterId AND advanced_to < :until")
fun settleUpTo(masterId: Long, until: Instant)
/** Records of ticks made before [before]: too old to undo. Counted from the tick, not the occurrence, which may be long overdue. */
@Query(
"DELETE FROM advanced_occurrences WHERE master_id = :masterId " +
"AND copy_id IN (SELECT id FROM tasks WHERE created_at < :before)",
)
fun pruneTickedBefore(masterId: Long, before: Instant)
/** [taskId], once detached with a tick, has a life of its own and no longer returns to the series. */
@Query("UPDATE advanced_detached SET task_id = NULL WHERE task_id = :taskId")
fun unlinkDetached(taskId: Long)
}
@@ -6,6 +6,8 @@ import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.sameSecond
import de.jeanlucmakiola.agendula.data.tasks.wholeSecond
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
@@ -139,12 +141,13 @@ class RoomTasksDataSource @Inject constructor(
if (occurrenceStart == null) return task(taskId)
val row = tasks.task(taskId) ?: return null
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
return occurrencesOf(row, tasks.overrides(taskId), clock.now())
.firstOrNull { it.occurrenceStart == occurrenceStart }
val occurrences = occurrencesOf(row, tasks.overrides(taskId), clock.now())
// Null rather than another occurrence: a write addressed to this one must not land on that.
return occurrences.firstOrNull { it.occurrenceStart == occurrenceStart }
?: occurrences.firstOrNull { it.occurrenceStart?.let { a -> sameSecond(a, occurrenceStart) } == true }
?: copyAt(row.task, occurrenceStart)?.let { copy ->
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
}
?: task(taskId)
}
private fun overrideTask(row: TaskRow, anchor: Instant): Task =
@@ -242,10 +245,8 @@ class RoomTasksDataSource @Inject constructor(
* reminders sync, a sync landing between the two would upload the task
* without them, or with the ones it is about to lose.
*/
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
alarms.syncForTask(taskId, reminders)
}
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?): Boolean =
reminders != null && alarms.syncForTask(taskId, reminders)
private fun storedReminders(taskId: Long): List<TaskReminder> =
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
@@ -253,7 +254,7 @@ class RoomTasksDataSource @Inject constructor(
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
writeReminders(taskId, reminders)
val remindersChanged = writeReminders(taskId, reminders)
if (current.masterId == null && edited.listId != current.listId) {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, TaskFormWriter.withRule(edited, form.rrule))
@@ -275,6 +276,7 @@ class RoomTasksDataSource @Inject constructor(
} else {
edited.copy(listId = current.listId, parentId = current.parentId)
},
changedBesides = remindersChanged,
)
writes.onWritten(setOf(current.listId))
}
@@ -283,6 +285,7 @@ class RoomTasksDataSource @Inject constructor(
private fun move(current: TaskEntity, edited: TaskEntity) {
// A copy moved elsewhere is a task of its own now.
advanced.keepAsOwnTask(current.id)
advanced.unlinkDetached(current.id)
val plan = TaskMove.plan(
edited = edited,
previous = current,
@@ -472,6 +475,8 @@ class RoomTasksDataSource @Inject constructor(
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
if (!alarms.syncForTask(taskId, reminders)) return@tx
advanced.keepAsOwnTask(taskId)
advanced.unlinkDetached(taskId)
// The reminders are part of the VTODO now, so changing them is an edit.
val current = tasks.entity(taskId) ?: return@tx
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
@@ -535,8 +540,11 @@ class RoomTasksDataSource @Inject constructor(
return
}
if (master.isClosed) {
// A series ends with its last occurrence; reopening any of it reopens the series.
if (!closing) closeSeries(master, override, close) else writeInstance(master, override, anchor, close)
// A series ends with its last occurrence; reopening that one reopens the
// series. An override reopens alone while other occurrences are closed
// only through the master, which reopening it would reopen too.
val reopensSeries = !closing && (override == null || !closedThroughMaster(master, anchor))
if (reopensSeries) closeSeries(master, override, close) else writeInstance(master, override, anchor, close)
return
}
if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close)
@@ -544,13 +552,15 @@ class RoomTasksDataSource @Inject constructor(
/**
* Writes [edited] over [current]. A copy whose only change is that it
* reopens moves its series back instead; any other edit makes it a task of
* its own, and it no longer stands for the series' occurrence.
* reopens moves its series back instead; any other edit, [changedBesides]
* included, makes it a task of its own, and it no longer stands for the
* series' occurrence. So does any edit to an occurrence detached with it.
*/
private fun saveRow(current: TaskEntity, edited: TaskEntity) {
private fun saveRow(current: TaskEntity, edited: TaskEntity, changedBesides: Boolean = false) {
advanced.unlinkDetached(current.id)
val record = advanced.forCopy(current.id)?.takeIf { it.undoable }
if (record != null) {
val unchanged = sameApartFromState(current, edited)
val unchanged = !changedBesides && sameApartFromState(current, edited)
if (unchanged && current.isClosed && !edited.isClosed && reopenCopy(current, edited, record)) return
if (!unchanged || !edited.isClosed) advanced.keepAsOwnTask(current.id)
}
@@ -571,6 +581,14 @@ class RoomTasksDataSource @Inject constructor(
normalizeTags(a.categories) == normalizeTags(b.categories) && a.color == b.color
}
/** Whether an occurrence of [master] other than [except] has no override, so its state is the master's. */
private fun closedThroughMaster(master: TaskEntity, except: Instant): Boolean {
val spec = master.recurrenceSpec() ?: return false
val overridden = tasks.overrides(master.id).mapNotNull { it.recurrenceId?.let(::wholeSecond) }.toSet()
return expandFrom(spec, maxOccurrences = overridden.size + 2)
.any { !sameSecond(it, except) && wholeSecond(it) !in overridden }
}
/** [close] applied to the master — and to [override], its last occurrence — which ends the series. */
private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) {
tasks.update(close(master))
@@ -603,7 +621,7 @@ class RoomTasksDataSource @Inject constructor(
at to detach(overrides.getValue(at.epochSeconds), now)
}
// Expansion drops the anchor's sub-second part; the series keeps it.
val newAnchor = next + (spec.anchor - floorSecond(spec.anchor))
val newAnchor = next + (spec.anchor - wholeSecond(spec.anchor))
val spent = spentBefore(spec, next)
val (start, due) = occurrenceTimes(master, newAnchor)
val ruleAfter = TaskFormWriter.remainingCount(rule, spent)
@@ -628,8 +646,8 @@ class RoomTasksDataSource @Inject constructor(
alarms.copyToTask(master.id, id, sameResource = false)
id
}
advanced.at(master.id, floorSecond(anchor))?.let { advanced.delete(it.copyId) }
advanced.pruneBefore(master.id, now - RECORD_HORIZON)
advanced.at(master.id, wholeSecond(anchor))?.let { advanced.delete(it.copyId) }
advanced.pruneTickedBefore(master.id, now - RECORD_HORIZON)
advanced.insert(
AdvancedOccurrenceEntity(
copyId = copyId,
@@ -655,7 +673,7 @@ class RoomTasksDataSource @Inject constructor(
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
spec.copy(exdate = null),
ExpansionWindow(from = floorSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
ExpansionWindow(from = wholeSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
).size
/**
@@ -706,7 +724,7 @@ class RoomTasksDataSource @Inject constructor(
*/
private fun reopenCopy(row: TaskEntity, reopened: TaskEntity, record: AdvancedOccurrenceEntity): Boolean {
val master = tasks.entity(record.masterId)?.takeIf {
!it.isDeleted && !it.isClosed && row.listId == it.listId && it.rrule == record.ruleAfter &&
!it.isDeleted && !it.isClosed && row.listId == it.listId && sameRule(it.rrule, record.ruleAfter) &&
it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true
}
if (master == null || tasks.subtasks(row.id).isNotEmpty()) return false
@@ -740,6 +758,13 @@ class RoomTasksDataSource @Inject constructor(
return true
}
/** The same `RRULE` however a server re-serialised it: parts in any order and case. */
private fun sameRule(a: String?, b: String?): Boolean {
fun parts(rule: String?) = rule?.trim()?.removePrefix("RRULE:")?.split(';')
?.map { it.trim().uppercase() }?.filter { it.isNotEmpty() }?.toSet()
return parts(a) == parts(b)
}
/** [row], a detached occurrence, back as [master]'s override for [anchor]. */
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant) {
val back = row.copy(
@@ -765,16 +790,14 @@ class RoomTasksDataSource @Inject constructor(
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? =
advanced.at(master.id, floorSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
advanced.at(master.id, wholeSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
val from = floorSecond(spec.anchor)
val from = wholeSecond(spec.anchor)
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
}
private fun floorSecond(at: Instant) = Instant.fromEpochSeconds(at.epochSeconds)
/**
* The residue without the series' own lines, as a new resource must not
* carry them. Lines inside a nested component (a `VALARM`'s `UID`) are its
@@ -791,9 +814,6 @@ class RoomTasksDataSource @Inject constructor(
}?.joinToString("\r\n")?.ifBlank { null }
}
/** Expansion runs in whole seconds; stored anchors may carry milliseconds. */
private fun sameSecond(a: Instant, b: Instant) = a.epochSeconds == b.epochSeconds
/** Applies [write] to [override], or to a new one forked from [master] for [occurrenceStart]. */
private fun writeInstance(master: TaskEntity, override: TaskEntity?, occurrenceStart: Instant, write: (TaskEntity) -> TaskEntity) {
if (override != null) {
@@ -199,4 +199,8 @@ interface TaskDao {
*/
@Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)")
fun markSynced(taskIds: List<Long>, href: String?, etag: String?): Int
/** Sets `is_dirty` again on [taskId] when it was written after [seen], its `last_modified` when uploaded. */
@Query("UPDATE tasks SET is_dirty = 1 WHERE id = :taskId AND last_modified IS NOT :seen")
fun dirtyIfChangedSince(taskId: Long, seen: Instant?)
}