refactor(data): address occurrences by (taskId, occurrenceStart)

Phase 0 of docs/OWN-STORE.md. Prepares the seam for the Room store while
the provider is still the store.

Task.id (the materialised instance row id) is gone; Task carries
occurrenceStart, its RECURRENCE-ID anchor, instead. updateInstance takes
(taskId, occurrenceStart, form) and AndroidTasksDataSource maps that back
to an instance row itself, so the provider path exercises the new
signature before Room exists.

Lazy-list keys move to Task.occurrenceKey. Two occurrences of one series
can appear in the same list once expansion is ours, and taskId alone
would collide there.

domain/Models.kt stops importing TasksContract — status, priority and
local-account constants now live in domain. StorageMode gains OWN as a
third value; LOCAL keeps meaning the dmfs provider until it is deleted.

Room 2.8.4 and room.schemaLocation added to the build.
This commit is contained in:
2026-08-13 15:55:24 +02:00
parent 8c3cbcf928
commit 11b20faf82
16 changed files with 221 additions and 49 deletions

View File

@@ -137,6 +137,13 @@ kotlin {
} }
} }
// Export each Room schema version to app/schemas/ and commit it. That JSON is
// what MigrationTestHelper reads to build an old database and migrate it, so
// without it a migration can only be tested by hand.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies { dependencies {
// Agendula's own task store — the dmfs provider vendored under our authority. // Agendula's own task store — the dmfs provider vendored under our authority.
// Contributes a <provider> to the merged manifest; no app code imports from it // Contributes a <provider> to the merged manifest; no app code imports from it
@@ -162,6 +169,10 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)

View File

@@ -20,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Instant
/** /**
* The only class that knows about the ContentResolver, [TasksContract] and the * The only class that knows about the ContentResolver, [TasksContract] and the
@@ -120,13 +121,39 @@ class AndroidTasksDataSource @Inject constructor(
if (rows == 0) throw TaskWriteFailedException("update task $taskId") if (rows == 0) throw TaskWriteFailedException("update task $taskId")
} }
override fun updateInstance(instanceId: Long, form: TaskForm) { override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val instanceId = instanceIdFor(taskId, occurrenceStart)
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
val uri = TasksContract.instanceUri(authority(), instanceId) val uri = TasksContract.instanceUri(authority(), instanceId)
val rows = resolver.update(uri, values.toContentValues(), null, null) val rows = resolver.update(uri, values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
} }
/**
* The provider's instance row id for one occurrence.
*
* The seam addresses occurrences by `(taskId, occurrenceStart)`; writing
* through the instances URI still needs the row id, so it is looked up here
* rather than carried around above the data layer. Selection is on `task_id`
* only — the anchor is matched in Kotlin because the column that holds it
* (`instance_original_time`) is missing on older provider schemas, where a
* WHERE clause naming it would throw instead of falling back.
*/
private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? {
val uri = TasksContract.instancesUri(authority())
val selection = "${Instances.TASK_ID} = ?"
return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c ->
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) {
return@use reader.getLong(Tasks.ID)
}
}
null
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority()) val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the // Replace rather than update: the provider's AlarmHandler re-validates the

View File

@@ -68,6 +68,9 @@ class ProviderResolver @Inject constructor(
/** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */ /** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */
fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) { fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) {
StorageMode.LOCAL -> own StorageMode.LOCAL -> own
// Room has no authority, no ContentResolver and nothing to permit. The
// provider entry stands in so ProviderStatus stays READY; nothing queries it.
StorageMode.OWN -> own
StorageMode.EXTERNAL -> resolveExternal() StorageMode.EXTERNAL -> resolveExternal()
} }

View File

