Move dated sub-tasks with their routine, mark tick copies (#160)

This commit is contained in:
makiolaj committed 2026-11-12 16:27:51 +01:00
1 parent 6113e73333
commit 6f4ae7e2ec
10 files changed
+515 -102

No files matched your search

+4 -3
View File
@@ -10,9 +10,10 @@ All notable changes to this project are documented here. The format follows
### Fixed
- Sub-tasks of a repeating task start over when ticking off its current
occurrence moves it on to its next date, as in tasks.org: completed and
half-done ones open again. Before, the next occurrence showed them already
ticked. Unticking gives them back their state, and repeating sub-tasks keep
their own schedule (#160).
half-done ones open again, and dated ones move along with the task. Before,
the next occurrence showed them already ticked. Unticking gives them back
their state and dates, and repeating sub-tasks keep their own schedule
(#160).
## [1.3.1] - 2026-11-09
@@ -14,6 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
@@ -763,6 +764,43 @@ class RoomTasksDataSourceTest {
assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30)
}
@Test
fun aDatedSubtaskMovesWithItsRoutineAndBack() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
val undated = source.insertTask(form(title = "Undated").copy(parentId = id))
source.setCompleted(step, completed = true)
val due = db.tasks().entity(step)!!.due!!
source.setCompletedInstance(id, at, completed = true)
// A week of wall-clock time: an hour either way across a DST change.
assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours))
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(undated)!!.due).isNull()
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(step)!!.due).isEqualTo(due)
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aMarkedCopyUnderARoutineIsLeftAlone() {
val at = now
val id = source.insertTask(form(title = "Routine"))
makeRecurring(id, at)
val copy = source.insertTask(form(title = "Daily part").copy(parentId = id))
source.setCompleted(copy, completed = true)
db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:1"))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() {
val at = now
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import java.time.ZoneId
import java.time.ZoneOffset
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Instant
@@ -196,12 +197,7 @@ class AndroidTasksDataSource @Inject constructor(
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()
): List<T> = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) ->
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
@@ -743,7 +739,8 @@ class AndroidTasksDataSource @Inject constructor(
* 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.
* When the series moved on, its sub-tasks start over for the next occurrence,
* and the tick remembers how they were (#160).
* and the tick remembers how they were (#160). Without a copy there is no
* tick to remember them in, so they are left as they are.
*/
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
if (!closing) return write()
@@ -757,13 +754,18 @@ class AndroidTasksDataSource @Inject constructor(
.onFailure { Log.w(TAG, "Reading the series after the tick failed", it) }
.getOrNull()?.takeIf { it != before }
}
val reset = if (after != null && SubtaskReset.movedOn(before, after, runCatching { seriesSpec(taskId) }.getOrNull())) {
runCatching { resetSubtasks(authority, taskId) }.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }.getOrNull()
} else {
null
}
runCatching {
detachedSince(authority, lastId)?.let { copy ->
val copy = detachedSince(authority, lastId)
val resets = SubtaskReset.resetsOnTick(copy, before, after)
copy?.let {
markCopy(authority, copy)
val reset = if (resets && before != null && after != null) {
runCatching { resetSubtasks(authority, taskId, SubtaskReset.shiftOf(before, after)) }
.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }
.getOrNull()
} else {
null
}
val tick = Tick(
copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis(),
reset = reset?.let(SubtaskReset::encode),
@@ -775,51 +777,83 @@ class AndroidTasksDataSource @Inject constructor(
}
}
/** Resets what [SubtaskReset] picks under [parentId]. */
private fun resetSubtasks(authority: String, parentId: Long): List<ResetSubtask> {
val copies = ticks.copies(authority)
val reset = SubtaskReset.toReset(parentId) { id ->
/** Marks [copyId] as a tick's copy where DAVx5 keeps and uploads unknown properties, so other installs know it too. */
private fun markCopy(authority: String, copyId: Long) {
val values = mapOf(
Properties.TASK_ID to copyId,
Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE,
Properties.DATA0 to SubtaskReset.COPY_MARKER_JSON,
)
runCatching { resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues()) }
.onFailure { Log.w(TAG, "Marking the ticked copy failed", it) }
}
/** The rows carrying Agendula's copy marker. */
private fun markedCopies(authority: String): Set<Long> = runCatching {
resolver.query(
TasksContract.propertiesUri(authority),
arrayOf(Properties.TASK_ID, Properties.DATA0),
"${Properties.MIMETYPE} = ? AND ${Properties.DATA0} LIKE ?",
arrayOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE, "%${SubtaskReset.COPY_MARKER}%"),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildSet { while (c.moveToNext()) if (SubtaskReset.isCopyMarker(r.getString(Properties.DATA0))) r.getLong(Properties.TASK_ID)?.let(::add) }
}.orEmpty()
}.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptySet())
/** Resets what [SubtaskReset] picks under [parentId], dated ones moved by [shift]. */
private fun resetSubtasks(authority: String, parentId: Long, shift: SeriesShift?): List<ResetSubtask> {
val copies = ticks.copies(authority) + markedCopies(authority)
val reset = SubtaskReset.toReset(parentId, shift) { id ->
childRows(authority, id, SUBTASK_STATE) { r ->
val rowId = r.getLong(Tasks.ID) ?: return@childRows null
SubtaskReset.providerNode(rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r))
val allDay = r.getBoolean(Tasks.IS_ALLDAY)
SubtaskReset.providerNode(
rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r),
SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), allDay, zoneOf(allDay, r.getString(Tasks.TZ)),
)
}
}
writeSubtaskStates(authority, reset.map { it.id to SubtaskState.FRESH })
val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates.takeIf { _ -> it.dates != it.priorDates }) }
if (ops.isNotEmpty()) resolver.applyBatch(authority, ArrayList(ops))
return reset
}
private fun zoneOf(allDay: Boolean, tz: String?): ZoneId =
if (allDay) ZoneOffset.UTC else tz?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault()
private fun subtaskState(r: ColumnReader) =
SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED))
private fun subtaskOps(authority: String, states: List<Pair<Long, SubtaskState>>): List<ContentProviderOperation> =
states.map { (id, state) ->
val values = mapOf(
Tasks.STATUS to state.status.toInt(),
Tasks.PERCENT_COMPLETE to state.percent,
Tasks.COMPLETED to state.completedAt,
)
ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
/** [state] onto sub-task [id], and [dates] when they change. */
private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation {
val values = buildMap<String, Any?> {
put(Tasks.STATUS, state.status.toInt())
put(Tasks.PERCENT_COMPLETE, state.percent)
put(Tasks.COMPLETED, state.completedAt)
if (dates != null) {
put(Tasks.DTSTART, dates.start)
put(Tasks.DUE, dates.due)
}
}
private fun writeSubtaskStates(authority: String, states: List<Pair<Long, SubtaskState>>) {
if (states.isNotEmpty()) resolver.applyBatch(authority, ArrayList(subtaskOps(authority, states)))
return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
}
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state. */
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */
private fun subtaskRestore(authority: String, reset: String?): List<ContentProviderOperation> {
val entries = SubtaskReset.decode(reset)
if (entries.isEmpty()) return emptyList()
val current = resolver.query(
val current = queryByIds(
TasksContract.tasksUri(authority),
SUBTASK_STATE,
"${Tasks.ID} IN (${entries.joinToString { "?" }}) AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
entries.map { it.id.toString() }.toTypedArray(),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildMap { while (c.moveToNext()) r.getLong(Tasks.ID)?.let { put(it, subtaskState(r)) } }
}.orEmpty()
return subtaskOps(authority, SubtaskReset.toRestore(entries, current::get).map { it.id to it.prior })
Tasks.ID,
entries.map { it.id },
filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0",
) { r -> r.getLong(Tasks.ID)?.let { it to (subtaskState(r) to SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))) } }.toMap()
return SubtaskReset.toRestore(entries, current::get).map {
subtaskOp(authority, it.id, it.prior, it.priorDates.takeIf { _ -> it.priorDates != it.dates })
}
}
/** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */
@@ -854,8 +888,11 @@ class AndroidTasksDataSource @Inject constructor(
?: 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 after = tick.after ?: return false
// Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither.
val now = seriesState(authority, series) ?: return false
if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false
val restore = before.filter { (column, value) -> after[column] != value }
val ops = arrayListOf(
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
@@ -867,9 +904,8 @@ class AndroidTasksDataSource @Inject constructor(
}.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 ->
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? =
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)) {
@@ -878,8 +914,7 @@ class AndroidTasksDataSource @Inject constructor(
else -> c.getString(i)
}
}
}
}
}?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } }
private fun lastTaskId(authority: String): Long =
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
@@ -1011,18 +1046,19 @@ private const val MIGRATIONS = "provider_migrations"
private const val TICKED_COPIES = "provider_ticked_copies"
/** Every copy a tick left, kept past [TickedCopies.HORIZON_MS]: nothing on the row itself marks it. */
/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */
private const val KNOWN_COPIES = "provider_known_copies"
/** What a tick changes on a series, and an undo changes back. */
/** What resetting a sub-task reads: whether it recurs, and its progress. */
/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */
private val SUBTASK_STATE = arrayOf(
Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED,
Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ,
)
/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */
private val SERIES_STATE = arrayOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ,
)
private const val TAG = "AndroidTasksDataSource"
@@ -1030,5 +1066,3 @@ 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
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.tasks
/** SQLite's default bound-argument limit on older Android releases. */
internal const val MAX_SQL_ARGS = 999
/**
* `idColumn IN (…)` selections over [ids], each with its arguments and
* [filterArgs] after them, in chunks that keep every query within [limit]
* bound arguments.
*/
internal fun inSelections(
idColumn: String,
ids: Collection<Long>,
filter: String? = null,
filterArgs: List<String> = emptyList(),
limit: Int = MAX_SQL_ARGS,
): List<Pair<String, Array<String>>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk ->
val selection = buildString {
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
if (filter != null) append(" AND ($filter)")
}
selection to (chunk.map(Long::toString) + filterArgs).toTypedArray()
}
@@ -7,6 +7,10 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import de.jeanlucmakiola.agendula.domain.statusFromInt
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
@@ -20,33 +24,102 @@ internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val
}
}
/** A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of a recurring one left. */
internal class SubtaskNode(val id: Long, val recurring: Boolean, val state: SubtaskState)
/** A sub-task's own start and due, as epoch millis. */
internal data class SubtaskDates(val start: Long?, val due: Long?) {
val isEmpty: Boolean get() = start == null && due == null
/** A sub-task [SubtaskReset] reset, with the state it had. */
internal data class ResetSubtask(val id: Long, val prior: SubtaskState)
companion object {
val NONE = SubtaskDates(null, null)
}
}
/**
* A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of
* a recurring one left. [zone] is the one its dates are read in.
*/
internal class SubtaskNode(
val id: Long,
val recurring: Boolean,
val state: SubtaskState,
val dates: SubtaskDates = SubtaskDates.NONE,
val isAllDay: Boolean = false,
val zone: ZoneId = ZoneOffset.UTC,
)
/** A sub-task [SubtaskReset] reset: its state and dates before, and the dates the reset gave it. */
internal data class ResetSubtask(
val id: Long,
val prior: SubtaskState,
val priorDates: SubtaskDates = SubtaskDates.NONE,
val dates: SubtaskDates = priorDates,
)
/**
* How far a series moved, as wall-clock time in [seriesZone], so a sub-task
* keeps its place relative to the routine across a DST change.
*/
internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId) {
private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone))
/** [at] moved along; an all-day date by whole days. */
fun apply(at: Long, isAllDay: Boolean, zone: ZoneId): Long {
val z = if (isAllDay) ZoneOffset.UTC else zone
val by = if (isAllDay) Duration.ofDays(delta.toDays()) else delta
return local(at, z).plus(by).atZone(z).toInstant().toEpochMilli()
}
private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone)
}
/**
* What a routine's sub-tasks do when its series moves on to the next occurrence
* (#160): the ones completed or in progress start over, as in tasks.org, and
* moving the series back gives them their state again.
* (#160): the ones completed or in progress start over, as in tasks.org, dated
* ones move along with the routine, and moving the series back gives them their
* state and dates again.
*/
internal object SubtaskReset {
/** Only bounds the search for a next occurrence. */
private val LOOKAHEAD = (366 * 1000).days
/** The `X-` property Agendula puts on the copy a provider tick left, so any device knows it for one. */
const val COPY_MARKER = "X-AGENDULA-TICK-COPY"
/** The marker as DAVx5 stores an unknown property: a JSON array of name and value. */
const val COPY_MARKER_JSON = "[\"$COPY_MARKER\",\"1\"]"
/** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */
const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property"
/** Whether an unknown-property row's JSON is the copy marker. */
fun isCopyMarker(json: String?): Boolean =
json?.trim()?.startsWith("[\"$COPY_MARKER\"", ignoreCase = true) == true
/** Whether a stored residue carries the copy marker, as a copy synced in from a provider install does. */
fun hasCopyMarker(residue: String?): Boolean = residue?.lines()?.any {
val line = it.trim()
line.startsWith(COPY_MARKER, ignoreCase = true) && line.getOrNull(COPY_MARKER.length).let { c -> c == ':' || c == ';' }
} == true
/**
* The sub-tasks under [parentId], all the way down, to reset. A recurring one
* has a series of its own and is left alone with everything under it; a
* cancelled one stays cancelled.
* The sub-tasks under [parentId], all the way down, to reset: started ones,
* and with a [shift], dated ones too. A recurring one has a series of its
* own and is left alone with everything under it; a cancelled one stays as
* it is.
*/
fun toReset(parentId: Long, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
val seen = mutableSetOf(parentId)
val reset = mutableListOf<ResetSubtask>()
fun walk(id: Long) {
children(id).filter { !it.recurring && seen.add(it.id) }.forEach {
if (it.state.status != TaskStatus.CANCELLED && !it.state.isFresh) reset += ResetSubtask(it.id, it.state)
val moves = shift != null && !it.dates.isEmpty
if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) {
val dates = if (shift == null) it.dates else SubtaskDates(
it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.zone) },
it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.zone) },
)
reset += ResetSubtask(it.id, it.state, it.dates, dates)
}
walk(it.id)
}
}
@@ -55,27 +128,67 @@ internal object SubtaskReset {
}
/**
* A provider row as a node. Nothing on a copy the provider detached marks it
* as one, so only the [copies] a tick in Agendula left are known: they belong
* to a recurring sub-task and count as recurring.
* A provider row as a node. Nothing the provider sets on a detached copy marks
* it as one, so a copy is known by Agendula's marker or by the [copies] ticks
* on this install left: it belongs to a recurring sub-task and counts as recurring.
*/
fun providerNode(id: Long, rrule: String?, rdate: String?, copies: Set<Long>, state: SubtaskState) =
SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state)
fun providerNode(
id: Long,
rrule: String?,
rdate: String?,
copies: Set<Long>,
state: SubtaskState,
dates: SubtaskDates = SubtaskDates.NONE,
isAllDay: Boolean = false,
zone: ZoneId = ZoneOffset.UTC,
) = SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state, dates, isAllDay, zone)
/** The reset sub-tasks still as the reset left them, so moving back may restore them. */
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> SubtaskState?): List<ResetSubtask> =
reset.filter { current(it.id)?.isFresh == true }
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> Pair<SubtaskState, SubtaskDates>?): List<ResetSubtask> =
reset.filter { entry -> current(entry.id)?.let { (state, dates) -> state.isFresh && dates == entry.dates } == true }
fun encode(reset: List<ResetSubtask>): String = reset.joinToString(";") {
listOf(it.id, it.prior.status.name, it.prior.percent ?: "", it.prior.completedAt ?: "").joinToString(",")
listOf(
it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt,
it.priorDates.start, it.priorDates.due, it.dates.start, it.dates.due,
).joinToString(",") { value -> value?.toString().orEmpty() }
}
fun decode(raw: String?): List<ResetSubtask> = raw.orEmpty().split(';').mapNotNull { entry ->
val parts = entry.split(',')
if (parts.size != 4) return@mapNotNull null
if (parts.size != 4 && parts.size != 8) return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null
ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull()))
val prior = SubtaskDates(parts.getOrNull(4)?.toLongOrNull(), parts.getOrNull(5)?.toLongOrNull())
val dates = if (parts.size == 8) SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()) else prior
ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull()), prior, dates)
}
/** A provider series' rule set from its columns, or null when it does not recur. */
fun specOf(state: Map<String, Any?>): RecurrenceSpec? {
val rrule = (state[Tasks.RRULE] as? String)?.ifBlank { null }
val rdate = (state[Tasks.RDATE] as? String)?.ifBlank { null }
if (rrule == null && rdate == null) return null
val anchor = anchor(state) ?: return null
return RecurrenceSpec(
rrule = rrule,
rdate = rdate,
exdate = state[Tasks.EXDATE] as? String,
anchor = Instant.fromEpochMilliseconds(anchor),
isAllDay = isAllDay(state),
timeZone = state[Tasks.TZ] as? String,
)
}
fun isAllDay(state: Map<String, Any?>): Boolean = (state[Tasks.IS_ALLDAY] as? Number)?.toInt()?.let { it != 0 } == true
/** How the series moved from [before] to [after], read in the zone its rule is expanded in. */
fun shiftOf(before: Map<String, Any?>, after: Map<String, Any?>, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? {
val from = anchor(before) ?: return null
val to = anchor(after) ?: return null
val zone = if (isAllDay(after)) ZoneOffset.UTC
else (after[Tasks.TZ] as? String)?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating
return SeriesShift(from, to, zone)
}
/**
@@ -85,7 +198,7 @@ internal object SubtaskReset {
* there. An `EXDATE` or a status alone is no move; a series whose rule ran
* out is finished.
*/
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec?): Boolean {
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean {
val was = anchor(before ?: return false) ?: return false
val now = anchor(after ?: return false) ?: return false
if (now <= was) return false
@@ -104,6 +217,14 @@ internal object SubtaskReset {
return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty()
}
/**
* Whether a provider tick resets the sub-tasks: the series moved on, and the
* provider left a [copy] whose tick can record what the reset changed. With
* no copy there is nothing to undo through, so they are left as they are.
*/
fun resetsOnTick(copy: Long?, before: Map<String, Any?>?, after: Map<String, Any?>?): Boolean =
copy != null && movedOn(before, after)
private fun anchor(state: Map<String, Any?>): Long? =
(state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong()
}
@@ -39,31 +39,38 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
/** Every copy a tick in Agendula left in [authority]. */
fun copies(authority: String): Set<Long> {
val kept = known().getStringSet(authority, emptySet()).orEmpty().mapNotNullTo(HashSet()) { it.toLongOrNull() }
val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() }
prefs().all.forEach { (key, value) ->
if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId }
}
return kept
}
/** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */
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
val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at)
val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId))
known().edit().apply {
known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) }
}.apply()
prefs().edit().apply {
stale.forEach(::remove)
putString(key, encode(tick))
}.apply()
val authority = key.substringBefore('|')
val kept = known().getStringSet(authority, emptySet()).orEmpty()
known().edit().putStringSet(authority, kept + tick.copyId.toString()).apply()
}
/** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */
fun remove(key: String) {
val copy = get(key)?.copyId
prefs().edit().remove(key).apply()
if (copy != null) {
val authority = key.substringBefore('|')
known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply()
}
}
private fun knownIds(authority: String): Set<String> = known().getStringSet(authority, emptySet()).orEmpty().toSet()
private fun encode(tick: Tick): String = JSONObject().apply {
put("copy", tick.copyId)
put("at", tick.at)
@@ -100,5 +107,16 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
companion object {
/** As long as the own store keeps a tick undoable. */
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
/**
* The keys of [ticks] past the horizon at [now], unreadable ones included,
* and per authority the copies they leave, which stay known after the tick goes.
*/
fun expire(ticks: Map<String, Tick?>, now: Long): Pair<Set<String>, Map<String, Set<Long>>> {
val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS }
val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } }
.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
return stale.keys to keep
}
}
}
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask
import de.jeanlucmakiola.agendula.data.tasks.SeriesShift
import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode
import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset
import de.jeanlucmakiola.agendula.data.tasks.SubtaskState
@@ -22,6 +24,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -642,7 +645,7 @@ class RoomTasksDataSource @Inject constructor(
isDirty = true,
),
)
val reset = resetSubtasks(master.id, now)
val (reset, resetLists) = resetSubtasks(master, newAnchor, now)
val copyId = if (override != null) {
detach(override, now, close)
} else {
@@ -672,33 +675,62 @@ class RoomTasksDataSource @Inject constructor(
),
)
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
writes.onWritten(setOf(master.listId) + reset.mapNotNull { tasks.entity(it.id)?.listId })
writes.onWritten(setOf(master.listId) + resetLists)
return true
}
/** Resets what [SubtaskReset] picks under [parentId] for the series' next occurrence. */
private fun resetSubtasks(parentId: Long, now: Instant): List<ResetSubtask> {
val reset = SubtaskReset.toReset(parentId) { id ->
tasks.subtasks(id).map { SubtaskNode(it.task.id, it.task.isRecurring, it.task.progress) }
/**
* Resets what [SubtaskReset] picks under [master] for the series' next
* occurrence at [newAnchor], dated ones moved along; the lists it wrote to.
*/
private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair<List<ResetSubtask>, Set<Long>> {
val shift = master.recurrenceAnchor?.let {
SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), master.zoneId())
}
reset.forEach { tasks.entity(it.id)?.let { sub -> tasks.update(sub.withProgress(SubtaskState.FRESH, now)) } }
return reset
val loaded = HashMap<Long, TaskEntity>()
val reset = SubtaskReset.toReset(master.id, shift) { id ->
tasks.subtasks(id).map { row ->
val sub = row.task.also { loaded[it.id] = it }
SubtaskNode(
sub.id,
sub.isRecurring || SubtaskReset.hasCopyMarker(sub.unknownProperties),
sub.progress,
sub.dates,
sub.isAllDay,
sub.zoneId(),
)
}
}
val lists = reset.mapNotNull { entry ->
loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId
}
return reset to lists.toSet()
}
/** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */
private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set<Long> {
val restore = SubtaskReset.toRestore(SubtaskReset.decode(record.resetSubtasks)) { tasks.entity(it)?.takeUnless { e -> e.isDeleted }?.progress }
return restore.mapNotNull { reset ->
tasks.entity(reset.id)?.also { tasks.update(it.withProgress(reset.prior, now)) }?.listId
val entries = SubtaskReset.decode(record.resetSubtasks)
val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id }
val restore = SubtaskReset.toRestore(entries) { id -> current[id]?.let { it.progress to it.dates } }
return restore.mapNotNull { entry ->
current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId
}.toSet()
}
private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds())
private fun TaskEntity.withProgress(state: SubtaskState, now: Instant) = copy(
private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds())
/** The zone a row's dates are read in, as [RecurrenceExpander] reads a series. */
private fun TaskEntity.zoneId(): ZoneId =
if (isAllDay) ZoneOffset.UTC else timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault()
private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates, now: Instant) = copy(
status = state.status,
percentComplete = state.percent,
completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds),
dtstart = dates.start?.let(Instant::fromEpochMilliseconds),
due = dates.due?.let(Instant::fromEpochMilliseconds),
lastModified = now,
isDirty = true,
)
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class SqlChunksTest {
@Test
fun `ids are split so no query binds more than the limit`() {
val ids = (1L..2_100L).toList()
val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0"))
assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder()
assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString))
assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue()
assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)")
}
@Test
fun `repeated ids are bound once, and none give no query`() {
assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4")
assertThat(inSelections("_id", emptyList())).isEmpty()
}
}
@@ -5,6 +5,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import org.junit.jupiter.api.Test
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
class SubtaskResetTest {
@@ -79,8 +81,13 @@ class SubtaskResetTest {
@Test
fun `what was reset survives encoding`() {
val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null)))
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)),
ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)),
)
assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset)
assertThat(SubtaskReset.decode("2,COMPLETED,100,1000")).containsExactly(ResetSubtask(2, done))
assertThat(SubtaskReset.decode("")).isEmpty()
assertThat(SubtaskReset.decode(null)).isEmpty()
assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty()
@@ -88,9 +95,91 @@ class SubtaskResetTest {
@Test
fun `moving back restores only the sub-tasks still as the reset left them`() {
val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, done))
val current = mapOf(2L to SubtaskState.FRESH, 3L to done)
assertThat(SubtaskReset.toRestore(reset, current::get)).containsExactly(ResetSubtask(2, done))
val moved = SubtaskDates(20L, null)
val reset = listOf(
ResetSubtask(2, done),
ResetSubtask(3, halfway),
ResetSubtask(4, done),
ResetSubtask(5, done, SubtaskDates(10L, null), moved),
ResetSubtask(6, done, SubtaskDates(10L, null), moved),
)
val current = mapOf(
2L to (SubtaskState.FRESH to SubtaskDates.NONE),
3L to (done to SubtaskDates.NONE),
5L to (SubtaskState.FRESH to moved),
// Re-dated since: the user's dates win.
6L to (SubtaskState.FRESH to SubtaskDates(30L, null)),
)
assertThat(SubtaskReset.toRestore(reset, current::get).map { it.id }).containsExactly(2L, 5L)
}
private val berlin = ZoneId.of("Europe/Berlin")
private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli()
@Test
fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() {
// A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time.
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin)
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z"))
assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true, zone = berlin)).isEqualTo(at("2026-10-28T00:00:00Z"))
}
@Test
fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() {
val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC)
val nodes = listOf(
SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true),
SubtaskNode(4, false, SubtaskState.FRESH),
SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
)
val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() }
assertThat(reset).containsExactly(
ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)),
ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))),
)
}
@Test
fun `a provider series' shift is read in its own zone`() {
val before = mapOf<String, Any?>(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L)
val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z"))
val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!!
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z"))
}
@Test
fun `a provider series' rule set is read from its columns`() {
val state = mapOf<String, Any?>(
Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null,
Tasks.IS_ALLDAY to 1L, Tasks.TZ to null,
)
val spec = SubtaskReset.specOf(state)!!
assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY")
assertThat(spec.isAllDay).isTrue()
assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))).isNull()
}
@Test
fun `the copy marker is recognised in a provider row and in a synced residue`() {
assertThat(SubtaskReset.isCopyMarker(SubtaskReset.COPY_MARKER_JSON)).isTrue()
assertThat(SubtaskReset.isCopyMarker(" [\"x-agendula-tick-copy\",\"1\"]")).isTrue()
assertThat(SubtaskReset.isCopyMarker("[\"X-AGENDULA-TICK-COPYRIGHT\",\"1\"]")).isFalse()
assertThat(SubtaskReset.isCopyMarker(null)).isFalse()
assertThat(SubtaskReset.hasCopyMarker("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:1")).isTrue()
assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPY;X-P=1:1")).isTrue()
assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPYRIGHT:1")).isFalse()
assertThat(SubtaskReset.hasCopyMarker(null)).isFalse()
}
@Test
fun `a provider tick resets only with a copy to record it in`() {
val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY")
val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY")
assertThat(SubtaskReset.resetsOnTick(9L, before, after)).isTrue()
assertThat(SubtaskReset.resetsOnTick(null, before, after)).isFalse()
assertThat(SubtaskReset.resetsOnTick(9L, before, before)).isFalse()
}
private val week = 7L * 24 * 60 * 60 * 1000
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class TickedCopiesTest {
private val now = 100L * 24 * 60 * 60 * 1000
private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000)
@Test
fun `a tick past the horizon goes, and its copy stays known`() {
val ticks = mapOf(
"org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31),
"org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2),
"org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40),
"garbage" to null,
)
val (stale, known) = TickedCopies.expire(ticks, now)
assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage")
assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L))
}
@Test
fun `nothing expires inside the horizon`() {
val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now)
assertThat(stale).isEmpty()
assertThat(known).isEmpty()
}
}