feat(store): implement TasksDataSource over Room

Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource
implements all 14 seam methods; a StorageMode-routing delegate picks it
or the provider per call, since the mode is a setting the user can change
while the process lives.

There is no instances table, so a series is expanded at read time by
RecurrenceExpander and any RECURRENCE-ID override is substituted for the
occurrence it replaces. A timed series carries each occurrence's length
across; a due-anchored one has no start to offset from, so the anchor is
the due date — matching how the provider instantiated the same series.

Editing one occurrence writes a RECURRENCE-ID override sharing the
master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a
brand-new task with its own UID instead — model (d), the one least
compatible with CalDAV. We inherited that without ever choosing it; this
is the choice.

TaskFormWriter states the completion rules directly instead of working
around the provider: progress and status now move together in both
directions, so a task can no longer strand itself "done at 75%".
TaskWriteMapper keeps the workarounds for External mode.

Deletes are hard when the list has no account and tombstones when it
does; master_id cascades, so a deleted series takes its overrides.
This commit is contained in:
2026-08-13 16:16:17 +02:00
parent 829a27da82
commit 2e915da588
9 changed files with 958 additions and 9 deletions

View File

@@ -0,0 +1,286 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/**
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
* forking only exist at this level.
*/
@RunWith(AndroidJUnit4::class)
class RoomTasksDataSourceTest {
private lateinit var db: TasksDatabase
private lateinit var source: RoomTasksDataSource
private var listId = 0L
private val now get() = Clock.System.now()
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TasksDatabase::class.java,
).allowMainThreadQueries().build()
source = RoomTasksDataSource(db)
listId = source.createLocalList("Personal", 0xFF112233.toInt())
}
@After
fun tearDown() = db.close()
private fun form(
title: String = "task",
due: Instant? = null,
percentComplete: Int? = null,
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
/** Turns [taskId] into a weekly series anchored at [anchor]. */
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
val entity = db.tasks().entity(taskId)!!
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
}
@Test
fun createsAndReadsBackALocalList() {
val lists = source.taskLists()
assertThat(lists).hasSize(1)
assertThat(lists.single().name).isEqualTo("Personal")
// No account, so the list still has to report something the lists screen
// can group under.
assertThat(lists.single().isLocal).isTrue()
assertThat(lists.single().accountName).isEqualTo("Local")
}
@Test
fun createsAndReadsBackANonRecurringTask() {
val due = now + 1.days
val id = source.insertTask(form(title = "Buy milk", due = due))
val task = source.task(id)!!
assertThat(task.taskId).isEqualTo(id)
assertThat(task.title).isEqualTo("Buy milk")
assertThat(task.due).isEqualTo(due)
assertThat(task.isRecurring).isFalse()
// A task that does not recur has no occurrence anchor, so it keys and edits
// by task id exactly as it did against the provider.
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("$id")
}
@Test
fun mintsAUidForEveryTask() {
val id = source.insertTask(form())
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
}
@Test
fun expandsARecurringSeriesIntoManyOccurrences() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
// The provider materialised exactly one upcoming occurrence; we expand the
// whole window, so a weekly series yields well over a hundred.
assertThat(occurrences.size).isGreaterThan(100)
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
assertThat(occurrences.all { it.isRecurring }).isTrue()
// Each occurrence keeps the series' length rather than the master's dates.
val first = occurrences.minBy { it.occurrenceStart!! }
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
}
@Test
fun exactlyOneOccurrenceIsTheCurrentOne() {
val id = source.insertTask(form())
makeRecurring(id, now - 30.days)
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
}
@Test
fun editingOneOccurrenceForksARecurrenceIdOverride() {
val anchor = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val override = db.tasks().override(id, target.occurrenceStart)!!
// RFC 5545's model: the override shares its master's UID — that is what
// makes it an override rather than a separate task. The dmfs provider
// detached the occurrence into a new task with its own UID instead.
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
assertThat(override.masterId).isEqualTo(id)
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
assertThat(override.rrule).isNull()
assertThat(override.title).isEqualTo("Water them twice")
}
@Test
fun anOverrideReplacesOnlyItsOwnOccurrence() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val before = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
val target = before.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
val after = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
assertThat(after).hasSize(before.size)
assertThat(after.filter { it.title == "Water them twice" }).hasSize(1)
}
@Test
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
val anchor = now
val id = source.insertTask(form())
makeRecurring(id, anchor)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 2 }
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
}
@Test
fun updatingANonRecurringTaskWritesThroughToItsRow() {
val id = source.insertTask(form(title = "old"))
source.updateTask(id, form(title = "new"))
assertThat(source.task(id)!!.title).isEqualTo("new")
}
@Test
fun completionTogglesTheWholeTriple() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
val done = db.tasks().entity(id)!!
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isNotNull()
source.setCompleted(id, completed = false)
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
}
@Test
fun completedTasksAreExcludedUnlessAskedFor() {
val id = source.insertTask(form())
source.setCompleted(id, completed = true)
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
}
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
source.setAlarm(id, 30)
assertThat(source.alarms()[id]).isEqualTo(30)
source.setAlarm(id, 60)
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.alarms()[id]).isEqualTo(60)
source.setAlarm(id, null)
assertThat(source.alarms()).doesNotContainKey(id)
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setAlarm(id, 30)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form())
val override = db.tasks().override(id, target.occurrenceStart)!!
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
}
@Test
fun deletingATaskInALocalListRemovesItOutright() {
val id = source.insertTask(form())
source.deleteTask(id)
// No account knows about it, so there is nothing to tombstone for.
assertThat(db.tasks().entity(id)).isNull()
}
@Test
fun deletingASeriesTakesItsOverridesWithIt() {
val id = source.insertTask(form())
makeRecurring(id, now)
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
source.deleteTask(id)
assertThat(db.tasks().allOverrides(listId)).isEmpty()
}
@Test
fun subtasksReadBackUnderTheirParent() {
val parent = source.insertTask(form(title = "Prepare invoice"))
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
}
@Test
fun exportReadsMastersNotOccurrences() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
val exported = source.exportTasks(listId)
// One row carrying the rule, not one row per occurrence with the rule lost.
assertThat(exported).hasSize(1)
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
assertThat(exported.single().uid).isNotEmpty()
}
@Test
fun insertingIntoAMissingListFails() {
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
assertThat(thrown).isNotNull()
}
}