@@ -22,9 +22,20 @@ enum class StorageMode {
* Agendula's own bundled provider (the `:provider` module). Always available — * Agendula's own bundled provider (the `:provider` module). Always available —
* it ships in the APK — and needs no permission grant at all, because * it ships in the APK — and needs no permission grant at all, because
* same-uid access to your own provider skips the permission check entirely. * same-uid access to your own provider skips the permission check entirely.
*
* On its way out: [OWN] replaces it once the Room store is the default, and
* this constant leaves with the `:provider` module. See `docs/OWN-STORE.md`.
*/ */
LOCAL, LOCAL,
/**
* Agendula's own Room database. Named as a third value rather than renaming
* [LOCAL] because both stores exist at once while the migration runs — a
* rename now would make `OWN` mean the dmfs provider for several phases and
* Room afterwards.
*/
OWN,
/** /**
* A tasks provider app already on the device (OpenTasks, tasks.org), synced by * A tasks provider app already on the device (OpenTasks, tasks.org), synced by
* whatever that provider's engine is — DAVx5 and friends. This is the original * whatever that provider's engine is — DAVx5 and friends. This is the original

View File

@@ -17,10 +17,17 @@ object TaskMapper {
fun instant(name: String): Instant? = fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
val instanceId = r.getLong(Tasks.ID) ?: 0L val rowId = r.getLong(Tasks.ID) ?: 0L
// Derived from the rule columns rather than the `is_recurring` column
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
val recurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING)
return Task( return Task(
id = instanceId, taskId = r.getLong(Instances.TASK_ID) ?: rowId,
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
listId = r.getLong(Tasks.LIST_ID) ?: 0L, listId = r.getLong(Tasks.LIST_ID) ?: 0L,
title = r.getString(Tasks.TITLE).orEmpty(), title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION), description = r.getString(Tasks.DESCRIPTION),
@@ -39,20 +46,30 @@ object TaskMapper {
listName = r.getString(Tasks.LIST_NAME), listName = r.getString(Tasks.LIST_NAME),
accountName = r.getString(Tasks.ACCOUNT_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME),
parentId = r.getLong(Tasks.PARENT_ID), parentId = r.getLong(Tasks.PARENT_ID),
// Derived from the rule columns rather than the `is_recurring` column isRecurring = recurring,
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is occurrenceStart = if (recurring) occurrenceAnchor(r) else null,
// absent on tasks.org's bundled provider (DB 22), where reading it
// would silently report every recurring task as one-off — and route
// its edits onto the series anchor.
isRecurring = r.getString(Tasks.RRULE) != null ||
r.getString(Tasks.RDATE) != null ||
r.getBoolean(Instances.IS_RECURRING),
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
created = instant(Tasks.CREATED), created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED), lastModified = instant(Tasks.LAST_MODIFIED),
) )
} }
/**
* The occurrence's `RECURRENCE-ID` anchor.
*
* `instance_original_time` is the provider's own name for it and is set on
* every occurrence of a recurring task, so it is read first. It is absent on
* older provider schemas, where the fallbacks reconstruct the same value: a
* DTSTART-anchored series instantiates each occurrence at its start, and a
* series carrying only DUE anchors on the due date instead.
*/
fun occurrenceAnchor(r: ColumnReader): Instant? =
(
r.getLong(Instances.INSTANCE_ORIGINAL_TIME)
?: r.getLong(Instances.INSTANCE_START)
?: r.getLong(Instances.INSTANCE_DUE)
)?.let { Instant.fromEpochMilliseconds(it) }
/** /**
* Maps a row of the **`tasks` table** — a master task, not an occurrence. * Maps a row of the **`tasks` table** — a master task, not an occurrence.
* *

View File

@@ -99,6 +99,13 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current" const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring" const val IS_RECURRING = "is_recurring"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
* instantiated at, before any override moved it. Set on every occurrence
* of a recurring task, which is what makes it the occurrence's identity.
*/
const val INSTANCE_ORIGINAL_TIME = "instance_original_time"
} }
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */ /** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlin.time.Instant
/** What to fetch from the provider. Smart-list date logic is applied above this. */ /** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery( data class TaskQuery(
@@ -25,12 +26,17 @@ interface TasksDataSource {
fun updateTask(taskId: Long, form: TaskForm) fun updateTask(taskId: Long, form: TaskForm)
/** /**
* Update a single occurrence of a recurring task, addressed by its *instance* * Update a single occurrence of a recurring task, addressed by the task row and
* row id. The provider forks an override task rather than moving the series * the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store
* anchor — which is what [updateTask] would do, since a recurring task's * forks an override rather than moving the series anchor — which is what
* start/due are read from the instances view. * [updateTask] would do, since a recurring task's start/due are the
* occurrence's resolved times.
*
* Addressing by `(taskId, occurrenceStart)` rather than by a materialised
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/ */
fun updateInstance(instanceId: Long, form: TaskForm) fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
/** /**
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm * Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
* property row. The provider never fires it — [de.jeanlucmakiola.agendula * property row. The provider never fires it — [de.jeanlucmakiola.agendula

View File

@@ -105,11 +105,12 @@ class TasksRepositoryImpl @Inject constructor(
// task's properties onto the new override row, so setting the alarm // task's properties onto the new override row, so setting the alarm
// beforehand is what carries it across. // beforehand is what carries it across.
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue) dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
// A recurring task's start/due come from the instances view, so writing // A recurring task's start/due are one occurrence's resolved times, so
// them back to tasks/<id> would re-anchor the whole series. Going through // writing them back to the task row would re-anchor the whole series.
// the occurrence lets the provider fork an override instead. // Going through the occurrence forks an override instead.
if (current != null && current.isRecurring) { val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart
dataSource.updateInstance(current.id, form) if (occurrence != null) {
dataSource.updateInstance(taskId, occurrence, form)
} else { } else {
dataSource.updateTask(taskId, form) dataSource.updateTask(taskId, form)
} }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.agendula.domain package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */ /** A task list (the `tasklists` table). Lists group under their account. */
@@ -15,7 +14,8 @@ data class TaskList(
val owner: String?, val owner: String?,
) { ) {
/** A device-only list Agendula (or another app) created locally, not synced. */ /** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE val isLocal: Boolean
get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE
} }
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
@@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
enum class Priority { NONE, LOW, MEDIUM, HIGH } enum class Priority { NONE, LOW, MEDIUM, HIGH }
/** /**
* A task occurrence as read from the `instances` view. [id] is the instance row * One occurrence of a task. [taskId] is the underlying task row and the stable
* id; [taskId] is the underlying `tasks._id` and the stable target for edits. * target for edits and navigation; [occurrenceStart] distinguishes occurrences of
* the same series.
*/ */
data class Task( data class Task(
val id: Long,
val taskId: Long, val taskId: Long,
val listId: Long, val listId: Long,
val title: String, val title: String,
@@ -49,12 +49,21 @@ data class Task(
val accountName: String?, val accountName: String?,
val parentId: Long?, val parentId: Long?,
/** /**
* This row carries a recurrence rule, so [id] is one occurrence of a series * This row carries a recurrence rule, so it is one occurrence of a series and
* and [start]/[due] are that occurrence's resolved times — *not* the master's * [start]/[due] are that occurrence's resolved times — *not* the master's
* anchor. Edits must go through the instances URI (see * anchor. Edits go through
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]). * [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which
* forks a `RECURRENCE-ID` override instead of re-anchoring the series.
*/ */
val isRecurring: Boolean, val isRecurring: Boolean,
/**
* This occurrence's `RECURRENCE-ID` anchor — what identifies it within its
* series — or `null` when the task does not recur. Together with [taskId] it
* is a stable, collision-free identity for an occurrence, which is what list
* keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance]
* address it by.
*/
val occurrenceStart: Instant? = null,
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
@@ -71,6 +80,15 @@ data class Task(
val isSubtask: Boolean get() = parentId != null && parentId > 0 val isSubtask: Boolean get() = parentId != null && parentId > 0
/** The task's own colour if set, else the list colour. */ /** The task's own colour if set, else the list colour. */
val effectiveColor: Int get() = taskColor ?: listColor val effectiveColor: Int get() = taskColor ?: listColor
/**
* Stable identity for a lazy-list key. Two occurrences of one series can show
* up in the same list, so [taskId] alone is not unique — and folding
* `(taskId, occurrenceStart)` into a Long could collide, which as a Compose
* key is a visible bug.
*/
val occurrenceKey: String
get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}"
} }
/** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */ /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
@@ -91,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when {
/** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */ /** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */
fun Priority.toICal(): Int = when (this) { fun Priority.toICal(): Int = when (this) {
Priority.NONE -> TasksContract.PRIORITY_NONE Priority.NONE -> PRIORITY_NONE
Priority.HIGH -> 1 Priority.HIGH -> 1
Priority.MEDIUM -> 5 Priority.MEDIUM -> 5
Priority.LOW -> 9 Priority.LOW -> 9
} }
fun statusFromInt(value: Int?): TaskStatus = when (value) { fun statusFromInt(value: Int?): TaskStatus = when (value) {
TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS
TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED ICalStatus.COMPLETED -> TaskStatus.COMPLETED
TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED ICalStatus.CANCELLED -> TaskStatus.CANCELLED
else -> TaskStatus.NEEDS_ACTION else -> TaskStatus.NEEDS_ACTION
} }
fun TaskStatus.toInt(): Int = when (this) { fun TaskStatus.toInt(): Int = when (this) {
TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION
TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS
TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED TaskStatus.COMPLETED -> ICalStatus.COMPLETED
TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED TaskStatus.CANCELLED -> ICalStatus.CANCELLED
} }

View File

@@ -0,0 +1,30 @@
package de.jeanlucmakiola.agendula.domain
/**
* iCalendar `STATUS` values for a `VTODO`, as integers.
*
* These live in `domain` rather than being read out of a provider contract: the
* numbering is Agendula's own storage encoding as much as it is dmfs's, and the
* domain layer must not depend on the data layer to map its own enums.
*/
object ICalStatus {
const val NEEDS_ACTION = 0
const val IN_PROCESS = 1
const val COMPLETED = 2
const val CANCELLED = 3
}
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */
const val PRIORITY_NONE = 0
/** How a device-only list identifies its (non-existent) account. */
object LocalAccount {
/** Shown as the section header above device-only lists. */
const val NAME = "Local"
/** What Agendula's own store reports for a list with no account. */
const val TYPE = "local"
/** What a dmfs-derived provider reports in External mode. */
const val DMFS_TYPE = "org.dmfs.account.LOCAL"
}

View File

@@ -419,7 +419,7 @@ private fun SearchResults(
} }
} else { } else {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task -> items(results, key = { it.occurrenceKey }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
} }
} }

