This commit is contained in:
1 parent
4b1294341a
commit
7afa2f41f0
13 files changed
+526
-65
No files matched your search
+130
-15
@@ -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)
|
||||
}
|
||||
+46
-26
@@ -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?)
|
||||
}
|
||||
Reference in new issue
Block a user