View File

@@ -147,7 +147,7 @@ class TasksDatabaseTest {
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id }) assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
.containsExactly(master) .containsExactly(master)
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override) assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
assertThat(tasks.overridesForList(listId).map { it.id }).containsExactly(override) assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id) assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
.isEqualTo(override) .isEqualTo(override)
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master) assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)

View File

@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@@ -12,14 +14,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import javax.inject.Provider
import javax.inject.Singleton import javax.inject.Singleton
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -30,10 +37,6 @@ private val Context.agendulaDataStore: DataStore<Preferences> by preferencesData
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataBindModule { abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
@Binds @Binds
@Singleton @Singleton
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
@@ -52,6 +55,33 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.agendulaDataStore context.agendulaDataStore
@Provides
@Singleton
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
// Room's default, stated rather than assumed: Auto Backup copies files
// without checkpointing, so a `-wal` sidecar can hold writes the
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.build()
/**
* The active store, chosen by [StorageMode].
*
* Resolved per injection point rather than bound once, because the mode is a
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
* can change while the process lives. Both implementations are singletons, so
* this picks between two long-lived objects rather than building either.
*/
@Provides
@Singleton
fun provideTasksDataSource(
resolver: ProviderResolver,
room: Provider<RoomTasksDataSource>,
external: Provider<AndroidTasksDataSource>,
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
@Provides @Provides
@IoDispatcher @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import javax.inject.Provider
import kotlin.time.Instant
/**
* Routes every call to the store [StorageMode] selects.
*
* Per-call rather than bound once: the mode is a setting the user can change
* while the process lives, and [StorageModeHolder] pushes the new value into
* [ProviderResolver] without rebuilding the object graph. Both delegates are
* singletons, so this chooses between two existing objects.
*
* The [StorageMode.LOCAL] branch disappears with the `:provider` module; after
* that this is just Room versus a third-party ContentProvider.
*/
class ModeRoutingTasksDataSource(
private val resolver: ProviderResolver,
private val room: Provider<RoomTasksDataSource>,
private val external: Provider<AndroidTasksDataSource>,
) : TasksDataSource {
private fun active(): TasksDataSource =
when (resolver.storageMode ?: resolver.autoMode()) {
StorageMode.OWN -> room.get()
StorageMode.LOCAL, StorageMode.EXTERNAL -> external.get()
}
override fun taskLists(): List<TaskList> = active().taskLists()
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
active().updateInstance(taskId, occurrenceStart, form)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, Int> = active().alarms()
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
override fun registerObserver(onChange: () -> Unit): AutoCloseable = active().registerObserver(onChange)
}

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.LocalAccount
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
/** Account type reported for a list attached to one of ours. */
const val CALDAV_ACCOUNT_TYPE = "caldav"
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
object RoomTaskMapper {
fun taskList(row: TaskListRow): TaskList = TaskList(
id = row.list.id,
name = row.list.name,
color = row.list.color,
// TaskList.accountName is non-null and the lists screen groups by it, so a
// list with no account still has to report something to group under.
accountName = row.accountDisplayName ?: LocalAccount.NAME,
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
isSynced = row.list.isSynced,
isVisible = row.list.isVisible,
owner = row.list.owner,
)
/**
* One occurrence of [row]. [occurrenceStart] is the occurrence's
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
* [start] / [due] are that occurrence's resolved times.
*/
fun task(
row: TaskRow,
occurrenceStart: Instant? = null,
start: Instant? = row.task.dtstart,
due: Instant? = row.task.due,
distanceFromCurrent: Int? = null,
): Task = Task(
taskId = row.task.id,
listId = row.task.listId,
title = row.task.title.orEmpty(),
description = row.task.description,
location = row.task.location,
url = row.task.url,
priority = priorityFromICal(row.task.priority),
status = row.task.status,
percentComplete = row.task.percentComplete,
start = start,
due = due,
isAllDay = row.task.isAllDay,
timeZone = row.task.timezone,
completedAt = row.task.completedAt,
listColor = row.listColor,
taskColor = row.task.color,
listName = row.listName,
accountName = row.accountDisplayName ?: LocalAccount.NAME,
parentId = row.task.parentId,
isRecurring = row.task.isRecurring,
occurrenceStart = occurrenceStart,
distanceFromCurrent = distanceFromCurrent,
created = row.task.createdAt,
lastModified = row.task.lastModified,
)
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
taskId = task.id,
uid = task.uid,
title = task.title.orEmpty(),
description = task.description,
location = task.location,
url = task.url,
priority = priorityFromICal(task.priority),
status = task.status,
percentComplete = task.percentComplete,
start = task.dtstart,
due = task.due,
isAllDay = task.isAllDay,
completedAt = task.completedAt,
created = task.createdAt,
lastModified = task.lastModified,
rrule = task.rrule,
rdate = task.rdate,
parentId = task.parentId?.takeIf { it > 0 },
)
}
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
val TaskEntity.isRecurring: Boolean
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
* dmfs provider instantiated the same series.
*/
val TaskEntity.recurrenceAnchor: Instant?
get() = dtstart ?: due
/** The rule set of this series, or `null` when it does not recur. */
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
if (!isRecurring) return null
val anchor = recurrenceAnchor ?: return null
return RecurrenceSpec(
rrule = rrule,
rdate = rdate,
exdate = exdate,
anchor = anchor,
isAllDay = isAllDay,
timeZone = timezone,
)
}

View File

@@ -0,0 +1,215 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/** How far either side of now a series is expanded. */
private val WINDOW_BACK = 365.days
private val WINDOW_FORWARD = 730.days
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/**
* [TasksDataSource] over Agendula's own Room store.
*
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
* .AndroidTasksDataSource]: there is no materialised instances table, so a
* recurring series is expanded here, at read time, by [RecurrenceExpander].
* Nothing above this cares — the repository already filters and sorts in Kotlin.
*/
@Singleton
class RoomTasksDataSource @Inject constructor(
private val database: TasksDatabase,
) : TasksDataSource {
private val clock: Clock = Clock.System
private val tasks get() = database.tasks()
private val lists get() = database.taskLists()
private val alarms get() = database.alarms()
// --- reads ----------------------------------------------------------------
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
override fun tasks(query: TaskQuery): List<Task> {
val now = clock.now()
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
return tasks.tasks(query.listId, query.includeCompleted)
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
.filter { query.includeCompleted || !it.isClosed }
}
override fun task(taskId: Long): Task? {
val row = tasks.task(taskId) ?: return null
// An override row is one occurrence in its own right; it names the
// occurrence it replaces rather than expanding to a series.
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
val now = clock.now()
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
}
override fun subtasks(parentTaskId: Long): List<Task> {
val now = clock.now()
return tasks.subtasks(parentTaskId)
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
override fun alarms(): Map<Long, Int> =
alarms.all().associate { it.taskId to it.minutesBefore }
/**
* Every occurrence of [row] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
*
* A non-recurring task is its own single occurrence and carries a null
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(from = now - WINDOW_BACK, until = now + WINDOW_FORWARD)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
// A timed series keeps each occurrence's duration; a due-anchored one has
// no start to offset from, so the anchor *is* the due date.
val length = row.task.dtstart?.let { start -> row.task.due?.let { it - start } }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
when {
override != null -> RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
)
row.task.dtstart != null -> RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = anchor,
due = length?.let { anchor + it },
distanceFromCurrent = distances[index],
)
else -> RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = null,
due = anchor,
distanceFromCurrent = distances[index],
)
}
}
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
return tasks.insert(entity)
}
override fun updateTask(taskId: Long, form: TaskForm) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone()))
}
/**
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
* what every other CalDAV client expects to receive. The dmfs provider
* detached the occurrence into a brand-new task with its own UID instead,
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
if (existing != null) {
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
return
}
val fork = TaskFormWriter.apply(
master.copy(
id = 0,
masterId = taskId,
recurrenceId = occurrenceStart,
rrule = null,
rdate = null,
exdate = null,
href = null,
etag = null,
),
form,
now,
zone(),
)
// The list and parent come from the master: moving one occurrence between
// lists or parents is not something the override model expresses.
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
alarms.replaceForTask(
taskId,
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
)
}
override fun setCompleted(taskId: Long, completed: Boolean) {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
}
/**
* Hard delete for a row no server knows about, tombstone for one that is
* still owed to a collection. `master_id` cascades, so deleting a series
* takes its overrides with it.
*/
override fun deleteTask(taskId: Long) {
val current = tasks.entity(taskId) ?: return
val listAccount = lists.entity(current.listId)?.accountId
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
// --- observation ----------------------------------------------------------
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) = onChange()
}
database.invalidationTracker.addObserver(observer)
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
}
private fun zone(): String = ZoneId.systemDefault().id
}