View File

@@ -773,20 +773,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */ /** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow { private sealed interface ListRow {
val key: Long val key: String
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow { data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: Long get() = task.taskId override val key: String get() = task.occurrenceKey
} }
data class Sub(val task: Task) : ListRow { data class Sub(val task: Task) : ListRow {
override val key: Long get() = task.taskId override val key: String get() = task.occurrenceKey
} }
/** The inline "add a subtask" row that closes an expanded group. */ /** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow { data class AddSub(val parent: Task) : ListRow {
// Negative so it never collides with a real (positive) provider task id. // Prefixed so it never collides with the task row it belongs to.
override val key: Long get() = -parent.taskId override val key: String get() = "add-${parent.occurrenceKey}"
} }
} }

View File

@@ -36,7 +36,6 @@ class TaskMapperTest {
val task = TaskMapper.task(reader) val task = TaskMapper.task(reader)
assertThat(task.id).isEqualTo(42L)
assertThat(task.taskId).isEqualTo(7L) assertThat(task.taskId).isEqualTo(7L)
assertThat(task.listId).isEqualTo(3L) assertThat(task.listId).isEqualTo(3L)
assertThat(task.title).isEqualTo("Buy milk") assertThat(task.title).isEqualTo("Buy milk")
@@ -50,6 +49,40 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue() assertThat(task.isSubtask).isTrue()
} }
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
mapOf(
Instances.TASK_ID to 7L,
Instances.INSTANCE_ORIGINAL_TIME to 500L,
Instances.INSTANCE_START to 900L,
),
)
assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L)
assertThat(anchored.occurrenceKey).isEqualTo("7@500")
// Older provider schemas omit it; the occurrence's start reconstructs it.
val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L))
assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L)
// A series carrying only DUE anchors on the due date instead.
val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L))
assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L)
}
@Test
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
)
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("4")
}
@Test @Test
fun `recurrence is detected from rrule when is_recurring is absent`() { fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column; // tasks.org's bundled provider is DB 22 and has no `is_recurring` column;

View File

@@ -17,7 +17,7 @@ class TaskSortingTest {
val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT) val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder() assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder()
} }
@Test @Test
@@ -27,6 +27,6 @@ class TaskSortingTest {
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT) val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder() assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).inOrder()
} }
} }

View File

@@ -11,7 +11,6 @@ fun testTask(
priority: Priority = Priority.NONE, priority: Priority = Priority.NONE,
due: Instant? = null, due: Instant? = null,
): Task = Task( ): Task = Task(
id = id,
taskId = id, taskId = id,
listId = listId, listId = listId,
title = title, title = title,
@@ -32,6 +31,7 @@ fun testTask(
accountName = null, accountName = null,
parentId = null, parentId = null,
isRecurring = false, isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0, distanceFromCurrent = 0,
created = null, created = null,
lastModified = null, lastModified = null,

View File

@@ -13,6 +13,8 @@ composeBom = "2026.05.01"
# Re-evaluate when 1.5.0 stable lands. # Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha21" material3 = "1.5.0-alpha21"
datastore = "1.2.1" datastore = "1.2.1"
# Room — Agendula's own task store (docs/OWN-STORE.md).
room = "2.8.4"
# SAF directory writing for export/backup (DocumentFile). # SAF directory writing for export/backup (DocumentFile).
documentfile = "1.1.0" documentfile = "1.1.0"
junit = "6.1.0" junit = "6.1.0"
@@ -71,6 +73,12 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
# Room — the own-store database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
# DataStore # DataStore
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }