diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt new file mode 100644 index 0000000..9731019 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -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() + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt index 17053f9..6095383 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt @@ -147,7 +147,7 @@ class TasksDatabaseTest { assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id }) .containsExactly(master) 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) .isEqualTo(override) assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 8bf97b6..b8f3448 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.room.Room +import androidx.room.RoomDatabase import androidx.datastore.preferences.preferencesDataStore import dagger.Binds import dagger.Module @@ -12,14 +14,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment 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.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksRepository 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import javax.inject.Provider import javax.inject.Singleton private val Context.agendulaDataStore: DataStore by preferencesDataStore( @@ -30,10 +37,6 @@ private val Context.agendulaDataStore: DataStore by preferencesData @InstallIn(SingletonComponent::class) abstract class DataBindModule { - @Binds - @Singleton - abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource - @Binds @Singleton abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository @@ -52,6 +55,33 @@ object DataProvideModule { fun provideDataStore(@ApplicationContext context: Context): DataStore = 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, + external: Provider, + ): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external) + @Provides @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt new file mode 100644 index 0000000..4220dab --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -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, + private val external: Provider, +) : TasksDataSource { + + private fun active(): TasksDataSource = + when (resolver.storageMode ?: resolver.autoMode()) { + StorageMode.OWN -> room.get() + StorageMode.LOCAL, StorageMode.EXTERNAL -> external.get() + } + + override fun taskLists(): List = active().taskLists() + override fun tasks(query: TaskQuery): List = active().tasks(query) + override fun task(taskId: Long): Task? = active().task(taskId) + override fun subtasks(parentTaskId: Long): List = 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 = active().alarms() + override fun exportTasks(listId: Long): List = 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt new file mode 100644 index 0000000..32c24eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt new file mode 100644 index 0000000..d482877 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -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 = lists.lists().map(RoomTaskMapper::taskList) + + override fun tasks(query: TaskQuery): List { + 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 { + val now = clock.now() + return tasks.subtasks(parentTaskId) + .flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) } + } + + override fun exportTasks(listId: Long): List = + tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) + + override fun alarms(): Map = + 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, now: Instant): List { + 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) = onChange() + } + database.invalidationTracker.addObserver(observer) + return AutoCloseable { database.invalidationTracker.removeObserver(observer) } + } + + private fun zone(): String = ZoneId.systemDefault().id +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 82bce92..c5747c5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -12,7 +12,7 @@ import kotlin.time.Instant * * Reads split masters from overrides on purpose: [tasks] returns the rows a * 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 * expands anything — that is phase 2's job, in Kotlin. */ @@ -68,9 +68,19 @@ interface TaskDao { @Query("SELECT * FROM tasks WHERE id = :taskId") 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") - fun overridesForList(listId: Long): List + /** + * Every override, optionally narrowed to one list — read alongside [tasks] so + * 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 @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") fun overrides(masterId: Long): List diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt new file mode 100644 index 0000000..5e81d33 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -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 + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt new file mode 100644 index 0000000..f5c55bd --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -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) + } +}