View File

@@ -12,7 +12,7 @@ import kotlin.time.Instant
* *
* Reads split masters from overrides on purpose: [tasks] returns the rows a * Reads split masters from overrides on purpose: [tasks] returns the rows a
* recurrence expander expands (a non-recurring task is its own single * recurrence expander expands (a non-recurring task is its own single
* occurrence), and [overridesForList] / [overrides] return the * occurrence), and [allOverrides] / [overrides] return the
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here * `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
* expands anything — that is phase 2's job, in Kotlin. * expands anything — that is phase 2's job, in Kotlin.
*/ */
@@ -68,9 +68,19 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE id = :taskId") @Query("SELECT * FROM tasks WHERE id = :taskId")
fun entity(taskId: Long): TaskEntity? fun entity(taskId: Long): TaskEntity?
/** Every override in [listId], for expansion alongside [tasks]. */ /**
@Query("SELECT * FROM tasks WHERE list_id = :listId AND master_id IS NOT NULL AND is_deleted = 0") * Every override, optionally narrowed to one list — read alongside [tasks] so
fun overridesForList(listId: Long): List<TaskEntity> * expansion can replace the occurrences they override in one pass rather than
* querying per series.
*/
@Query(
"""
SELECT * FROM tasks
WHERE master_id IS NOT NULL AND is_deleted = 0
AND (:listId IS NULL OR list_id = :listId)
"""
)
fun allOverrides(listId: Long?): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
fun overrides(masterId: Long): List<TaskEntity> fun overrides(masterId: Long): List<TaskEntity>

View File

@@ -0,0 +1,91 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toICal
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
internal fun Instant.forAllDay(allDay: Boolean): Instant =
if (!allDay) this
else Instant.fromEpochMilliseconds(
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
)
/**
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
* testable on the JVM without a database.
*
* This is the Room counterpart of
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
* External mode. It is a separate object rather than a shared one because most
* of what that mapper does is work around the provider — clearing `DURATION`
* because the provider validates a merged row, writing `STATUS` both ways
* because the provider auto-completes at 100% but will not reopen below it. Here
* those rules are ours to state directly.
*/
object TaskFormWriter {
/** A brand-new task. [uid] is minted by the caller and never null. */
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
apply(
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
form,
now,
tzId,
)
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
val percent = form.percentComplete?.coerceIn(0, 100)
val timed = !form.isAllDay && (form.start != null || form.due != null)
return current.copy(
listId = form.listId,
title = form.title.trim(),
description = form.description?.trim()?.ifBlank { null },
priority = form.priority.toICal(),
percentComplete = percent,
status = statusFor(percent, current.status),
completedAt = completedAtFor(percent, current, now),
dtstart = form.start?.forAllDay(form.isAllDay),
due = form.due?.forAllDay(form.isAllDay),
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
duration = null,
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
lastModified = now,
isDirty = true,
)
}
/** The completion triple, for the standalone complete toggle. */
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
percentComplete = if (completed) 100 else null,
completedAt = if (completed) now else null,
lastModified = now,
isDirty = true,
)
/**
* A form carrying no percent leaves status alone — the standalone toggle stays
* authoritative. Otherwise progress and status move together in both
* directions, which is the asymmetry the provider never had: it auto-completed
* at 100% but would not reopen below it, stranding a task "done at 75%".
*/
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
percent == null -> current
percent >= 100 -> TaskStatus.COMPLETED
percent > 0 -> TaskStatus.IN_PROCESS
else -> TaskStatus.NEEDS_ACTION
}
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
percent == null -> current.completedAt
percent >= 100 -> current.completedAt ?: now
else -> null
}
}

View File

@@ -0,0 +1,152 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000)
private const val ZONE = "Europe/Berlin"
private fun task(
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
completedAt: Instant? = null,
) = TaskEntity(
id = 1,
listId = 1,
uid = "uid-1",
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
)
private fun form(
title: String = "task",
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
) = TaskForm(
title = title,
listId = 1,
percentComplete = percentComplete,
start = start,
due = due,
isAllDay = isAllDay,
)
class TaskFormWriterTest {
@Test
fun `mints the task with its uid and creation time`() {
val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE)
assertThat(entity.uid).isEqualTo("uid-9")
assertThat(entity.title).isEqualTo("Buy milk")
assertThat(entity.createdAt).isEqualTo(NOW)
assertThat(entity.lastModified).isEqualTo(NOW)
assertThat(entity.isDirty).isTrue()
}
@Test
fun `progress and status move together in both directions`() {
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
.isEqualTo(TaskStatus.COMPLETED)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status)
.isEqualTo(TaskStatus.IN_PROCESS)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status)
.isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun `dropping below 100 percent reopens the task`() {
// The provider auto-completed at 100% but would not reopen below it, which
// stranded a task "done at 75%". TaskWriteMapper works around that for
// External mode; on our own store the rule is simply symmetric.
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE)
assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(reopened.completedAt).isNull()
}
@Test
fun `a form with no percent leaves the completion state alone`() {
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE)
assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(saved.completedAt).isEqualTo(NOW)
}
@Test
fun `re-saving a finished task keeps its original completion time`() {
val earlier = Instant.fromEpochMilliseconds(1_000_000)
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE)
assertThat(saved.completedAt).isEqualTo(earlier)
}
@Test
fun `all-day times are pinned to UTC midnight`() {
// Date-only in iCalendar. Storing a local-midnight instant would land on the
// previous day for anyone west of UTC.
val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000)
val saved = TaskFormWriter.apply(
task(),
form(start = midMorning, due = midMorning, isAllDay = true),
NOW,
ZONE,
)
assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.timezone).isNull()
}
@Test
fun `a timed task records the zone, an undated one does not`() {
val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE)
assertThat(timed.timezone).isEqualTo(ZONE)
val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE)
assertThat(undated.timezone).isNull()
}
@Test
fun `writing a due date clears any duration`() {
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
val withDuration = task().copy(duration = "PT1H")
assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull()
}
@Test
fun `the complete toggle sets and clears the whole triple`() {
val done = TaskFormWriter.completed(task(), completed = true, now = NOW)
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isEqualTo(NOW)
val reopened = TaskFormWriter.completed(done, completed = false, now = NOW)
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(reopened.percentComplete).isNull()
assertThat(reopened.completedAt).isNull()
}
@Test
fun `priority is written as the raw iCalendar integer`() {
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority)
.isEqualTo(1)
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority)
.isEqualTo(0)
}
}