From 47cf99af323de647adc0eaf270d213ebe2225f1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 22:13:26 +0200 Subject: [PATCH 01/21] fix(data): correct the tasks-provider interaction end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of every path through the OpenTasks/tasks.org ContentProvider, prompted by edited due times reverting. Four independent defects produced that one symptom, plus several unrelated ones alongside. Edits reverting - The edit form was bound from a LaunchedEffect in the nav host while its ViewModel survives on the back stack, and bindEdit replaced state wholesale. MainActivity declares no configChanges, so any Activity recreation (rotation, theme/font/display-size change, split-screen, unfolding) re-fired the effect and overwrote in-progress edits with the stored row. Guarded with a `bound` flag; picker state moved to rememberSaveable so an open picker also survives. All-day handling - All-day items are date-only in iCalendar and belong at UTC midnight with a null tz. The app wrote *local* midnight, so in Berlin an all-day task drifted back a day on every save cycle, corrupting anything synced. Rendering had the mirror bug, so the two cancelled out locally and hid each other. - Toggling the all-day switch flipped the flag but left the timestamp, so an all-day task toggled off read back as 02:00 — another apparent "time reset". - New domain/AllDayTime.kt owns the two conventions and the conversion between them; the picker, the write mapper and the toggle all go through it. Provider write contract - DUE and DURATION are mutually exclusive and the provider validates the merged row, so saving a due date onto a task that carried a duration threw IllegalArgumentException — the save simply failed. DURATION is now cleared alongside every time write. - A recurring task's start/due are read from the instances view, and writing them back to tasks/ re-anchored the whole series. Updates now go through instances/, where the provider forks an override instead. - Recurrence is derived from rrule/rdate rather than the is_recurring column: that column only exists from OpenTasks 1.4.0 (DB 23) and is absent on tasks.org's bundled provider (DB 22), where it would report every recurring task as one-off and send its edits to the anchor. Reminders - The per-task Reminder field in the edit form was inert: never persisted, never read back, and REMINDER_WITHOUT_DUE could block a save over a value that was discarded regardless. Leads are now stored as Alarm property rows and preferred over the per-list/global setting. Written before the task update so a recurrence fork copies them onto the override. Note the provider fires nothing itself — ReminderScheduler still arms the alarm. - Reminders were keyed by task id over rows read from the instances view, so .toMap() collapsed a recurring task to one arbitrary occurrence (the query is unsorted). Now keyed per occurrence, with request codes and intent data to match. Missed reminders within 6h fire once on boot instead of being dropped. Robustness - Four terminal `catch`es killed their upstream on the first provider failure. SettingsViewModel is collected in setContent above the permission gate for the Activity's lifetime, so a pre-grant SecurityException left the list picker empty until the process restarted. Replaced with capped-backoff retry. - lazyChildren had no catch at all; an exception escaped stateIn past viewModelScope's SupervisorJob and crashed the process. - Observer registration is all-or-nothing (the second register throwing leaked the first), ProviderChangeReceiver validates action and authority and debounces, and the permission gate re-checks on resume. Also drops the unused DateTimeField composable and the stale INSTANCES projection, which omitted the recurrence columns the mapper now depends on. Bumps floret-kit to pick up the matching all-day formatting fix. Verified by unit tests (43 app, 15 core-time) and a clean assembleDebug; the provider interaction itself has not been exercised on a device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/reminders/DueReminderReceiver.kt | 12 +- .../data/reminders/ProviderChangeReceiver.kt | 24 +++ .../data/reminders/ReminderScheduler.kt | 60 +++++-- .../data/reminders/ScheduledReminderStore.kt | 29 ++- .../data/tasks/AndroidTasksDataSource.kt | 58 +++++- .../agendula/data/tasks/ProviderFlow.kt | 31 ++++ .../agendula/data/tasks/TaskMapper.kt | 9 +- .../agendula/data/tasks/TaskProjections.kt | 34 +--- .../agendula/data/tasks/TaskWriteMapper.kt | 48 ++++- .../agendula/data/tasks/TasksContract.kt | 56 ++++++ .../agendula/data/tasks/TasksDataSource.kt | 19 ++ .../agendula/data/tasks/TasksRepository.kt | 7 + .../data/tasks/TasksRepositoryImpl.kt | 35 +++- .../agendula/domain/AllDayTime.kt | 42 +++++ .../jeanlucmakiola/agendula/domain/Models.kt | 6 + .../jeanlucmakiola/agendula/ui/RootScreen.kt | 16 ++ .../agendula/ui/common/DateTimeField.kt | 167 ------------------ .../agendula/ui/common/PickerTime.kt | 20 +++ .../agendula/ui/detail/TaskDetailScreen.kt | 13 +- .../agendula/ui/detail/TaskDetailViewModel.kt | 4 +- .../agendula/ui/edit/TaskEditScreen.kt | 20 ++- .../agendula/ui/edit/TaskEditViewModel.kt | 29 ++- .../agendula/ui/lists/ListsViewModel.kt | 4 +- .../agendula/ui/settings/SettingsViewModel.kt | 10 +- .../agendula/ui/tasklist/TaskListViewModel.kt | 10 +- .../agendula/data/tasks/TaskMapperTest.kt | 25 +++ .../data/tasks/TaskWriteMapperTest.kt | 67 +++++++ .../agendula/domain/AllDayTimeTest.kt | 60 +++++++ floret-kit | 2 +- 29 files changed, 657 insertions(+), 260 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt index dba31d7..01aac13 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.core.net.toUri import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource @@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() { companion object { private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" - fun intent(context: Context, taskId: Long): Intent = - Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) + /** + * [triggerAt] rides in the intent *data*, not just an extra: PendingIntent + * identity ignores extras, so two occurrences of the same recurring task + * would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT. + */ + fun intent(context: Context, taskId: Long, triggerAt: Long): Intent = + Intent(context, DueReminderReceiver::class.java) + .setData("agendula://reminder/$taskId/$triggerAt".toUri()) + .putExtra(EXTRA_TASK_ID, taskId) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt index 82c491b..c0c74e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt @@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -20,10 +22,25 @@ import javax.inject.Inject class ProviderChangeReceiver : BroadcastReceiver() { @Inject lateinit var scheduler: ReminderScheduler + @Inject lateinit var providerResolver: ProviderResolver private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onReceive(context: Context, intent: Intent) { + // The receiver has to stay exported to hear the provider's broadcast, and + // the sender holds no permission we could require — so validate the + // broadcast itself. Without this, any installed app can spam a full + // re-sync (an unbounded provider read) by firing a matching intent. + if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return + val authority = providerResolver.resolve()?.authority ?: return + if (intent.data?.host != authority) return + // External sync can fire these in bursts; one re-sync per burst is plenty. + val now = SystemClock.elapsedRealtime() + synchronized(Companion) { + if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return + lastSyncAt = now + } + val pending = goAsync() scope.launch { try { @@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() { } } } + + private companion object { + const val MIN_SYNC_INTERVAL_MS = 10_000L + + @Volatile + var lastSyncAt = -MIN_SYNC_INTERVAL_MS + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 4cea316..8f1cb58 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -43,35 +43,57 @@ class ReminderScheduler @Inject constructor( val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } .getOrElse { return@withContext } + // One reminder per *occurrence*: the instances view yields a row per + // occurrence, all sharing a taskId, so this is a Set rather than a + // taskId-keyed Map — keying by task would collapse a daily recurring task + // down to one arbitrary reminder (the query is unsorted, so which one + // survived was provider-defined). + // Per-task leads, stored as Alarm property rows. One query for all of them. + val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() } + val desired = tasks .filter { !it.isClosed && it.due != null } .mapNotNull { task -> - // The task's list may override the global lead, or opt out entirely - // (override = null), in which case it gets no reminder at all. - val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null - task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L) + // A reminder set on the task itself wins; otherwise the task's list + // may override the global lead, or opt out entirely (override = + // null), in which case it gets no reminder at all. + val lead = perTask[task.taskId] + ?: settings.reminderLeadFor(task.listId) + ?: return@mapNotNull null + ScheduledReminder( + taskId = task.taskId, + triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, + ) } - .toMap() - .filterValues { it in now..horizon } + // The lower bound trails `now` so a reminder missed while the device was + // off still fires once on boot instead of being silently dropped — + // setExactAndAllowWhileIdle delivers a past trigger immediately. Anything + // already armed stays armed (the diff below), so it can't re-fire. + .filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon } + .toSet() val previous = store.all() - (previous.keys - desired.keys).forEach { cancel(it) } - desired.forEach { (taskId, triggerAt) -> - if (previous[taskId] != triggerAt) schedule(taskId, triggerAt) - } + (previous - desired).forEach { cancel(it) } + (desired - previous).forEach { schedule(it) } store.replace(desired) } private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java) - private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? { + private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? { val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags) + return PendingIntent.getBroadcast( + context, + reminder.requestCode, + DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt), + flags, + ) } - private fun schedule(taskId: Long, triggerAt: Long) { - val pi = pendingIntent(taskId, create = true) ?: return + private fun schedule(reminder: ScheduledReminder) { + val triggerAt = reminder.triggerAt + val pi = pendingIntent(reminder, create = true) ?: return val am = alarmManager() val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms() if (canExact) { @@ -81,19 +103,21 @@ class ReminderScheduler @Inject constructor( } } - private fun cancel(taskId: Long) { - pendingIntent(taskId, create = false)?.let { + private fun cancel(reminder: ScheduledReminder) { + pendingIntent(reminder, create = false)?.let { alarmManager().cancel(it) it.cancel() } } private suspend fun clearAll() { - store.all().keys.forEach { cancel(it) } - store.replace(emptyMap()) + store.all().forEach { cancel(it) } + store.replace(emptySet()) } private companion object { const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days + /** How long after its trigger a missed reminder is still worth firing. */ + const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt index 1331b0a..a802258 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt @@ -9,25 +9,38 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Remembers which task reminders are currently scheduled (taskId → trigger time), - * so [ReminderScheduler] can diff against a fresh computation and cancel only the - * alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings. + * One armed alarm. A recurring task has many occurrences sharing a [taskId], so + * the trigger time is part of the identity — keying by task alone would collapse + * a daily task down to a single reminder. + */ +data class ScheduledReminder(val taskId: Long, val triggerAt: Long) { + /** + * Request code for this alarm's PendingIntent. Derived from both fields so + * sibling occurrences don't share (and overwrite) one alarm slot. + */ + val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode() +} + +/** + * Remembers which task reminders are currently armed, so [ReminderScheduler] can + * diff against a fresh computation and touch only the alarms that changed. + * Persisted in DataStore as a set of `taskId|trigger` strings. */ @Singleton class ScheduledReminderStore @Inject constructor( private val dataStore: DataStore, ) { - suspend fun all(): Map = + suspend fun all(): Set = dataStore.data.first()[KEY].orEmpty().mapNotNull { entry -> val parts = entry.split('|') val id = parts.getOrNull(0)?.toLongOrNull() val at = parts.getOrNull(1)?.toLongOrNull() - if (id != null && at != null) id to at else null - }.toMap() + if (id != null && at != null) ScheduledReminder(id, at) else null + }.toSet() - suspend fun replace(scheduled: Map) { + suspend fun replace(scheduled: Set) { dataStore.edit { prefs -> - prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet() + prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet() } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 1ddbfa6..7487a95 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -11,6 +11,7 @@ import android.os.Looper import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm @@ -98,6 +99,50 @@ class AndroidTasksDataSource @Inject constructor( if (rows == 0) throw TaskWriteFailedException("update task $taskId") } + override fun updateInstance(instanceId: Long, form: TaskForm) { + val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + } + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { + val uri = TasksContract.propertiesUri(authority()) + // Replace rather than update: the provider's AlarmHandler re-validates the + // whole row on every update, so a partial edit throws — and delete+insert + // means we never have to track property_id. + resolver.delete( + uri, + "${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?", + arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE), + ) + if (minutesBeforeDue != null) { + resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues()) + ?: throw TaskWriteFailedException("set alarm for task $taskId") + } + } + + override fun alarms(): Map { + val uri = TasksContract.propertiesUri(authority()) + val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE) + return resolver.query( + uri, + projection, + "${Properties.MIMETYPE} = ?", + arrayOf(TasksContract.Alarm.MIMETYPE), + null, + )?.use { c -> + val reader = CursorColumnReader(c) + buildMap { + while (c.moveToNext()) { + val id = reader.getLong(Properties.TASK_ID) + val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE) + if (id != null && minutes != null) put(id, minutes) + } + } + } ?: emptyMap() + } + override fun setCompleted(taskId: Long, completed: Boolean) { val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) @@ -127,9 +172,16 @@ class AndroidTasksDataSource @Inject constructor( val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) = onChange() } - resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) - resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) - return AutoCloseable { resolver.unregisterContentObserver(observer) } + // Register both or neither: if the second call throws, the first + // registration would otherwise leak (no AutoCloseable was handed back yet). + try { + resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) + resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) + } catch (e: RuntimeException) { + runCatching { resolver.unregisterContentObserver(observer) } + throw e + } + return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } } } private fun Map.toContentValues(): ContentValues { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt new file mode 100644 index 0000000..472df64 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.retryWhen + +private const val BASE_RETRY_MS = 1_000L +private const val MAX_RETRY_MS = 30_000L + +/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */ +private fun retryDelayMs(attempt: Long): Long = + (BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS) + +/** + * Recover a provider-backed flow without killing it. + * + * Provider reads fail for reasons that resolve on their own: the read permission + * isn't granted yet (first launch collects before the permission gate), or the + * provider app is mid-update. A terminal `catch` swallows the failure *and* + * cancels the upstream, so the flow never produces again — the screen stays empty + * until the process restarts, even after the user grants the permission. + * + * This emits [fallback] instead and keeps retrying with a capped backoff, so the + * collector recovers on its own once the provider becomes readable. + */ +fun Flow.recoveringFromProviderFailure(fallback: () -> T): Flow = + retryWhen { _, attempt -> + emit(fallback()) + delay(retryDelayMs(attempt)) + true + } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index c8b36ab..11273e8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -38,7 +38,14 @@ object TaskMapper { listName = r.getString(Tasks.LIST_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME), parentId = r.getLong(Tasks.PARENT_ID), - isRecurring = r.getBoolean(Instances.IS_RECURRING), + // 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. + isRecurring = r.getString(Tasks.RRULE) != null || + r.getString(Tasks.RDATE) != null || + r.getBoolean(Instances.IS_RECURRING), distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), created = instant(Tasks.CREATED), lastModified = instant(Tasks.LAST_MODIFIED), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt index 6b6fa45..b7464bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt @@ -1,8 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks /** Column lists requested from the provider. Order is irrelevant; we read by name. */ object TaskProjections { @@ -18,31 +16,9 @@ object TaskProjections { Lists.ACCOUNT_TYPE, ) - /** Read from the `instances` view (inherits all task columns). */ - val INSTANCES: Array = arrayOf( - Tasks.ID, - Instances.TASK_ID, - Tasks.LIST_ID, - Tasks.TITLE, - Tasks.DESCRIPTION, - Tasks.LOCATION, - Tasks.URL, - Tasks.PRIORITY, - Tasks.STATUS, - Tasks.PERCENT_COMPLETE, - Tasks.COMPLETED, - Tasks.IS_ALLDAY, - Tasks.TZ, - Instances.INSTANCE_START, - Instances.INSTANCE_DUE, - Tasks.TASK_COLOR, - Tasks.LIST_COLOR, - Tasks.LIST_NAME, - Tasks.ACCOUNT_NAME, - Tasks.PARENT_ID, - Instances.IS_RECURRING, - Instances.DISTANCE_FROM_CURRENT, - Tasks.CREATED, - Tasks.LAST_MODIFIED, - ) + // No `instances` projection on purpose: that read passes `projection = null` + // (all columns), because the view's shape differs across provider versions — + // tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list + // here would drift out of sync with the by-name mapper and quietly drop + // columns it depends on. See AndroidTasksDataSource.queryInstances. } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 601ac24..494e78d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -1,9 +1,21 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.TaskForm 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. */ +private fun Instant.forAllDay(allDay: Boolean): Instant = + if (!allDay) this + else Instant.fromEpochMilliseconds( + Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY, + ) /** * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), @@ -39,8 +51,17 @@ object TaskWriteMapper { } } put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) - put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) - put(Tasks.DUE, form.due?.toEpochMilliseconds()) + // All-day tasks are date-only in iCalendar. The provider reads them back + // through DateTime.toAllDay(), which drops the time-of-day and resolves the + // remaining date against UTC — so a local-midnight instant lands on the + // previous day for anyone west of UTC. Pin all-day values to UTC midnight. + put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + // DUE and DURATION are mutually exclusive. The provider's Validating + // processor evaluates the *merged* row (supplied values over the stored + // ones), so writing DUE onto a task that already carries a DURATION throws + // "Only one of DUE or DURATION must be supplied." Clear it alongside. + put(Tasks.DURATION, null) put(Tasks.PARENT_ID, form.parentId) // The provider treats a null tz as local time; set it explicitly for // timed tasks so the stored instant is unambiguous across zones. @@ -48,6 +69,16 @@ object TaskWriteMapper { put(Tasks.TZ, if (timed) tzId else null) } + /** + * Values for an update through the *instances* URI (a recurring occurrence). + * The provider clones the row into an override and strips list/recurrence + * fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than + * written and silently ignored — moving one occurrence between lists or + * parents isn't a thing the override model expresses. + */ + fun instanceValues(form: TaskForm, tzId: String): Map = + taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID + fun completionValues(completed: Boolean, nowMillis: Long): Map = if (completed) { mapOf( @@ -63,6 +94,19 @@ object TaskWriteMapper { ) } + /** + * A reminder for [taskId], as an Alarm property row. The provider's validator + * requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every + * write, so all three are always present. + */ + fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map = mapOf( + Properties.TASK_ID to taskId, + Properties.MIMETYPE to Alarm.MIMETYPE, + Alarm.MINUTES_BEFORE to minutesBeforeDue, + Alarm.REFERENCE to Alarm.REFERENCE_DUE, + Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, + ) + fun localListValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), Lists.COLOR to color, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index a400d59..9a25f46 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -66,6 +66,9 @@ object TasksContract { const val IS_ALLDAY = "is_allday" const val TZ = "tz" const val RRULE = "rrule" + const val RDATE = "rdate" + /** Set on an override row — the master occurrence this one replaces. */ + const val ORIGINAL_INSTANCE_ID = "original_instance_id" const val PARENT_ID = "parent_id" const val SORTING = "sorting" const val CREATED = "created" @@ -98,6 +101,50 @@ object TasksContract { const val IS_RECURRING = "is_recurring" } + /** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */ + object Properties { + const val PATH = "properties" + const val PROPERTY_ID = "property_id" + const val TASK_ID = "task_id" + const val MIMETYPE = "mimetype" + } + + /** + * An alarm property row — a per-task reminder lead. + * + * Storage and sync format *only*: the provider fires nothing (its alarm + * scheduling is commented out and the internal `alarms` table is never + * populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler] + * still arms the real AlarmManager alarm. Writing it here is what makes the + * lead survive a sync and show up in other OpenTasks clients. + * + * The columns are the generic `dataN` slots; the meanings below are the + * Alarm property's contract for them. + */ + object Alarm { + const val MIMETYPE = "vnd.android.cursor.item/alarm" + + /** `data0` — minutes from the reference date; positive means *before* it. */ + const val MINUTES_BEFORE = "data0" + + /** `data1` — which date to count from. */ + const val REFERENCE = "data1" + + /** `data2` — optional message shown with the alarm. */ + const val MESSAGE = "data2" + + /** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */ + const val ALARM_TYPE = "data3" + + const val REFERENCE_DUE = 1 + const val REFERENCE_START = 2 + + /** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */ + const val TYPE_MESSAGE = 1 + } + + fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}") + // --- status values (TaskColumns.STATUS_*) -------------------------------- const val STATUS_NEEDS_ACTION = 0 const val STATUS_IN_PROCESS = 1 @@ -112,6 +159,15 @@ object TasksContract { fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") + /** + * A single occurrence. Updating through this URI is how a *recurring* task is + * edited: the provider clones the row into an override task + * (`original_instance_id` set, recurrence fields stripped) instead of moving + * the series anchor, which is what writing to `tasks/` would do. + */ + fun instanceUri(authority: String, instanceId: Long): Uri = + Uri.parse("content://$authority/${Instances.PATH}/$instanceId") + /** Append the sync-adapter params required to write local-account rows. */ fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = uri.buildUpon() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index e73d7ee..6054f09 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -23,6 +23,25 @@ interface TasksDataSource { fun insertTask(form: TaskForm): Long fun updateTask(taskId: Long, form: TaskForm) + + /** + * Update a single occurrence of a recurring task, addressed by its *instance* + * row id. The provider forks an override task rather than moving the series + * anchor — which is what [updateTask] would do, since a recurring task's + * start/due are read from the instances view. + */ + fun updateInstance(instanceId: Long, form: TaskForm) + /** + * Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm + * property row. The provider never fires it — [de.jeanlucmakiola.agendula + * .data.reminders.ReminderScheduler] does — but persisting it here is what + * syncs the lead and shares it with other OpenTasks clients. + */ + fun setAlarm(taskId: Long, minutesBeforeDue: Int?) + + /** Every task's reminder lead, by task id. One query, for the scheduler. */ + fun alarms(): Map + fun setCompleted(taskId: Long, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index 10efd6f..3333e6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -39,6 +39,13 @@ interface TasksRepository { suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun setCompleted(taskId: Long, completed: Boolean) suspend fun deleteTask(taskId: Long) + + /** + * The per-task reminder lead in minutes before due, or `null` if the task has + * none (in which case the list's / global setting applies). Read when the edit + * form loads so saving can't silently drop it. + */ + suspend fun reminderFor(taskId: Long): Int? suspend fun createLocalList(name: String, color: Int): Long /** Synchronous snapshot for the permission/onboarding gate. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index d137d1e..8ac224e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -80,18 +80,39 @@ class TasksRepositoryImpl @Inject constructor( } override suspend fun createTask(form: TaskForm): Long = - withContext(io) { dataSource.insertTask(form) } + withContext(io) { + val id = dataSource.insertTask(form) + form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) } + id + } + + override suspend fun reminderFor(taskId: Long): Int? = + withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() } override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = withContext(io) { - // Conflict-safe overwrite: re-read just before writing and bail if the - // provider's last_modified moved since the form captured it (external - // sync / another app). A null baseline means "force / overwrite anyway". + // Re-read just before writing: it settles the conflict check *and* tells + // us which URI to write through. + val current = dataSource.task(taskId) + // Conflict-safe overwrite: bail if the provider's last_modified moved + // since the form captured it (external sync / another app). A null + // baseline means "force / overwrite anyway". if (expectedLastModified != null) { - val current = dataSource.task(taskId)?.lastModified - if (current != null && current != expectedLastModified) throw TaskConflictException(taskId) + val seen = current?.lastModified + if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) + } + // Write the reminder first: forking a recurring occurrence copies the + // task's properties onto the new override row, so setting the alarm + // beforehand is what carries it across. + dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue) + // A recurring task's start/due come from the instances view, so writing + // them back to tasks/ would re-anchor the whole series. Going through + // the occurrence lets the provider fork an override instead. + if (current != null && current.isRecurring) { + dataSource.updateInstance(current.id, form) + } else { + dataSource.updateTask(taskId, form) } - dataSource.updateTask(taskId, form) } override suspend fun setCompleted(taskId: Long, completed: Boolean) = diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt new file mode 100644 index 0000000..e86fca0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.domain + +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.time.Instant + +/** + * All-day tasks are date-only in iCalendar. OpenTasks reads them back through + * `DateTime.toAllDay()`, which discards the time-of-day and resolves the + * remaining date against UTC — so the storage convention is **UTC midnight of + * the intended calendar date, with a null timezone**. Timed tasks, by contrast, + * are ordinary instants rendered in the device's zone. + * + * These two conventions disagree about which day a given instant is, which is + * why every all-day value needs an explicit conversion rather than a raw + * `Instant` passed straight through. + */ + +/** UTC midnight of [date] — the storage form for an all-day value. */ +fun allDayInstantOf(date: java.time.LocalDate): Instant = + Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + +/** + * The calendar date this instant denotes: read in UTC for [allDay] values, + * in [zone] for timed ones. + */ +fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()) + .atZone(if (allDay) ZoneOffset.UTC else zone) + .toLocalDate() + +/** + * Move an instant across the two conventions when the all-day switch flips, so + * the day the user is looking at stays put. Without this, toggling all-day off + * turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00, + * in New York) — reading to the user as "the time reset itself". + */ +fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant = + if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone)) + else Instant.fromEpochMilliseconds( + calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(), + ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 47e8e68..e19e9cc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -48,6 +48,12 @@ data class Task( val listName: String?, val accountName: String?, val parentId: Long?, + /** + * This row carries a recurrence rule, so [id] is one occurrence of a series + * and [start]/[due] are that occurrence's resolved times — *not* the master's + * anchor. Edits must go through the instances URI (see + * [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]). + */ val isRecurring: Boolean, val distanceFromCurrent: Int?, val created: Instant?, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index e481bd7..fc41b08 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -11,12 +11,16 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus @@ -39,6 +43,18 @@ fun RootScreen( ActivityResultContracts.RequestMultiplePermissions(), ) { permissionViewModel.refresh() } + // Re-check on every resume, not just after the in-app request: the user may + // have granted the permission (or installed a provider) in system Settings and + // come back, and otherwise the gate would hold until the process restarts. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt deleted file mode 100644 index 493f41f..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt +++ /dev/null @@ -1,167 +0,0 @@ -package de.jeanlucmakiola.agendula.ui.common - -import de.jeanlucmakiola.floret.time.formatDateTime -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Event -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import de.jeanlucmakiola.agendula.R -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZoneOffset -import kotlin.time.Instant - -private val zone: ZoneId get() = ZoneId.systemDefault() - -internal fun Instant.toLocalDate(): LocalDate = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() - -internal fun Instant.toLocalTime(): LocalTime = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() - -internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = - Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) - -/** - * A labelled date(-time) field for the edit form: a tonal row showing the - * current value (or nothing), tappable to pick a date and — unless [allDay] — - * a time. A clear affordance appears once a value is set. Emits `null` when - * cleared. Styled to match the app's rounded tonal family. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DateTimeField( - label: String, - value: Instant?, - allDay: Boolean, - onChange: (Instant?) -> Unit, - modifier: Modifier = Modifier, -) { - var showDatePicker by remember { mutableStateOf(false) } - var showTimePicker by remember { mutableStateOf(false) } - var pendingDate by remember { mutableStateOf(null) } - - Surface( - onClick = { showDatePicker = true }, - shape = RoundedCornerShape(22.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set), - style = MaterialTheme.typography.bodyLarge, - ) - } - if (value != null) { - IconButton(onClick = { onChange(null) }) { - Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) - } - } - } - } - - if (showDatePicker) { - val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - showDatePicker = false - val millis = dateState.selectedDateMillis ?: return@TextButton - val date = java.time.Instant.ofEpochMilli(millis) - .atZone(ZoneOffset.UTC).toLocalDate() - if (allDay) { - onChange(localToInstant(date, LocalTime.MIDNIGHT)) - } else { - pendingDate = date - showTimePicker = true - } - }, - ) { Text(stringResource(android.R.string.ok)) } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) { DatePicker(state = dateState) } - } - - if (showTimePicker) { - val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()) - val timeState = rememberTimePickerState( - initialHour = base.toLocalTime().hour, - initialMinute = base.toLocalTime().minute, - ) - Dialog(onDismissRequest = { showTimePicker = false }) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TimePicker(state = timeState) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { showTimePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - TextButton(onClick = { - showTimePicker = false - val date = pendingDate ?: return@TextButton - onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute))) - }) { Text(stringResource(android.R.string.ok)) } - } - } - } - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt new file mode 100644 index 0000000..be77de3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.ui.common + +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlin.time.Instant + +/** + * Zone helpers shared by the date/time pickers. All-day conversions live in + * [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case, + * where the device zone is the right frame of reference. + */ + +private val zone: ZoneId get() = ZoneId.systemDefault() + +internal fun Instant.toLocalTime(): LocalTime = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() + +internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = + Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index f1ae23a..15b37a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -501,13 +501,16 @@ private fun taskWhenLines(task: Task): Pair? { val due = task.due return when { start != null && due != null -> { - val sameDay = start.formatDate() == due.formatDate() - val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}" - val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}" + val allDay = task.isAllDay + val sameDay = start.formatDate(allDay) == due.formatDate(allDay) + val primary = + if (sameDay) due.formatDate(allDay) + else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}" + val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}" primary to secondary } - due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime() - start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime() + due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime() + start != null -> start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime() else -> null } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index 2e48a3a..98db1a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskForm @@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -42,7 +42,7 @@ class TaskDetailViewModel @Inject constructor( if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) } .onStart { emit(TaskDetailUiState.Loading) } - .catch { emit(TaskDetailUiState.NotFound) } + .recoveringFromProviderFailure { TaskDetailUiState.NotFound } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index 5d09499..570ca19 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.agendula.ui.common.toLocalDate +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import java.time.LocalTime @@ -178,7 +179,7 @@ private fun EditContent( val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp - var pickerTarget by remember { mutableStateOf(null) } + var pickerTarget by rememberSaveable { mutableStateOf(null) } var showListPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } @@ -653,7 +654,7 @@ private fun ScheduleRow( ) } else { Text( - text = value.formatDate(), + text = value.formatDate(allDay), style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), @@ -689,12 +690,15 @@ private fun DateTimePickerFlow( onResult: (Instant) -> Unit, onDismiss: () -> Unit, ) { - var pendingDate by remember { mutableStateOf(null) } - var showTime by remember { mutableStateOf(false) } + var pendingDate by rememberSaveable { mutableStateOf(null) } + var showTime by rememberSaveable { mutableStateOf(false) } if (!showTime) { + // M3's DatePicker speaks UTC millis. An all-day value is already UTC-based, + // a timed one is read in the device zone — calendarDate picks the right frame + // so the dialog opens on the day the rest of the UI shows. val initialMillis = (initial ?: nowInstant()) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + .calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) DatePickerDialog( onDismissRequest = onDismiss, @@ -703,7 +707,7 @@ private fun DateTimePickerFlow( val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() if (allDay) { - onResult(localToInstant(date, LocalTime.MIDNIGHT)) + onResult(allDayInstantOf(date)) } else { pendingDate = date showTime = true @@ -865,7 +869,7 @@ private fun ParentPickerSheet( GroupedRow( title = task.title.ifBlank { stringResource(R.string.task_untitled) }, position = positionOf(index, section.tasks.size), - summary = task.due?.formatDate(), + summary = task.due?.formatDate(task.isAllDay), selected = task.taskId == selectedId, minHeight = 56.dp, onClick = { choose(task.taskId) }, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index d25d0e5..03d5e63 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.populatedFields +import de.jeanlucmakiola.agendula.domain.rebasedForAllDay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor( private var editingTaskId: Long? = null + /** + * Whether the form has already been populated. The host `LaunchedEffect` + * re-fires whenever the composition restarts — an Activity recreation + * (rotation, theme/font/display-size change, split-screen, unfolding) — while + * this ViewModel survives on the nav back stack. Without this guard the + * rebind would overwrite in-progress edits with the untouched provider row. + */ + private var bound = false + /** `last_modified` captured when the form loaded — the conflict-check baseline. */ private var baselineLastModified: Instant? = null @@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor( /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew(presetListId: Long? = null, parentId: Long? = null) { + if (bound) return + bound = true editingTaskId = null baselineLastModified = null viewModelScope.launch { @@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor( /** Load an existing task for editing. */ fun bindEdit(taskId: Long) { + if (bound && editingTaskId == taskId) return + bound = true editingTaskId = taskId viewModelScope.launch { defaultFields = settingsPrefs.settings.first().defaultEditFields @@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor( priority = task.priority, parentId = task.parentId, percentComplete = task.percentComplete, + reminderMinutesBeforeDue = repository.reminderFor(taskId), lists = lists, parentCandidates = loadParents(task.listId, selfId = taskId), ), @@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor( fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) } - fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } + /** + * All-day and timed values use different conventions (UTC midnight vs. a real + * instant in the device zone), so the switch has to move the timestamps too — + * flipping the flag alone makes an all-day task read back as "02:00", which + * looks to the user like the time reset itself. + */ + fun onAllDayChange(value: Boolean) = update { + it.copy( + isAllDay = value, + start = it.start?.rebasedForAllDay(value), + due = it.due?.rebasedForAllDay(value), + ) + } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index a6d5071..d6b8aeb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task @@ -12,7 +13,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import java.time.ZoneId @@ -56,7 +56,7 @@ class ListsViewModel @Inject constructor( repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), ) { lists, openTasks, completedTasks -> buildContent(lists, openTasks, completedTasks) as ListsUiState - }.catch { emit(ListsUiState.Failure) } + }.recoveringFromProviderFailure { ListsUiState.Failure } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) private fun buildContent( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index aa2fb24..e8d0614 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -7,12 +7,12 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.floret.reminders.ReminderOverride import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -33,8 +33,14 @@ class SettingsViewModel @Inject constructor( repository: TasksRepository, ) : ViewModel() { + // MainActivity collects this for the theme, above the permission gate and for + // the whole Activity lifetime — so the list flow must survive the pre-grant + // SecurityException and recover once permission is given, not die for good. val state: StateFlow = - combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists -> + combine( + prefs.settings, + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + ) { settings, lists -> SettingsUiState(settings, lists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 3120e14..4287b7e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm @@ -12,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -87,7 +87,10 @@ class TaskListViewModel @Inject constructor( } } .onStart { emit(TaskListUiState.Loading) } - .catch { emit(TaskListUiState.Failure) } + // Recover rather than terminate: a provider hiccup (mid-update, + // permission not yet granted) shows Failure but keeps retrying, + // so the screen heals itself instead of staying stuck. + .recoveringFromProviderFailure { TaskListUiState.Failure } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) @@ -112,6 +115,9 @@ class TaskListViewModel @Inject constructor( combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } } } + // Without this an exception here escapes stateIn's coroutine, past + // viewModelScope's SupervisorJob, and crashes the process. + .recoveringFromProviderFailure { emptyMap() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) /** The screen reports which expanded parents need their children fetched. */ diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt index 7d70aa9..1876328 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt @@ -50,6 +50,31 @@ class TaskMapperTest { assertThat(task.isSubtask).isTrue() } + @Test + 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; + // reading it alone would report the series as one-off and send its edits + // to the master row, re-anchoring the whole thing. + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `recurrence is detected from rdate alone`() { + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `a plain task is not recurring`() { + val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off"))) + assertThat(task.isRecurring).isFalse() + } + @Test fun `falls back to instance id when task_id missing, and list color when no task color`() { val task = TaskMapper.task( diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index faa5034..4ec590e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -85,6 +85,57 @@ class TaskWriteMapperTest { assertThat(values[Tasks.TZ]).isNull() } + @Test + fun `all-day timestamps are pinned to UTC midnight`() { + // 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2). + // The provider resolves all-day dates against UTC, so storing this as-is + // would land the task on the 20th for anyone reading it back. + val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true), + tzId = "Europe/Berlin", + ) + + val dayMs = 24L * 60 * 60 * 1000 + assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L) + assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L) + } + + @Test + fun `timed timestamps are written untouched`() { + val at = Instant.fromEpochMilliseconds(1_784_412_345_678L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Standup", listId = 1L, start = at, due = at), + tzId = "Europe/Berlin", + ) + assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L) + assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L) + } + + @Test + fun `duration is always cleared so it cannot collide with due`() { + // The provider validates the *merged* row and throws "Only one of DUE or + // DURATION must be supplied" if the stored row still carries a duration. + val values = TaskWriteMapper.taskValues( + TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)), + tzId = "UTC", + ) + assertThat(values.containsKey(Tasks.DURATION)).isTrue() + assertThat(values[Tasks.DURATION]).isNull() + } + + @Test + fun `instance values drop list and parent, which an override cannot express`() { + val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L)) + val values = TaskWriteMapper.instanceValues(form, tzId = "UTC") + + assertThat(values.containsKey(Tasks.LIST_ID)).isFalse() + assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse() + // …but still carries the edit itself. + assertThat(values[Tasks.TITLE]).isEqualTo("x") + assertThat(values[Tasks.DUE]).isEqualTo(1_000L) + } + @Test fun `completion sets status, percent and timestamp, un-completion clears them`() { val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) @@ -97,6 +148,22 @@ class TaskWriteMapperTest { assertThat(undone[Tasks.COMPLETED]).isNull() } + @Test + fun `alarm carries every column the provider's validator demands`() { + val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) + + assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L) + assertThat(values[TasksContract.Properties.MIMETYPE]) + .isEqualTo("vnd.android.cursor.item/alarm") + assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30) + // REFERENCE must be present and non-negative, ALARM_TYPE present and + // non-zero (0 is excluded from the provider's has_alarms count). + assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE) + assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE) + // property_id must be absent or the insert is rejected. + assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse() + } + @Test fun `local list uses the LOCAL account`() { val values = TaskWriteMapper.localListValues("Inbox", 0x123) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt new file mode 100644 index 0000000..957192f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Instant + +class AllDayTimeTest { + + private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July + private val newYork = ZoneId.of("America/New_York") // UTC-4 in July + private val julyTwentieth = LocalDate.of(2026, 7, 20) + + @Test + fun `an all-day instant is UTC midnight of its date`() { + val instant = allDayInstantOf(julyTwentieth) + assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L) + assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `an all-day date reads the same everywhere, unlike a timed one`() { + val allDay = allDayInstantOf(julyTwentieth) + // The whole point: zone must not change which day an all-day value denotes. + assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth) + assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth) + // Read as a timed value in New York it would slip to the 19th — the bug. + assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1)) + } + + @Test + fun `toggling all-day off keeps the day and lands on local midnight`() { + val allDay = allDayInstantOf(julyTwentieth) + val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin) + + assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth) + val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin) + assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT) + } + + @Test + fun `toggling all-day on keeps the day the user was looking at`() { + // 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day. + val lateEvening = Instant.fromEpochMilliseconds( + julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(), + ) + val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin) + + assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `round-tripping the toggle is stable`() { + val original = allDayInstantOf(julyTwentieth) + val there = original.rebasedForAllDay(allDay = false, zone = newYork) + val back = there.rebasedForAllDay(allDay = true, zone = newYork) + assertThat(back).isEqualTo(original) + } +} diff --git a/floret-kit b/floret-kit index 5a576c4..396e538 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 5a576c4d288e469a75c396b365e6ace8290aa3d0 +Subproject commit 396e5389032bbd5f16d95fb997c1e497df780621 From f978c3727cdbdd72631cb6870f0fdaad5e066fae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 21:19:52 +0200 Subject: [PATCH 02/21] feat(provider): ship our own task store, vendored under our own authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 2 and 3 of docs/STORAGE-AND-SYNC.md. Agendula stops depending on a tasks provider app being installed: it now carries one. The module New :provider — the dmfs task provider 1.4.2 (Apache-2.0, DB 23), vendored in-tree, renamed to authority de.jeanlucmakiola.agendula.tasks and permissions de.jeanlucmakiola.agendula.permission.*. It coexists with OpenTasks and tasks.org rather than replacing them; nothing collides with org.dmfs.*, so both can be installed at once. The contract shape is untouched — same tables, same columns — because that is what our data layer and every CalDAV engine already speak. We own the namespace it lives in, not the schema. Vendored rather than depended on because the permission names are hardcoded in the upstream AAR's manifest and cannot be renamed in a prebuilt artifact; in-tree also satisfies F-Droid's from-source rule. provider/PROVENANCE.md records the upstream commit and every deviation, each marked with an AGENDULA CHANGE comment at the site so the list and the code cannot drift apart. The change that matters most is the account cleanup. Upstream holds GET_ACCOUNTS and deletes any task list whose account it cannot see. We dropped that permission — we only ever need our own accounts, which are visible without it — but an account we cannot see is indistinguishable from one that was removed, so left alone the provider would quietly delete synced lists. Cleanup is now restricted to account types this package authenticates itself, which is currently none. ProviderAccountCleanupTest pins that, and answers open question 3: the local path works with no account present at all. Also required by targetSdk 36, none of which upstream faced at 29: FLAG_IMMUTABLE on the notification PendingIntent, an inexact-alarm fallback so a revoked SCHEDULE_EXACT_ALARM cannot kill the app on a timezone change, and an explicit android:exported on the receiver. Storage modes ProviderResolver gains a StorageMode: LOCAL (our provider) or EXTERNAL (an installed one). Not a third SYNCED value — synced is LOCAL with an account attached, which is derived state, and modelling it as a separate store would imply switching sync on is a migration. It isn't. When the user has not chosen, the tell is whether we already hold an external provider's runtime permission. That permission is dangerous-level, so it can only be there because an earlier version asked and they agreed — the signature of an existing Posture A user, who must not be dropped onto an empty store. Fresh installs get local-first. hasPermission now short-circuits for our own provider: same-uid access bypasses the check outright, so ProviderStatus.NEEDS_PERMISSION can no longer fire in Local mode. That was the work item the storage-and-sync doc called for. The resolver's platform calls moved behind ProviderEnvironment so the decision — the part that loses people their data if wrong — is unit-tested on the JVM. Verified: 51 vendored provider tests pass, app tests pass, lintDebug and assembleDebug clean. ProviderAccountCleanupTest skips on ARM64, where Robolectric has no SQLite backend, and runs on x86_64 CI. Not yet exercised on a device. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 17 +- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 21 +- .../agendula/data/di/DataModule.kt | 15 + .../agendula/data/di/Qualifiers.kt | 10 + .../agendula/data/prefs/SettingsPrefs.kt | 19 + .../data/tasks/ProviderEnvironment.kt | 51 + .../agendula/data/tasks/ProviderResolver.kt | 126 +- .../agendula/data/tasks/StorageMode.kt | 30 + .../agendula/data/tasks/StorageModeHolder.kt | 47 + .../ui/permission/PermissionViewModel.kt | 10 + .../data/tasks/ProviderResolverTest.kt | 161 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 27 + provider/LICENSE | 202 ++ provider/NOTICE | 2 + provider/PROVENANCE.md | 192 ++ provider/build.gradle.kts | 66 + provider/proguard-rules.pro | 25 + provider/src/main/AndroidManifest.xml | 72 + .../java/org/dmfs/ngrams/NGramGenerator.java | 168 ++ .../dmfs/provider/tasks/AuthorityUtil.java | 43 + .../dmfs/provider/tasks/ContentOperation.java | 419 ++++ .../provider/tasks/FTSDatabaseHelper.java | 630 ++++++ .../provider/tasks/ProviderOperation.java | 39 + .../provider/tasks/SQLiteContentProvider.java | 364 ++++ .../provider/tasks/TaskDatabaseHelper.java | 895 +++++++++ .../org/dmfs/provider/tasks/TaskProvider.java | 1408 ++++++++++++++ .../tasks/TaskProviderBroadcastReceiver.java | 134 ++ .../java/org/dmfs/provider/tasks/Utils.java | 214 ++ .../provider/tasks/handler/AlarmHandler.java | 133 ++ .../tasks/handler/CategoryHandler.java | 277 +++ .../tasks/handler/DefaultPropertyHandler.java | 54 + .../tasks/handler/PropertyHandler.java | 155 ++ .../tasks/handler/PropertyHandlerFactory.java | 61 + .../tasks/handler/RelationHandler.java | 276 +++ .../tasks/model/AbstractInstanceAdapter.java | 37 + .../tasks/model/AbstractListAdapter.java | 56 + .../tasks/model/AbstractTaskAdapter.java | 71 + .../model/ContentValuesInstanceAdapter.java | 161 ++ .../tasks/model/ContentValuesListAdapter.java | 130 ++ .../tasks/model/ContentValuesTaskAdapter.java | 132 ++ .../CursorContentValuesInstanceAdapter.java | 212 ++ .../model/CursorContentValuesListAdapter.java | 139 ++ .../model/CursorContentValuesTaskAdapter.java | 169 ++ .../provider/tasks/model/EntityAdapter.java | 151 ++ .../provider/tasks/model/InstanceAdapter.java | 109 ++ .../provider/tasks/model/ListAdapter.java | 82 + .../provider/tasks/model/TaskAdapter.java | 362 ++++ .../model/adapters/BinaryFieldAdapter.java | 95 + .../model/adapters/BooleanFieldAdapter.java | 94 + .../model/adapters/DateTimeFieldAdapter.java | 243 +++ .../DateTimeIterableFieldAdapter.java | 198 ++ .../model/adapters/DurationFieldAdapter.java | 106 + .../tasks/model/adapters/FieldAdapter.java | 148 ++ .../model/adapters/FloatFieldAdapter.java | 95 + .../model/adapters/IntegerFieldAdapter.java | 96 + .../model/adapters/LongFieldAdapter.java | 94 + .../model/adapters/RRuleFieldAdapter.java | 122 ++ .../model/adapters/SimpleFieldAdapter.java | 100 + .../model/adapters/StringFieldAdapter.java | 95 + .../tasks/model/adapters/UrlFieldAdapter.java | 95 + .../tasks/processors/EntityProcessor.java | 35 + .../provider/tasks/processors/Logging.java | 67 + .../tasks/processors/NoOpProcessor.java | 50 + .../tasks/processors/instances/Detaching.java | 337 ++++ .../instances/TaskValueDelegate.java | 284 +++ .../processors/instances/Validating.java | 186 ++ .../processors/lists/ListCommitProcessor.java | 56 + .../tasks/processors/lists/Validating.java | 137 ++ .../processors/tasks/AutoCompleting.java | 210 ++ .../tasks/processors/tasks/Instantiating.java | 397 ++++ .../tasks/processors/tasks/Moving.java | 205 ++ .../tasks/processors/tasks/Originating.java | 77 + .../tasks/processors/tasks/Relating.java | 150 ++ .../tasks/processors/tasks/Reparenting.java | 119 ++ .../tasks/processors/tasks/Searchable.java | 66 + .../processors/tasks/TaskCommitProcessor.java | 67 + .../tasks/processors/tasks/Validating.java | 278 +++ .../processors/tasks/instancedata/Dated.java | 51 + .../tasks/instancedata/Distant.java | 43 + .../tasks/instancedata/DueDated.java | 39 + .../tasks/instancedata/Enduring.java | 59 + .../tasks/instancedata/Overridden.java | 62 + .../tasks/instancedata/StartDated.java | 39 + .../tasks/instancedata/TaskRelated.java | 57 + .../instancedata/VanillaInstanceData.java | 46 + .../provider/tasks/utils/ContainsValues.java | 72 + .../tasks/utils/InstanceValuesIterable.java | 120 ++ .../dmfs/provider/tasks/utils/Limited.java | 49 + .../provider/tasks/utils/LimitedIterator.java | 63 + .../tasks/utils/OverrideValuesFunction.java | 64 + .../dmfs/provider/tasks/utils/Profiled.java | 80 + .../org/dmfs/provider/tasks/utils/Range.java | 56 + .../provider/tasks/utils/ResourceArray.java | 49 + .../provider/tasks/utils/RowIterator.java | 57 + .../provider/tasks/utils/TableColumns.java | 61 + .../tasks/utils/TaskInstanceIterable.java | 80 + .../tasks/utils/TaskInstanceIterator.java | 78 + .../dmfs/provider/tasks/utils/Timestamps.java | 55 + .../org/dmfs/provider/tasks/utils/With.java | 64 + .../org/dmfs/provider/tasks/utils/Zipped.java | 43 + .../org/dmfs/tasks/contract/TaskContract.java | 1728 +++++++++++++++++ .../org/dmfs/tasks/contract/UriFactory.java | 57 + .../res/drawable/ic_24_agendula_tasks.xml | 4 + provider/src/main/res/values-cs/strings.xml | 14 + provider/src/main/res/values-de/strings.xml | 14 + provider/src/main/res/values-es/strings.xml | 14 + provider/src/main/res/values-fr/strings.xml | 14 + provider/src/main/res/values-hu/strings.xml | 14 + provider/src/main/res/values-it/strings.xml | 14 + provider/src/main/res/values-ja/strings.xml | 14 + provider/src/main/res/values-nl/strings.xml | 14 + provider/src/main/res/values-pl/strings.xml | 14 + .../src/main/res/values-pt-rBR/strings.xml | 14 + .../src/main/res/values-pt-rPT/strings.xml | 14 + provider/src/main/res/values-ru/strings.xml | 16 + provider/src/main/res/values-sr/strings.xml | 14 + provider/src/main/res/values-uk/strings.xml | 14 + .../src/main/res/values/agendula_defaults.xml | 16 + .../agendula_provider_changed_receivers.xml | 15 + provider/src/main/res/values/strings.xml | 20 + .../tasks/ProviderAccountCleanupTest.java | 215 ++ .../DateTimeIterableFieldAdapterTest.java | 223 +++ .../tasks/instancedata/DatedTest.java | 62 + .../tasks/instancedata/DistantTest.java | 46 + .../tasks/instancedata/DueDatedTest.java | 84 + .../tasks/instancedata/EnduringTest.java | 80 + .../tasks/instancedata/OverriddenTest.java | 110 ++ .../tasks/instancedata/StartDatedTest.java | 84 + .../tasks/instancedata/TaskRelatedTest.java | 44 + .../instancedata/VanillaInstanceDataTest.java | 53 + .../tasks/utils/ContainsValuesTest.java | 94 + .../tasks/utils/ContentValuesWithLong.java | 57 + .../tasks/utils/TaskInstanceIterableTest.java | 188 ++ .../tasks/utils/TaskInstanceIteratorTest.java | 121 ++ .../dmfs/provider/tasks/utils/ZippedTest.java | 60 + .../src/test/resources/robolectric.properties | 26 + settings.gradle.kts | 5 + 139 files changed, 17424 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt create mode 100644 provider/LICENSE create mode 100644 provider/NOTICE create mode 100644 provider/PROVENANCE.md create mode 100644 provider/build.gradle.kts create mode 100644 provider/proguard-rules.pro create mode 100644 provider/src/main/AndroidManifest.xml create mode 100644 provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/Utils.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/With.java create mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java create mode 100644 provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java create mode 100644 provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java create mode 100644 provider/src/main/res/drawable/ic_24_agendula_tasks.xml create mode 100644 provider/src/main/res/values-cs/strings.xml create mode 100644 provider/src/main/res/values-de/strings.xml create mode 100644 provider/src/main/res/values-es/strings.xml create mode 100644 provider/src/main/res/values-fr/strings.xml create mode 100644 provider/src/main/res/values-hu/strings.xml create mode 100644 provider/src/main/res/values-it/strings.xml create mode 100644 provider/src/main/res/values-ja/strings.xml create mode 100644 provider/src/main/res/values-nl/strings.xml create mode 100644 provider/src/main/res/values-pl/strings.xml create mode 100644 provider/src/main/res/values-pt-rBR/strings.xml create mode 100644 provider/src/main/res/values-pt-rPT/strings.xml create mode 100644 provider/src/main/res/values-ru/strings.xml create mode 100644 provider/src/main/res/values-sr/strings.xml create mode 100644 provider/src/main/res/values-uk/strings.xml create mode 100644 provider/src/main/res/values/agendula_defaults.xml create mode 100644 provider/src/main/res/values/agendula_provider_changed_receivers.xml create mode 100644 provider/src/main/res/values/strings.xml create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java create mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java create mode 100644 provider/src/test/resources/robolectric.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9698ad7..fd13d5c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,6 +138,11 @@ kotlin { } dependencies { + // Agendula's own task store — the dmfs provider vendored under our authority. + // Contributes a to the merged manifest; no app code imports from it + // except ProviderResolver, which reads the authority out of its resources. + implementation(project(":provider")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d37b60..130f919 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,15 @@ - + runtime by the permission flow, and only once the user has actually + selected External mode. Both are dangerous-level. + + Agendula's own bundled provider needs NO entry here: it runs under our uid + and a same-uid caller bypasses a provider's permission checks outright. + Its permissions are declared by the :provider module, for other apps. --> @@ -74,13 +80,16 @@ - + + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 000182b..0adb920 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -35,17 +36,27 @@ class AgendulaApp : Application() { issueTitle = getString(R.string.crash_report_issue_title), ), ) - val scheduler = EntryPointAccessors - .fromApplication(this, ReminderEntryPoint::class.java) - .reminderScheduler() + val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) + val scheduler = entryPoint.reminderScheduler() + // Start mirroring the stored storage mode into ProviderResolver before + // anything reads a provider. + val storageModeHolder = entryPoint.storageModeHolder() + storageModeHolder.start() CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - runCatching { scheduler.sync() } + // Wait for the stored mode to land first. Rescheduling alarms against + // whichever provider autoMode happens to pick would arm them off the + // wrong store for a user who chose the other one. + runCatching { + storageModeHolder.awaitReady() + scheduler.sync() + } } } @EntryPoint @InstallIn(SingletonComponent::class) - interface ReminderEntryPoint { + interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler + fun storageModeHolder(): StorageModeHolder } } 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 7e668e7..8bf97b6 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 @@ -10,12 +10,16 @@ import dagger.Provides import dagger.hilt.InstallIn 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.ProviderEnvironment import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton private val Context.agendulaDataStore: DataStore by preferencesDataStore( @@ -33,6 +37,10 @@ abstract class DataBindModule { @Binds @Singleton abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository + + @Binds + @Singleton + abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment } @Module @@ -47,4 +55,11 @@ object DataProvideModule { @Provides @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + // SupervisorJob so one failing collector can't take the others down with it. + CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt index 6be87bc..e42dfcf 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt @@ -6,3 +6,13 @@ import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher + +/** + * Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that + * outlives any screen and has nothing to be cancelled by, such as keeping the + * selected storage mode mirrored out of DataStore. It is never cancelled, so + * don't launch anything unbounded in it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 4508baf..1644fc5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec @@ -82,6 +83,23 @@ class SettingsPrefs @Inject constructor( suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } + /** + * Which task store backs the app, or `null` while the user has not chosen — + * which is the normal state, since most people never open Settings. + * + * Kept out of [Settings] on purpose. Everything in there is a rendering + * preference collected by the UI; this one selects an authority in the data + * layer, is read on paths that must not wait for a whole settings object, and + * `null` genuinely means "undecided" rather than "default" — the difference + * matters, because undecided is what lets `ProviderResolver.autoMode` keep an + * upgrading Posture A user pointed at the provider that holds their data. + */ + val storageMode: Flow = dataStore.data.map { p -> + p[STORAGE_MODE]?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + } + + suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } + /** One-time reminder onboarding gate; false until the step has been shown. */ val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false } @@ -113,6 +131,7 @@ class SettingsPrefs @Inject constructor( val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") + val STORAGE_MODE = stringPreferencesKey("storage_mode") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt new file mode 100644 index 0000000..4bf4c9e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The three platform facts [ProviderResolver] needs, behind an interface. + * + * Same seam the data source uses, for the same reason: which store a returning + * user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows + * them an empty app, and that decision is worth testing on the JVM rather than + * only on a device. Everything Android-shaped lives here so the logic above stays + * plain Kotlin. + */ +interface ProviderEnvironment { + + /** Our own bundled provider's authority, from the `:provider` module's resources. */ + val ownAuthority: String + + /** Our own package name. */ + val ownPackage: String + + /** The package declaring [authority], or `null` when nothing on the device does. */ + fun packageDeclaring(authority: String): String? + + /** Whether this app currently holds [permission]. */ + fun isGranted(permission: String): Boolean +} + +@Singleton +class AndroidProviderEnvironment @Inject constructor( + @ApplicationContext private val context: Context, +) : ProviderEnvironment { + + override val ownAuthority: String + // Read from the module that declares it, never written as a literal: the + // authority lives in exactly one place, its own string resource. + get() = context.getString(de.jeanlucmakiola.agendula.provider.R.string.agendula_tasks_authority) + + override val ownPackage: String get() = context.packageName + + override fun packageDeclaring(authority: String): String? = + context.packageManager.resolveContentProvider(authority, 0)?.packageName + + override fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index ba24eb0..da2e700 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,59 +1,133 @@ package de.jeanlucmakiola.agendula.data.tasks -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton /** * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every - * candidate, so the [TasksContract] columns apply regardless of which is present. + * candidate — ours included, since `:provider` *is* that provider vendored — so + * the [TasksContract] columns apply regardless of which is active. */ data class TaskProvider( val authority: String, val readPermission: String, val writePermission: String, val packageName: String? = null, + /** + * True for Agendula's own bundled provider. It runs in our process under our + * uid, and a same-uid caller bypasses a provider's permission checks outright, + * so [ProviderResolver.hasPermission] must never gate on a grant for it. + */ + val isOwn: Boolean = false, ) /** - * The A/B seam. Detects which tasks provider is installed at runtime and which - * permission set it needs, so nothing above the data layer hardcodes an - * authority. Under Posture B (bundled provider) this simply finds our own - * `org.dmfs.tasks` first. See docs/PLAN.md. + * The A/B seam: the only class in the app that knows an authority exists. + * + * - **Posture A** — an *external* provider (OpenTasks, tasks.org). Still fully + * supported; it stopped being the default and became a user choice. + * - **Posture B** — our own bundled provider, under our **own** authority. It + * coexists with everything and replaces nothing. + * + * Both terms were redefined by `docs/STORAGE-AND-SYNC.md`. Posture B used to mean + * "bundle OpenTasks and squat `org.dmfs.tasks`"; that is a dead end and is not + * coming back, because two apps cannot declare the same authority + * (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same permission name + * (`INSTALL_FAILED_DUPLICATE_PERMISSION`) — anyone with OpenTasks installed would + * simply have been unable to install Agendula. + * + * Which one is active comes from [storageMode]; how that gets decided when the + * user has not chosen is [autoMode]. */ @Singleton class ProviderResolver @Inject constructor( - @ApplicationContext private val context: Context, + private val environment: ProviderEnvironment, ) { - /** The active provider, or `null` when no tasks provider is installed. */ - fun resolve(): TaskProvider? { - for (candidate in CANDIDATES) { - val info = context.packageManager.resolveContentProvider(candidate.authority, 0) - ?: continue - return candidate.copy(packageName = info.packageName) + /** + * The user's explicit choice, or `null` while they have not made one (which is + * the normal state — most people never open Settings). Kept as a plain field + * rather than read from DataStore on demand because [resolve] is called from + * synchronous data-source code on every query, including from the main thread + * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + */ + @Volatile + var storageMode: StorageMode? = null + + /** Agendula's own provider. Always present — it ships inside the APK. */ + val own: TaskProvider by lazy { + TaskProvider( + authority = environment.ownAuthority, + readPermission = OWN_READ_PERMISSION, + writePermission = OWN_WRITE_PERMISSION, + packageName = environment.ownPackage, + isOwn = true, + ) + } + + /** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */ + fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) { + StorageMode.LOCAL -> own + StorageMode.EXTERNAL -> resolveExternal() + } + + /** + * What to use when the user has not chosen — and the one piece of real + * judgement in this class, because getting it wrong loses people their data. + * + * Ranking our own provider first unconditionally would be wrong: someone who + * has been using Agendula over OpenTasks since 0.3.x would update, land on an + * empty bundled store, and reasonably conclude their tasks were deleted. + * + * So the tell is **whether we already hold an external provider's runtime + * permission**. That is a dangerous permission — it can only be there because + * a previous version asked and the user agreed, which is precisely the + * definition of "this person is an existing Posture A user". A fresh install + * never holds it, and gets local-first storage. + * + * Deliberately cheap and synchronous: a PackageManager lookup and a permission + * check, no database probe. Settings overrides it either way. + */ + fun autoMode(): StorageMode { + val external = resolveExternal() + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.LOCAL + } + + /** The first installed external candidate, or `null` when none is present. */ + fun resolveExternal(): TaskProvider? { + for (candidate in EXTERNAL_CANDIDATES) { + val packageName = environment.packageDeclaring(candidate.authority) ?: continue + return candidate.copy(packageName = packageName) } return null } fun hasPermission(provider: TaskProvider): Boolean = - granted(provider.readPermission) && granted(provider.writePermission) - - private fun granted(permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + // Same uid, same process: there is nothing to grant, and asking would put a + // permission dialog in front of a purely local app for no reason. This is + // the bypass docs/STORAGE-AND-SYNC.md calls for — without it + // ProviderStatus.NEEDS_PERMISSION fires in Local mode and the onboarding + // gate asks for a permission that can never be granted. + provider.isOwn || + (environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)) companion object { /** - * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by - * `org.dmfs.provider.tasks.TaskProvider`, guarded by - * `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks` - * + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical - * authority; on a device with only one installed, order is moot. + * Declared by the `:provider` module's manifest. Listed here so the app can + * name them; nothing ever requests them, since [hasPermission] short-circuits + * for our own provider. */ - val CANDIDATES: List = listOf( + const val OWN_READ_PERMISSION = "de.jeanlucmakiola.agendula.permission.READ_TASKS" + const val OWN_WRITE_PERMISSION = "de.jeanlucmakiola.agendula.permission.WRITE_TASKS" + + /** + * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by + * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*` + * (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`. + * OpenTasks is listed first as the canonical authority; on a device with + * only one installed, order is moot. + */ + val EXTERNAL_CANDIDATES: List = listOf( TaskProvider( authority = "org.dmfs.tasks", readPermission = "org.dmfs.permission.READ_TASKS", diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt new file mode 100644 index 0000000..85789d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks + +/** + * Which task store backs the app — the user's choice, per `docs/STORAGE-AND-SYNC.md`. + * + * Only two values, though the document describes three modes. **Synced is not a + * third store**: it is [LOCAL] with an account attached, so it is derived state + * (does an account of ours exist?) rather than something the user picks. That + * also means switching sync on is never a migration. Adding a `SYNCED` constant + * here would imply otherwise. + * + * Nothing above the data layer reads this; it selects an authority for + * [ProviderResolver] and stops there. + */ +enum class StorageMode { + /** + * Agendula's own bundled provider (the `:provider` module). Always available — + * 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. + */ + LOCAL, + + /** + * 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 + * Posture A, still fully supported, but now a choice rather than the only way. + * Requires that provider's runtime read/write permissions. + */ + EXTERNAL, +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt new file mode 100644 index 0000000..81b86dc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Mirrors the stored [StorageMode] into [ProviderResolver]. + * + * The resolver is consulted synchronously from every data-source call and from + * `providerStatus()` on the main thread, so it cannot read DataStore itself. + * This is the one component that bridges the two: it collects the preference for + * the life of the process and pushes each value across. + * + * [awaitReady] exists for the startup race. Until the first DataStore emission + * arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers + * instead — fine as a steady state, wrong for a user who explicitly chose the + * other mode. Anything that touches the provider before the UI is up (the launch + * reminder re-sync, notably) should wait rather than risk reading the wrong + * store and rescheduling every alarm off it. + */ +@Singleton +class StorageModeHolder @Inject constructor( + private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val firstValue = CompletableDeferred() + + /** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */ + fun start() { + scope.launch { + prefs.storageMode.collect { mode -> + resolver.storageMode = mode + firstValue.complete(Unit) + } + } + } + + /** Suspends until the stored mode has been applied at least once. */ + suspend fun awaitReady() = firstValue.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 9e6760a..9186e16 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -20,6 +20,10 @@ data class PermissionUiState( * Gates app entry: is a tasks provider installed, and do we hold its permissions? * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. + * + * In the default Local mode this gate never appears at all — Agendula's own + * provider ships in the APK and is reached same-uid, so there is nothing to + * install and nothing to grant. It exists for External mode. */ @HiltViewModel class PermissionViewModel @Inject constructor( @@ -37,7 +41,13 @@ class PermissionViewModel @Inject constructor( val provider = providerResolver.resolve() _state.value = PermissionUiState( status = repository.providerStatus(), + // Never our own provider's permissions. They are declared for *other* + // apps to hold; requesting them here would show a dialog for a + // permission that a same-uid caller does not need and the system will + // not meaningfully grant. In practice this branch is unreachable in + // Local mode, since the status is already READY — belt and braces. permissionsToRequest = provider + ?.takeUnless { it.isOwn } ?.let { listOf(it.readPermission, it.writePermission) } .orEmpty(), ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt new file mode 100644 index 0000000..edf4494 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The storage-mode decision, which is the part of [ProviderResolver] with real + * consequences: pick wrong for a returning user and the app opens on an empty + * store where their tasks used to be. + */ +class ProviderResolverTest { + + /** + * A [ProviderEnvironment] with no Android in it. + * + * @param installed authority -> declaring package, i.e. what is on the device. + * @param granted permissions this app currently holds. + */ + private class FakeEnvironment( + val installed: Map = emptyMap(), + val granted: Set = emptySet(), + ) : ProviderEnvironment { + override val ownAuthority = "de.jeanlucmakiola.agendula.tasks" + override val ownPackage = "de.jeanlucmakiola.agendula" + override fun packageDeclaring(authority: String): String? = installed[authority] + override fun isGranted(permission: String): Boolean = permission in granted + } + + private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } + + private fun resolver( + installed: Map = emptyMap(), + granted: Set = emptySet(), + mode: StorageMode? = null, + ) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode } + + private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks") + private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission) + + @Nested + inner class OwnProvider { + + @Test + fun `needs no permission grant`() { + // The bypass the whole Local mode rests on: our provider runs under our + // own uid, so there is nothing to grant and nothing to ask for. + val resolver = resolver() + assertThat(resolver.hasPermission(resolver.own)).isTrue() + } + + @Test + fun `is always resolvable because it ships in the APK`() { + assertThat(resolver(mode = StorageMode.LOCAL).resolve()).isNotNull() + } + + @Test + fun `does not use a dmfs authority`() { + // Squatting org.dmfs.tasks would make Agendula and OpenTasks mutually + // uninstallable. Guards against a careless resync of the vendored module. + assertThat(resolver().own.authority).isEqualTo("de.jeanlucmakiola.agendula.tasks") + } + } + + @Nested + inner class AutoMode { + + @Test + fun `a fresh install with nothing else present is local`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.LOCAL) + } + + @Test + fun `an upgrading user who already granted OpenTasks stays on it`() { + // Holding a dangerous permission means a previous version asked and they + // agreed — the signature of an existing Posture A user. Sending them to + // our empty bundled store would read as data loss. + val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `OpenTasks merely installed is not enough`() { + // Someone who has OpenTasks for unrelated reasons, and never granted us + // anything, has no data with us there. Local-first is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.LOCAL) + } + + @Test + fun `a half-granted external provider does not count`() { + val resolver = resolver( + installed = openTasksInstalled, + granted = setOf(openTasks.readPermission), + ) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.LOCAL) + } + } + + @Nested + inner class ExplicitChoice { + + @Test + fun `overrides the automatic answer in both directions`() { + val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted) + + val forcedLocal = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.LOCAL } + assertThat(forcedLocal.resolve()?.isOwn).isTrue() + + val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL } + assertThat(forcedExternal.resolve()).isNull() + } + + @Test + fun `external with no provider installed resolves to nothing`() { + // Drives the "install a tasks provider" gate rather than silently + // falling back to our own store behind the user's back. + assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() + } + + @Test + fun `external still requires the runtime permission`() { + val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) + val provider = resolver.resolve() + assertThat(provider).isNotNull() + assertThat(resolver.hasPermission(provider!!)).isFalse() + } + } + + @Nested + inner class ExternalCandidates { + + @Test + fun `prefer OpenTasks over tasks_org when both are installed`() { + val resolver = resolver( + installed = mapOf( + "org.dmfs.tasks" to "org.dmfs.tasks", + "org.tasks.opentasks" to "org.tasks", + ), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `fall through to tasks_org when OpenTasks is absent`() { + val resolver = resolver( + installed = mapOf("org.tasks.opentasks" to "org.tasks"), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") + } + + @Test + fun `never include our own provider`() { + // EXTERNAL must mean "somebody else's store". If ours ever leaked into + // this list, choosing External would silently keep using it. + assertThat(ProviderResolver.EXTERNAL_CANDIDATES.none { it.isOwn }).isTrue() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a96f72d..d8237c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b691c2a..b72b303 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,22 @@ androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" +# --- :provider (vendored dmfs task provider) --------------------------------- +# Versions the upstream 1.4.2 source was written against. These are its runtime +# dependencies, not ours — nothing above the data layer touches them, and they +# only move when we deliberately resync the fork. See provider/PROVENANCE.md. +dmfsJems = "1.43" +dmfsRfc5545Datetime = "0.2.4" +dmfsLibRecur = "0.12.2" +# The provider's own test suite is JUnit 4 + Robolectric, unlike the app's +# JUnit 5. Kept as upstream wrote it (rewriting ~13 test classes would forfeit +# the regression coverage that makes vendoring safe), but on current versions: +# upstream pins Robolectric 3.5.1, which predates AGP's resource handling. +robolectric = "4.16" +junit4 = "4.13.2" +hamcrest = "3.0" +mockito = "5.20.0" + [libraries] # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -92,8 +108,19 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material # Android tests - GrantPermissionRule androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +# :provider — vendored dmfs task provider (see provider/PROVENANCE.md) +dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" } +dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" } +dmfs-rfc5545-datetime = { group = "org.dmfs", name = "rfc5545-datetime", version.ref = "dmfsRfc5545Datetime" } +dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/provider/LICENSE b/provider/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/provider/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/provider/NOTICE b/provider/NOTICE new file mode 100644 index 0000000..6a0d99d --- /dev/null +++ b/provider/NOTICE @@ -0,0 +1,2 @@ +OpenTasks - Open Source Task App for Android +Copyright 2012-2015 Marten Gajda \ No newline at end of file diff --git a/provider/PROVENANCE.md b/provider/PROVENANCE.md new file mode 100644 index 0000000..f911df4 --- /dev/null +++ b/provider/PROVENANCE.md @@ -0,0 +1,192 @@ +# `:provider` — provenance + +This module is **not our code**. It is the dmfs task provider, vendored, with +its namespace renamed to ours and a short list of changes recorded below. + +| | | +|---|---| +| Upstream | [dmfs/opentasks](https://github.com/dmfs/opentasks) | +| Module taken | `opentasks-provider`, plus `opentasks-contract` (see [Why the contract came along](#why-the-contract-came-along)) | +| Version | `1.4.2` | +| Commit | `49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21) | +| License | Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE), both upstream's, unmodified | +| Database version | **23** | + +Agendula itself is MIT. Apache-2.0 into MIT is fine in that direction, but this +module keeps its own `LICENSE`, `NOTICE`, and per-file Apache headers, and those +must survive any future edit here. + +**1.4.2 specifically, for the database version.** DB 23 is the first to carry +`is_recurring`. tasks.org's fork is DB 22 and lacks it — which is why +`TaskMapper.task` on the app side derives recurrence from `rrule`/`rdate` +instead of trusting that column, and why it must keep doing so as long as +External mode supports tasks.org. + +## Why vendored at all + +Recorded properly in [`docs/STORAGE-AND-SYNC.md`](../docs/STORAGE-AND-SYNC.md); +in one line: **the permission names are hardcoded in the upstream AAR's +manifest.** No prebuilt artifact — Maven Central, JitPack, anything — can have +them renamed without `tools:` node surgery, and shipping under dmfs's own +permission names would make Agendula and OpenTasks mutually uninstallable +(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfies F-Droid's +from-source requirement, which a JitPack artifact would not. + +In-tree rather than a git submodule, unlike floret-kit: we co-develop the kit, +whereas this is a fork we expect to resync from upstream approximately never. + +## The namespace rename + +Everything in this table is a rename and nothing more. The **contract shape is +untouched** — same tables, same column names, same URI paths — because that +shape is what our data layer, and every CalDAV engine, already speaks. We own +the namespace it lives in, not the schema. + +| | Upstream | Ours | +|---|---|---| +| Authority | `org.dmfs.tasks` | `de.jeanlucmakiola.agendula.tasks` | +| Read permission | `org.dmfs.permission.READ_TASKS` | `de.jeanlucmakiola.agendula.permission.READ_TASKS` | +| Write permission | `org.dmfs.permission.WRITE_TASKS` | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` | +| Permission group | `org.dmfs.tasks.permissiongroup.Tasks` | `de.jeanlucmakiola.agendula.permissiongroup.Tasks` | +| Notification-alarm action | `org.dmfs.tasks.provider.NOTIFICATION_ALARM` | `de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM` | +| Resource prefix | `opentasks_*` | `agendula_*` | +| R class | `org.dmfs.tasks.provider.R` | `de.jeanlucmakiola.agendula.provider.R` | + +### What was deliberately *not* renamed + +- **Java package names** stay `org.dmfs.provider.tasks` / `org.dmfs.tasks.contract`. + They are not a registered namespace — two apps may share them freely — and + keeping them means the diff against upstream stays legible. Only the AGP + `namespace` (which decides where `R` lands) is ours. +- **`TaskContract.LOCAL_ACCOUNT_TYPE`** stays `"org.dmfs.account.LOCAL"`. It is a + value stored in the database and recognised by dmfs-contract providers + generally, so the *same* app code has to write it whether it is talking to our + provider or, in External mode, to OpenTasks. Renaming it would fork the write + path in two for no gain. +- **`ACTION_BROADCAST_TASK_DUE` / `…_TASK_STARTING` / `ACTION_DATABASE_INITIALIZED`.** + Every send site calls `setPackage()` on its own package first, so these never + cross app boundaries and cannot collide with an installed OpenTasks. + +## Changes to upstream source + +Four files under `src/main/java`, one under `src/test/java`. Each edit is marked +with an `AGENDULA CHANGE` comment at the site, so this list and the code cannot +drift apart. Keep that convention. + +### Behavioural + +1. **`Utils.cleanUpLists` — only prune account types we authenticate ourselves.** + *The one change here that is about correctness rather than mechanics.* + + Upstream holds `GET_ACCOUNTS` and enumerates every account on the device. We + dropped that permission (below), so `AccountManager` only ever reports + accounts of our own type. Upstream's cleanup deletes any task list whose + account is absent from that array — and an account we *cannot see* is + indistinguishable from one that has been *removed*. Left alone, the provider + would quietly delete synced lists. Not "sync stops": data disappears, with no + error anywhere. + + Now a list is only prunable when its account type belongs to an authenticator + in **this package**. Agendula ships no authenticator yet, so the set is empty + and nothing is ever pruned; our sync adapter's type will join it on its own + when it lands, no edit needed here. Local lists were already exempt upstream. + +2. **`TaskProvider.insert` — the same restriction for the stale-list signal.** + Upstream flags "list with unknown account" and broadcasts about it. With the + account cache holding only our own accounts, that fired on every insert into + any externally-synced list. Nothing listens today; the point is that whatever + listens tomorrow gets a signal that means something. + +3. **`TaskProviderBroadcastReceiver.onReceive` — fall-through written out.** + Upstream's `switch` has no `break` in any branch, so `TIMEZONE_CHANGED` runs + all three content operations and `NOTIFICATION_ALARM` runs the last two. The + comment on the first branch ("don't trigger the notifications update yet") + describes breaks that were never written, so the code and the stated intent + contradict each other. + + **Observed behaviour is preserved exactly**, just spelled out with `if`s + rather than reached by accident. A vendored fork is the wrong place to guess + at intent. ⚠️ **Open:** which of the two is the bug wants a device with a task + due across a timezone change to settle. + +4. **`TaskProviderBroadcastReceiver.planNotificationUpdate` — inexact-alarm fallback.** + `setExact` throws `SecurityException` on API 31–32 when the user revokes + `SCHEDULE_EXACT_ALARM` — inside a receiver handling a system broadcast, so the + app would die on every timezone change. Falls back to `set` when exact alarms + aren't permitted. This alarm drives only the provider's own bookkeeping; + Agendula's user-visible reminders come from `ReminderScheduler`, which asks + for the permission properly. + +### Required by modern Android (would not build or would crash otherwise) + +5. **`PendingIntent.FLAG_IMMUTABLE`** added in `planNotificationUpdate`. Mandatory + since Android 12; throws `IllegalArgumentException` without it at targetSdk ≥ 31. + Upstream targets 29. Nothing mutates the intent later, so immutable is also + correct on the merits. +6. **`android:exported`** stated explicitly on the receiver. AGP hard-errors on an + intent-filtered component without it at targetSdk ≥ 31. +7. **`package=` attribute** removed from the manifest; AGP 8+ takes it from the + `namespace` in the build file. +8. **``** removed + along with the androidTest sources it existed for (below). + +### Permissions + +9. **`android.permission.GET_ACCOUNTS` dropped.** We only ever need to see + accounts of our own type, and since API 26 an authenticator makes those + visible to its own package with no grant at all. Safe **only** in combination + with change 1 — the two must be read together. + +### Build and test + +10. **`build.gradle` → `build.gradle.kts`**, on the root version catalog. minSdk + 21 → 29 (matching `:app`; the merger rejects a lower floor), Java 8 → 17. +11. **Test stack modernised**, sources otherwise untouched: JUnit 4.12 → 4.13.2, + Robolectric 3.5.1 → 4.16, Mockito 2.27 → 5.20, Hamcrest 1.3 → 3.0. + `org.dmfs:jems`, `rfc5545-datetime` and `lib-recur` stay on the versions + upstream pinned — all three resolve from Maven Central, so no new repository + was added (`settings.gradle.kts` is still `google()` + `mavenCentral()` under + `FAIL_ON_PROJECT_REPOS`). +12. **`ZippedTest.testAbsent`** — diamond `new Zipped<>` given an explicit type + argument. `absent()` pins no type, and javac 17 will not infer what javac 8 + did. The assertion is unchanged. +13. **`src/test/resources/robolectric.properties` added** (`sdk=34`, + `conscryptMode=OFF`). A library module has no `targetSdk` for Robolectric to + read, and Robolectric installs Conscrypt unconditionally, whose uber jar has + no `linux-aarch_64` native — so without this the suite fails at setup on ARM64 + machines while passing on x86_64 CI. See the file for the reasoning. +14. **`src/androidTest` dropped entirely.** It depends on `contentpal` / + `contenttestpal`, which are JitPack-only; adding JitPack would widen the + dependency trust surface for test-only code. ⚠️ This is the one place + vendoring lost coverage — those were the provider's *integration* tests + (recurrence, reparenting, instances, observers). The 51 JVM tests in + `src/test` all pass and are retained. +15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies + `org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority + and has never heard of ours. Anything re-added here also needs a `` + entry in the app manifest or package-visibility rules drop the broadcast. +16. **Translated `agendula_provider_label` overrides removed** (the other + translated strings are kept as upstream shipped them). The base label became + "Agendula tasks" so it is distinguishable from OpenTasks' own "Tasks" entry in + the system permission dialog; the inherited translations still said plain + "Tasks" in their language, which would have contradicted it. + +## Resyncing from upstream + +Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the +`AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on +this directory is the audit trail, and the 51 JVM tests are the safety net. +Re-read change 1 before touching anything account-related. + +## Known-unverified + +Everything here is verified by the JVM test suite and a clean build. What is +**not** yet verified on a device with real data: + +- The local-list path with **no account present at all** — the entirety of Local + mode. `cleanUpLists` exempts local lists explicitly and change 1 makes the + prunable set empty, so it should hold by construction; it is covered by + `ProviderAccountCleanupTest`, but that is Robolectric, not a device. +- The timezone-change behaviour in change 3. +- Any interaction with an external sync engine writing into our authority + (nothing does yet — that is the DAVx5 ask, step 4 of the sequencing). diff --git a/provider/build.gradle.kts b/provider/build.gradle.kts new file mode 100644 index 0000000..3206e9c --- /dev/null +++ b/provider/build.gradle.kts @@ -0,0 +1,66 @@ +// Agendula's own task store: the dmfs task provider (Apache-2.0), vendored. +// +// This module is a fork, not a dependency. What we changed and why is recorded +// in PROVENANCE.md; the short version is that the authority and the permission +// names had to become ours, and the permission names are hardcoded in the +// upstream AAR's manifest, so no prebuilt artifact could have been used. +// +// It stays Java, on upstream's `org.dmfs.*` package names, formatted the way +// upstream formats it. That is deliberate: every deviation from upstream is a +// line we have to re-reason about if we ever resync, so the diff is kept +// legible rather than idiomatic. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.agendula.provider" + compileSdk = 37 + + defaultConfig { + // Matches :app. Upstream ships minSdk 21 / targetSdk 29; targetSdk is set + // by the application module anyway, but the SDK floor here has to agree + // with :app's or the manifest merger rejects it. + minSdk = 29 + + consumerProguardFiles("proguard-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + // The provider reads its own authority out of resources, so it needs R. + // It has no BuildConfig use at all. + buildConfig = false + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + lint { + // Upstream's translations are inherited as-is and are partial — a missing + // string falls back to the English base at runtime. Same call as :app. + informational += listOf("MissingTranslation") + } +} + +dependencies { + implementation(libs.dmfs.jems) + implementation(libs.dmfs.rfc5545.datetime) + implementation(libs.dmfs.lib.recur) + + // Upstream's own JVM test suite, on current versions of its stack. Note this + // module is JUnit 4 while :app is JUnit 5 — deliberately, see the version + // catalog. Do not add `useJUnitPlatform()` here. + testImplementation(libs.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.hamcrest) + testImplementation(libs.mockito.core) + testImplementation(libs.dmfs.jems.testing) +} diff --git a/provider/proguard-rules.pro b/provider/proguard-rules.pro new file mode 100644 index 0000000..6a525b0 --- /dev/null +++ b/provider/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/marten/.local/Android/Sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/provider/src/main/AndroidManifest.xml b/provider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8cd8305 --- /dev/null +++ b/provider/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java b/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java new file mode 100644 index 0000000..58e3064 --- /dev/null +++ b/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java @@ -0,0 +1,168 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.ngrams; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + + +/** + * Generator for N-grams from a given String. + * + * @author Marten Gajda + */ +public final class NGramGenerator +{ + /** + * A {@link Pattern} that matches anything that doesn't belong to a word or number. + */ + private final static Pattern SEPARATOR_PATTERN = Pattern.compile("[^\\p{L}\\p{M}\\d]+"); + + /** + * A {@link Pattern} that matches anything that doesn't belong to a word. + */ + private final static Pattern SEPARATOR_PATTERN_NO_NUMBERS = Pattern.compile("[^\\p{L}\\p{M}]+"); + + private final int mN; + private final int mMinWordLen; + private boolean mAllLowercase = true; + private boolean mReturnNumbers = true; + private boolean mAddSpaceInFront = false; + private Locale mLocale = Locale.getDefault(); + + + public NGramGenerator(int n) + { + this(n, 1); + } + + + public NGramGenerator(int n, int minWordLen) + { + mN = n; + mMinWordLen = minWordLen; + } + + + /** + * Set whether to convert all words to lower-case first. + * + * @param lowercase + * true to convert the test to lower case first. + * + * @return This instance. + */ + public NGramGenerator setAllLowercase(boolean lowercase) + { + mAllLowercase = lowercase; + return this; + } + + + /** + * Set whether to index the beginning of a word with a space in front. This slightly raises the weight of word beginnings when searching. + * + * @param addSpace + * true to add a space in front of each word, false otherwise. + * + * @return This instance. + */ + public NGramGenerator setAddSpaceInFront(boolean addSpace) + { + mAddSpaceInFront = addSpace; + return this; + } + + + /** + * Sets the {@link Locale} to use when converting the input string to lower case. This has no effect when {@link #setAllLowercase(boolean)} is called with + * false. + * + * @param locale + * The {@link Locale} to user for the conversion to lower case. + * + * @return This instance. + */ + public NGramGenerator setLocale(Locale locale) + { + mLocale = locale; + return this; + } + + + /** + * Get all N-grams contained in the given String. + * + * @param data + * The String to analyze. + * + * @return The {@link Set} containing the N-grams. + */ + public Set getNgrams(String data) + { + if (data == null) + { + return Collections.emptySet(); + } + + if (mAllLowercase) + { + data = data.toLowerCase(mLocale); + } + + String[] words = mReturnNumbers ? SEPARATOR_PATTERN.split(data) : SEPARATOR_PATTERN_NO_NUMBERS.split(data); + + Set set = new HashSet(128); + + for (String word : words) + { + getNgrams(word, set); + } + + return set; + } + + + private void getNgrams(String word, Set ngrams) + { + final int len = word.length(); + + if (len < mMinWordLen) + { + return; + } + + final int n = mN; + final int last = Math.max(1, len - n + 1); + + for (int i = 0; i < last; ++i) + { + ngrams.add(word.substring(i, Math.min(i + n, len))); + } + + if (mAddSpaceInFront) + { + /* + * Add another String with a space and the first n-1 characters of the word. + */ + ngrams.add(" " + word.substring(0, Math.min(len, n - 1))); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java b/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java new file mode 100644 index 0000000..369cc57 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.Context; + +import de.jeanlucmakiola.agendula.provider.R; + + +/** + * Access for the authority name of the tasks content provider. + * + * @author Gabor Keszthelyi + */ +// TODO Figure out better design or at least rename to TaskAuthority.get(context) (results in changes in many files) +public final class AuthorityUtil +{ + private static String sCachedValue; + + + public static String taskAuthority(Context context) + { + if (sCachedValue == null) + { + sCachedValue = context.getString(R.string.agendula_tasks_authority); + } + return sCachedValue; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java new file mode 100644 index 0000000..622533c --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java @@ -0,0 +1,419 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Handler; +import android.util.Log; + +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.TimeZone; + + +public enum ContentOperation +{ + /** + * When the local timezone has been changed we need to update the due and start sorting values. This handler will take care of running the appropriate + * update. In addition it fires an operation to update all notifications. + */ + UPDATE_TIMEZONE(new OperationHandler() + { + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + long start = System.currentTimeMillis(); + + // request an update of all instance values + ContentValues vals = new ContentValues(1); + Instantiating.addUpdateRequest(vals); + + // execute update that triggers a recalculation of all due and start sorting values + int count = context.getContentResolver().update( + TaskContract.Tasks.getContentUri(uri.getAuthority()).buildUpon().appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true").build(), + vals, null, null); + + Log.i("TaskProvider", "time to update " + count + " tasks: " + (System.currentTimeMillis() - start) + " ms"); + + // now update alarms as well + UPDATE_NOTIFICATION_ALARM.fire(context, null); + } + }), + + /** + * Takes care of everything we need to send task start and task due broadcasts. + */ + POST_NOTIFICATIONS(new OperationHandler() + { + + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + TimeZone localTimeZone = TimeZone.getDefault(); + + // the date-time of when the last notification was shown + DateTime lastAlarm = getLastAlarmTimestamp(context); + // the current time, we show all notifications between and now + DateTime now = DateTime.nowAndHere(); + + String lastAlarmString = Long.toString(lastAlarm.getInstance()); + String nowString = Long.toString(now.getInstance()); + + // load all tasks that have started or became due since the last time we've shown a notification. + Cursor instancesCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, "((" + TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " + + TaskContract.Instances.INSTANCE_DUE_SORTING + "<=?) or (" + TaskContract.Instances.INSTANCE_START_SORTING + ">? and " + + TaskContract.Instances.INSTANCE_START_SORTING + "<=?)) and " + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { + lastAlarmString, nowString, lastAlarmString, nowString }, null, null, null); + + try + { + while (instancesCursor.moveToNext()) + { + InstanceAdapter task = new CursorContentValuesInstanceAdapter(InstanceAdapter._ID.getFrom(instancesCursor), instancesCursor, null); + + DateTime instanceDue = task.valueOf(InstanceAdapter.INSTANCE_DUE); + if (instanceDue != null && !instanceDue.isFloating()) + { + // make sure we compare instances in local time + instanceDue = instanceDue.shiftTimeZone(localTimeZone); + } + + DateTime instanceStart = task.valueOf(InstanceAdapter.INSTANCE_START); + if (instanceStart != null && !instanceStart.isFloating()) + { + // make sure we compare instances in local time + instanceStart = instanceStart.shiftTimeZone(localTimeZone); + } + + if (instanceDue != null && lastAlarm.getInstance() < instanceDue.getInstance() && instanceDue.getInstance() <= now.getInstance()) + { + // this task became due since the last alarm, send a due broadcast + sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_DUE, task.uri(uri.getAuthority())); + } + else if (instanceStart != null && lastAlarm.getInstance() < instanceStart.getInstance() && instanceStart.getInstance() <= now.getInstance()) + { + // this task has started since the last alarm, send a start broadcast + sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_STARTING, task.uri(uri.getAuthority())); + } + } + } + finally + { + instancesCursor.close(); + } + + // all notifications up to now have been triggered + saveLastAlarmTime(context, now); + + // set the alarm for the next notification + UPDATE_NOTIFICATION_ALARM.fire(context, null); + } + + + @SuppressLint("NewApi") + private void saveLastAlarmTime(Context context, DateTime time) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Editor editor = prefs.edit(); + editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); + editor.apply(); + } + + + private DateTime getLastAlarmTimestamp(Context context) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); + } + + + /** + * Sends a notification broadcast for a task instance that has started or became due. + * + * @param context + * A {@link Context}. + * @param action + * The broadcast action. + * @param uri + * The task uri. + */ + private void sendBroadcast(Context context, String action, Uri uri) + { + Intent intent = new Intent(action); + intent.setData(uri); + // only notify our own package + intent.setPackage(context.getPackageName()); + context.sendBroadcast(intent); + } + }), + + /** + * Determines the date-time of when the next task becomes due or starts (whatever happens first) and sets an alarm to trigger a notification. + */ + UPDATE_NOTIFICATION_ALARM(new OperationHandler() + { + + @Override + public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) + { + TimeZone localTimeZone = TimeZone.getDefault(); + DateTime lastAlarm = getLastAlarmTimestamp(context); + DateTime now = DateTime.nowAndHere(); + + if (now.before(lastAlarm)) + { + // time went backwards, set last alarm time to now + lastAlarm = now; + saveLastAlarmTime(context, now); + } + + String lastAlarmString = Long.toString(lastAlarm.getInstance()); + + DateTime nextAlarm = null; + + // find the next task that starts + Cursor nextInstanceStartCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_START_SORTING + ">? and " + + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, + TaskContract.Instances.INSTANCE_START_SORTING, "1"); + + try + { + if (nextInstanceStartCursor.moveToNext()) + { + TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceStartCursor), + nextInstanceStartCursor, null); + nextAlarm = task.valueOf(TaskAdapter.INSTANCE_START); + if (!nextAlarm.isFloating()) + { + nextAlarm = nextAlarm.shiftTimeZone(localTimeZone); + } + } + } + finally + { + nextInstanceStartCursor.close(); + } + + // find the next task that's due + Cursor nextInstanceDueCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " + + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, + TaskContract.Instances.INSTANCE_DUE_SORTING, "1"); + + try + { + if (nextInstanceDueCursor.moveToNext()) + { + TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceDueCursor), nextInstanceDueCursor, + null); + DateTime nextDue = task.valueOf(TaskAdapter.INSTANCE_DUE); + if (!nextDue.isFloating()) + { + nextDue = nextDue.shiftTimeZone(localTimeZone); + } + + if (nextAlarm == null || nextAlarm.getInstance() > nextDue.getInstance()) + { + nextAlarm = nextDue; + } + } + } + finally + { + nextInstanceDueCursor.close(); + } + + if (nextAlarm != null) + { + TaskProviderBroadcastReceiver.planNotificationUpdate(context, nextAlarm); + } + else + { + saveLastAlarmTime(context, now); + } + } + + + @SuppressLint("NewApi") + private void saveLastAlarmTime(Context context, DateTime time) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Editor editor = prefs.edit(); + editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); + editor.apply(); + } + + + private DateTime getLastAlarmTimestamp(Context context) + { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); + } + + }); + + /** + * A lock object to serialize the execution of all incoming {@link ContentOperation}. + */ + private final static Object mLock = new Object(); + + /** + * The base path of the Uri to trigger content operations. + */ + private final static String BASE_PATH = "content_operation"; + + /** + * The {@link OperationHandler} that handles this {@link ContentOperation}. + */ + private final OperationHandler mHandler; + + private static final String PREFS_NAME = "org.dmfs.provider.tasks"; + private static final String PREFS_KEY_LAST_ALARM_TIMESTAMP = "org.dmfs.provider.tasks.prefs.LAST_ALARM_TIMESTAMP"; + + + ContentOperation(OperationHandler handler) + { + mHandler = handler; + } + + + /** + * Execute this {@link ContentOperation} with the given values. + * + * @param context + * A {@link Context}. + * @param values + * Optional {@link ContentValues}, may be null. + */ + public void fire(Context context, ContentValues values) + { + context.getContentResolver().update(uri(AuthorityUtil.taskAuthority(context)), values == null ? new ContentValues() : values, null, null); + } + + + /** + * Run the operation on the given handler. + * + * @param context + * A {@link Context}. + * @param handler + * A {@link Handler} to run the operation on. + * @param uri + * The {@link Uri} that triggered this operation. + * @param db + * The database. + * @param values + * The {@link ContentValues} that were supplied. + */ + void run(final Context context, Handler handler, final Uri uri, final SQLiteDatabase db, final ContentValues values) + { + handler.post(new Runnable() + { + @Override + public void run() + { + synchronized (mLock) + { + mHandler.handleOperation(context, uri, db, values); + } + } + }); + } + + + /** + * Returns the {@link Uri} that triggers this {@link ContentOperation}. + * + * @param authority + * The authority of this provide. + * + * @return A {@link Uri}. + */ + private Uri uri(String authority) + { + return new Uri.Builder().scheme("content").authority(authority).path(BASE_PATH).appendPath(this.toString()).build(); + } + + + /** + * Register the operations with the given {@link UriMatcher}. + * + * @param uriMatcher + * The {@link UriMatcher}. + * @param authority + * The authority of this TaskProvider. + * @param firstID + * Teh first Id to use for our Uris. + */ + public static void register(UriMatcher uriMatcher, String authority, int firstID) + { + for (ContentOperation op : values()) + { + Uri uri = op.uri(authority); + uriMatcher.addURI(authority, uri.getPath().substring(1) /* remove leading slash */, firstID + op.ordinal()); + } + } + + + /** + * Return a {@link ContentOperation} that belongs to the given id. + * + * @param id + * The id or the {@link ContentOperation}. + * @param firstId + * The first ID to use for Uris. + * + * @return The respective {@link ContentOperation} or null if none was found. + */ + public static ContentOperation get(int id, int firstId) + { + if (id < firstId) + { + return null; + } + + if (id - firstId >= values().length) + { + return null; + } + + return values()[id - firstId]; + } + + + public interface OperationHandler + { + void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java new file mode 100644 index 0000000..1093a4f --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java @@ -0,0 +1,630 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import org.dmfs.jems.iterable.decorators.Chunked; +import org.dmfs.ngrams.NGramGenerator; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.TaskColumns; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +/** + * Supports the {@link TaskDatabaseHelper} in the matter of full-text-search. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ +public class FTSDatabaseHelper +{ + /** + * We search the ngram table in chunks of 500. This should be good enough for an average task but still well below + * the SQLITE expression length limit and the variable count limit. + */ + private final static int NGRAM_SEARCH_CHUNK_SIZE = 500; + + private final static float SEARCH_RESULTS_MIN_SCORE = 0.33f; + + /** + * A Generator for 3-grams. + */ + private final static NGramGenerator TRIGRAM_GENERATOR = new NGramGenerator(3, 1).setAddSpaceInFront(true); + + /** + * A Generator for 4-grams. + */ + private final static NGramGenerator TETRAGRAM_GENERATOR = new NGramGenerator(4, 3 /* shorter words are fully covered by trigrams */).setAddSpaceInFront( + true); + private static final String PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s = ?", FTSContentColumns.TASK_ID, FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String NON_PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s is null", FTSContentColumns.TASK_ID, + FTSContentColumns.TYPE, + FTSContentColumns.PROPERTY_ID); + private static final String[] NGRAM_SYNC_COLUMNS = { "_rowid_", FTSContentColumns.NGRAM_ID }; + + + /** + * Search content columns. Defines all the columns for the full text search + * + * @author Tobias Reinsch + */ + public interface FTSContentColumns + { + /** + * The row id of the belonging task. + */ + String TASK_ID = "fts_task_id"; + + /** + * The the property id of the searchable entry or null if the entry is not related to a property. + */ + String PROPERTY_ID = "fts_property_id"; + + /** + * The the type of the searchable entry + */ + String TYPE = "fts_type"; + + /** + * An n-gram for a task. + */ + String NGRAM_ID = "fts_ngram_id"; + + } + + + /** + * The columns of the N-gram table for the FTS search + * + * @author Tobias Reinsch + */ + public interface NGramColumns + { + /** + * The row id of the N-gram. + */ + String NGRAM_ID = "ngram_id"; + + /** + * The content of the N-gram + */ + String TEXT = "ngram_text"; + + } + + + public static final String FTS_CONTENT_TABLE = "FTS_Content"; + public static final String FTS_NGRAM_TABLE = "FTS_Ngram"; + public static final String FTS_TASK_VIEW = "FTS_Task_View"; + public static final String FTS_TASK_PROPERTY_VIEW = "FTS_Task_Property_View"; + + /** + * SQL command to create the table for full text search and contains relationships between ngrams and tasks + */ + private final static String SQL_CREATE_SEARCH_CONTENT_TABLE = "CREATE TABLE " + FTS_CONTENT_TABLE + "( " + FTSContentColumns.TASK_ID + " Integer, " + + FTSContentColumns.NGRAM_ID + " Integer, " + FTSContentColumns.PROPERTY_ID + " Integer, " + FTSContentColumns.TYPE + " Integer, " + "FOREIGN KEY(" + + FTSContentColumns.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ")," + "FOREIGN KEY(" + FTSContentColumns.TASK_ID + + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ") UNIQUE (" + FTSContentColumns.TASK_ID + ", " + FTSContentColumns.TYPE + ", " + + FTSContentColumns.PROPERTY_ID + ") ON CONFLICT IGNORE )"; + + /** + * SQL command to create the table that stores the NGRAMS + */ + private final static String SQL_CREATE_NGRAM_TABLE = "CREATE TABLE " + FTS_NGRAM_TABLE + "( " + NGramColumns.NGRAM_ID + + " Integer PRIMARY KEY AUTOINCREMENT, " + NGramColumns.TEXT + " Text)"; + + // FIXME: at present the minimum score is hard coded can we leave that decision to the caller? + private final static String SQL_RAW_QUERY_SEARCH_TASK = "SELECT %s " + ", (1.0*count(DISTINCT " + NGramColumns.NGRAM_ID + ")/?) as " + TaskContract.Tasks.SCORE + " from " + + FTS_NGRAM_TABLE + " join " + FTS_CONTENT_TABLE + " on (" + FTS_NGRAM_TABLE + "." + NGramColumns.NGRAM_ID + "=" + FTS_CONTENT_TABLE + "." + + FTSContentColumns.NGRAM_ID + ") join " + Tables.INSTANCE_VIEW + " on (" + Tables.INSTANCE_VIEW + "." + TaskContract.Instances.TASK_ID + " = " + FTS_CONTENT_TABLE + "." + + FTSContentColumns.TASK_ID + ") where %s group by " + TaskContract.Instances.TASK_ID + " having " + TaskContract.Tasks.SCORE + " >= " + SEARCH_RESULTS_MIN_SCORE + + " and " + Tasks.VISIBLE + " = 1 order by %s;"; + + private final static String SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION = Tables.INSTANCE_VIEW + ".* ," + FTS_NGRAM_TABLE + "." + NGramColumns.TEXT; + + private final static String SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER = "CREATE TRIGGER search_task_delete_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " + + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Tasks._ID + "; END"; + + private final static String SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER = "CREATE TRIGGER search_task_delete_property_trigger AFTER DELETE ON " + + Tables.PROPERTIES + " BEGIN " + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Properties.TASK_ID + + " AND " + FTSContentColumns.PROPERTY_ID + " = old." + Properties.PROPERTY_ID + "; END"; + + + /** + * The different types of searchable entries for tasks linked to the TYPE column. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ + public interface SearchableTypes + { + /** + * This is an entry for the title of a task. + */ + int TITLE = 1; + + /** + * This is an entry for the description of a task. + */ + int DESCRIPTION = 2; + + /** + * This is an entry for the location of a task. + */ + int LOCATION = 3; + + /** + * This is an entry for a property of a task. + */ + int PROPERTY = 4; + + } + + + public static void onCreate(SQLiteDatabase db) + { + initializeFTS(db); + } + + + public static void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) + { + if (oldVersion < 8) + { + initializeFTS(db); + initializeFTSContent(db); + } + if (oldVersion < 16) + { + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, + FTSContentColumns.PROPERTY_ID)); + } + } + + + /** + * Creates the tables and triggers used in FTS. + * + * @param db + * The {@link SQLiteDatabase}. + */ + private static void initializeFTS(SQLiteDatabase db) + { + db.execSQL(SQL_CREATE_SEARCH_CONTENT_TABLE); + db.execSQL(SQL_CREATE_NGRAM_TABLE); + db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER); + db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER); + + // create indices + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_NGRAM_TABLE, true, NGramColumns.TEXT)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.NGRAM_ID)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.TASK_ID)); + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.PROPERTY_ID, FTSContentColumns.TASK_ID, + FTSContentColumns.NGRAM_ID)); + + db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, + FTSContentColumns.PROPERTY_ID)); + + } + + + /** + * Creates the FTS entries for the existing tasks. + * + * @param db + * The writable {@link SQLiteDatabase}. + */ + private static void initializeFTSContent(SQLiteDatabase db) + { + String[] task_projection = new String[] { Tasks._ID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION }; + Cursor c = db.query(Tables.TASKS_PROPERTY_VIEW, task_projection, null, null, null, null, null); + while (c.moveToNext()) + { + insertTaskFTSEntries(db, c.getLong(0), c.getString(1), c.getString(2), c.getString(3)); + } + c.close(); + } + + + /** + * Inserts the searchable texts of the task in the database. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * The row id of the task. + * @param title + * The title of the task. + * @param description + * The description of the task. + */ + private static void insertTaskFTSEntries(SQLiteDatabase db, long taskId, String title, String description, String location) + { + // title + if (title != null && title.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.TITLE, title); + } + + // location + if (location != null && location.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.LOCATION, location); + } + + // description + if (description != null && description.length() > 0) + { + updateEntry(db, taskId, -1, SearchableTypes.DESCRIPTION, description); + } + + } + + + /** + * Updates the existing searchables entries for the task. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param task + * The {@link TaskAdapter} containing the new values. + */ + public static void updateTaskFTSEntries(SQLiteDatabase db, TaskAdapter task) + { + // title + if (task.isUpdated(TaskAdapter.TITLE)) + { + updateEntry(db, task.id(), -1, SearchableTypes.TITLE, task.valueOf(TaskAdapter.TITLE)); + } + + // location + if (task.isUpdated(TaskAdapter.LOCATION)) + { + updateEntry(db, task.id(), -1, SearchableTypes.LOCATION, task.valueOf(TaskAdapter.LOCATION)); + } + + // description + if (task.isUpdated(TaskAdapter.DESCRIPTION)) + { + updateEntry(db, task.id(), -1, SearchableTypes.DESCRIPTION, task.valueOf(TaskAdapter.DESCRIPTION)); + } + + } + + + /** + * Updates or creates the searchable entries for a property. Passing null as searchable text will remove the entry. + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * the row id of the task this property belongs to. + * @param propertyId + * the id of the property + * @param searchableText + * the searchable text value of the property + */ + public static void updatePropertyFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String searchableText) + { + updateEntry(db, taskId, propertyId, SearchableTypes.PROPERTY, searchableText); + } + + + /** + * Returns the IDs of each of the provided ngrams, creating them in th database if necessary. + * + * @param db + * A writable {@link SQLiteDatabase}. + * @param ngrams + * The NGrams. + * + * @return The ids of the ngrams in the given set. + */ + private static Set ngramIds(SQLiteDatabase db, Set ngrams) + { + if (ngrams.size() == 0) + { + return Collections.emptySet(); + } + + Set missingNgrams = new HashSet<>(ngrams); + Set ngramIds = new HashSet<>(ngrams.size() * 2); + + for (Iterable chunk : new Chunked<>(NGRAM_SEARCH_CHUNK_SIZE, ngrams)) + { + // build selection and arguments for each chunk + // we can't do this in a single query because the length of sql statement and number of arguments is limited. + + StringBuilder selection = new StringBuilder(NGramColumns.TEXT); + selection.append(" in ("); + boolean first = true; + List arguments = new ArrayList<>(NGRAM_SEARCH_CHUNK_SIZE); + for (String ngram : chunk) + { + if (first) + { + first = false; + } + else + { + selection.append(","); + } + selection.append("?"); + arguments.add(ngram); + } + selection.append(" )"); + + try (Cursor c = db.query(FTS_NGRAM_TABLE, new String[] { NGramColumns.NGRAM_ID, NGramColumns.TEXT }, selection.toString(), + arguments.toArray(new String[0]), null, null, null)) + { + while (c.moveToNext()) + { + // remove the ngrams we already have in the table + missingNgrams.remove(c.getString(1)); + // remember its id + ngramIds.add(c.getLong(0)); + } + } + } + + ContentValues values = new ContentValues(1); + + // now insert the missing ngrams and store their ids + for (String ngram : missingNgrams) + { + values.put(NGramColumns.TEXT, ngram); + ngramIds.add(db.insert(FTS_NGRAM_TABLE, null, values)); + } + return ngramIds; + + } + + + private static void updateEntry(SQLiteDatabase db, long taskId, long propertyId, int type, String searchableText) + { + // generate nGrams + Set propertyNgrams = TRIGRAM_GENERATOR.getNgrams(searchableText); + propertyNgrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchableText)); + + // get an ID for each of the Ngrams. + Set ngramIds = ngramIds(db, propertyNgrams); + + // unlink unused ngrams from the task and get the missing ones we have to link to the tak + Set missing = syncNgrams(db, taskId, propertyId, type, ngramIds); + + // insert ngram relations for all new ngrams + addNgrams(db, missing, taskId, propertyId, type); + } + + + /** + * Inserts NGrams relations for a task entry. + * + * @param db + * A writable {@link SQLiteDatabase}. + * @param ngramIds + * The set of NGram ids. + * @param taskId + * The row id of the task. + * @param propertyId + * The row id of the property. + */ + private static void addNgrams(SQLiteDatabase db, Set ngramIds, long taskId, Long propertyId, int contentType) + { + ContentValues values = new ContentValues(4); + for (Long ngramId : ngramIds) + { + values.put(FTSContentColumns.TASK_ID, taskId); + values.put(FTSContentColumns.NGRAM_ID, ngramId); + values.put(FTSContentColumns.TYPE, contentType); + if (contentType == SearchableTypes.PROPERTY) + { + values.put(FTSContentColumns.PROPERTY_ID, propertyId); + } + else + { + values.putNull(FTSContentColumns.PROPERTY_ID); + } + db.insert(FTS_CONTENT_TABLE, null, values); + } + + } + + + /** + * Synchronizes the NGram relations of a task + * + * @param db + * The writable {@link SQLiteDatabase}. + * @param taskId + * The task row id. + * @param propertyId + * The property row id, ignored if contentType is not {@link SearchableTypes#PROPERTY}. + * @param contentType + * The {@link SearchableTypes} type. + * @param ngramsIds + * The set of ngrams ids which should be linked to the task + * + * @return The number of deleted relations. + */ + private static Set syncNgrams(SQLiteDatabase db, long taskId, long propertyId, int contentType, Set ngramsIds) + { + String selection; + String[] selectionArgs; + if (SearchableTypes.PROPERTY == contentType) + { + selection = PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType), String.valueOf(propertyId) }; + } + else + { + selection = NON_PROPERTY_NGRAM_SELECTION; + selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType) }; + } + + // In order to sync the ngrams, we go over each existing ngram and delete ngram relations not in the set of new ngrams + // Then we return the set of ngrams we didn't find + Set missing = new HashSet<>(ngramsIds); + try (Cursor c = db.query(FTS_CONTENT_TABLE, NGRAM_SYNC_COLUMNS, selection, selectionArgs, null, null, null)) + { + while (c.moveToNext()) + { + Long ngramId = c.getLong(1); + if (!ngramsIds.contains(ngramId)) + { + db.delete(FTS_CONTENT_TABLE, "_rowid_ = ?", new String[] { c.getString(0) }); + } + else + { + // this ngram wasn't missing + missing.remove(ngramId); + } + } + } + return missing; + } + + + /** + * Queries the task database to get a cursor with the search results. + * + * @param db + * The {@link SQLiteDatabase}. + * @param searchString + * The search query string. + * @param projection + * The database projection for the query. + * @param selection + * The selection for the query. + * @param selectionArgs + * The arguments for the query. + * @param sortOrder + * The sorting order of the query. + * + * @return A cursor of the task database with the search result. + */ + public static Cursor getTaskSearchCursor(SQLiteDatabase db, String searchString, String[] projection, String selection, String[] selectionArgs, + String sortOrder) + { + + StringBuilder selectionBuilder = new StringBuilder(1024); + + if (!TextUtils.isEmpty(selection)) + { + selectionBuilder.append(" ("); + selectionBuilder.append(selection); + selectionBuilder.append(") AND ("); + } + else + { + selectionBuilder.append(" ("); + } + + Set ngrams = TRIGRAM_GENERATOR.getNgrams(searchString); + ngrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchString)); + + String[] queryArgs; + + if (searchString != null && searchString.length() > 1) + { + + selectionBuilder.append(NGramColumns.TEXT); + selectionBuilder.append(" in ("); + + for (int i = 0, count = ngrams.size(); i < count; ++i) + { + if (i > 0) + { + selectionBuilder.append(","); + } + selectionBuilder.append("?"); + + } + + // selection arguments + if (selectionArgs != null && selectionArgs.length > 0) + { + queryArgs = new String[selectionArgs.length + ngrams.size() + 1]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); + String[] ngramArray = ngrams.toArray(new String[ngrams.size()]); + System.arraycopy(ngramArray, 0, queryArgs, selectionArgs.length + 1, ngramArray.length); + } + else + { + String[] temp = ngrams.toArray(new String[ngrams.size()]); + + queryArgs = new String[temp.length + 1]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(temp, 0, queryArgs, 1, temp.length); + } + selectionBuilder.append(" ) "); + } + else + { + selectionBuilder.append(NGramColumns.TEXT); + selectionBuilder.append(" like ?"); + + // selection arguments + if (selectionArgs != null && selectionArgs.length > 0) + { + queryArgs = new String[selectionArgs.length + 2]; + queryArgs[0] = String.valueOf(ngrams.size()); + System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); + queryArgs[queryArgs.length - 1] = " " + searchString + "%"; + } + else + { + queryArgs = new String[2]; + queryArgs[0] = String.valueOf(ngrams.size()); + queryArgs[1] = " " + searchString + "%"; + } + + } + + selectionBuilder.append(") AND "); + selectionBuilder.append(Tasks._DELETED); + selectionBuilder.append(" = 0"); + + if (sortOrder == null) + { + sortOrder = Tasks.SCORE + " desc"; + } + else + { + sortOrder = Tasks.SCORE + " desc, " + sortOrder; + } + Cursor c = db.rawQueryWithFactory(null, + String.format(SQL_RAW_QUERY_SEARCH_TASK, SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION, selectionBuilder.toString(), sortOrder), queryArgs, + null); + return c; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java new file mode 100644 index 0000000..2d50bfd --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +/** + * @author Marten Gajda + */ +public enum ProviderOperation +{ + + /** + * Insert operations. + */ + INSERT, + + /** + * Update operations. + */ + UPDATE, + + /** + * Delete operations. + */ + DELETE +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java new file mode 100644 index 0000000..21c54ae --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.utils.Profiled; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + + +/** + * General purpose {@link ContentProvider} base class that uses SQLiteDatabase for storage. + */ +/* + * Changed by marten@dmfs.org: + * + * removed protected mDb field and replaced it by local fields. There is no reason to store the database if we get a new one for every transaction. Instead we + * also pass the database to the *InTransaction methods. + * + * update visibility of class and methods + */ +abstract class SQLiteContentProvider extends ContentProvider +{ + + interface TransactionEndTask + { + void execute(SQLiteDatabase database); + } + + + @SuppressWarnings("unused") + private static final String TAG = "SQLiteContentProvider"; + + private SQLiteOpenHelper mOpenHelper; + private final Set mChangedUris = new HashSet<>(); + + private final ThreadLocal mApplyingBatch = new ThreadLocal(); + private static final int SLEEP_AFTER_YIELD_DELAY = 4000; + + /** + * Maximum number of operations allowed in a batch between yield points. + */ + private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500; + + private final Iterable mTransactionEndTasks; + + + protected SQLiteContentProvider(Iterable transactionEndTasks) + { + // append a task to set the transaction to successful + mTransactionEndTasks = new Joined<>(transactionEndTasks, new SingletonIterable<>(new SuccessfulTransactionEndTask())); + } + + + @Override + public boolean onCreate() + { + mOpenHelper = getDatabaseHelper(getContext()); + return true; + } + + + /** + * Returns a {@link SQLiteOpenHelper} that can open the database. + */ + protected abstract SQLiteOpenHelper getDatabaseHelper(Context context); + + /** + * The equivalent of the {@link #insert} method, but invoked within a transaction. + */ + public abstract Uri insertInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #update} method, but invoked within a transaction. + */ + public abstract int updateInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #delete} method, but invoked within a transaction. + */ + public abstract int deleteInTransaction(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, boolean callerIsSyncAdapter); + + + /** + * Call this to add a URI to the list of URIs to be notified when the transaction is committed. + */ + protected void postNotifyUri(Uri uri) + { + synchronized (mChangedUris) + { + mChangedUris.add(uri); + } + } + + + public boolean isCallerSyncAdapter(Uri uri) + { + return false; + } + + + public SQLiteOpenHelper getDatabaseHelper() + { + return mOpenHelper; + } + + + private boolean applyingBatch() + { + return mApplyingBatch.get() != null && mApplyingBatch.get(); + } + + + @Override + public Uri insert(Uri uri, ContentValues values) + { + return new Profiled("Insert").run((Single) () -> + { + Uri result; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + result = insertInTransaction(db, uri, values, callerIsSyncAdapter); + } + return result; + }); + } + + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) + { + return new Profiled("BulkInsert").run((Single) () -> + { + int numValues = values.length; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try + { + for (int i = 0; i < numValues; i++) + { + insertInTransaction(db, uri, values[i], callerIsSyncAdapter); + db.yieldIfContendedSafely(); + } + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + return numValues; + }); + } + + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) + { + return new Profiled("Update").run((Single) () -> + { + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + }); + } + + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) + { + return new Profiled("Delete").run((Single) () -> + { + int count; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + if (!applyingBatch) + { + db.beginTransaction(); + try + { + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + endTransaction(db); + } + finally + { + db.endTransaction(); + } + onEndTransaction(callerIsSyncAdapter); + } + else + { + count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + }); + } + + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException + { + return new Profiled(String.format(Locale.ENGLISH, "Batch of %d operations", operations.size())).run( + (Fragile) () -> + { + int ypCount = 0; + int opCount = 0; + boolean callerIsSyncAdapter = false; + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try + { + mApplyingBatch.set(true); + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + for (int i = 0; i < numOperations; i++) + { + if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) + { + throw new OperationApplicationException("Too many content provider operations between yield points. " + + "The maximum number of operations per yield point is " + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + } + final ContentProviderOperation operation = operations.get(i); + if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) + { + callerIsSyncAdapter = true; + } + if (i > 0 && operation.isYieldAllowed()) + { + opCount = 0; + if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) + { + ypCount++; + } + } + results[i] = operation.apply(this, results, i); + } + endTransaction(db); + return results; + } + finally + { + mApplyingBatch.set(false); + db.endTransaction(); + onEndTransaction(callerIsSyncAdapter); + } + }); + } + + + protected void onEndTransaction(boolean callerIsSyncAdapter) + { + Set changed; + synchronized (mChangedUris) + { + changed = new HashSet(mChangedUris); + mChangedUris.clear(); + } + ContentResolver resolver = getContext().getContentResolver(); + for (Uri uri : changed) + { + boolean syncToNetwork = !callerIsSyncAdapter && syncToNetwork(uri); + resolver.notifyChange(uri, null, syncToNetwork); + } + } + + + protected boolean syncToNetwork(Uri uri) + { + return false; + } + + + private void endTransaction(SQLiteDatabase database) + { + for (TransactionEndTask task : mTransactionEndTasks) + { + task.execute(database); + } + } + + + /** + * A {@link TransactionEndTask} which sets the transaction to be successful. + */ + private static class SuccessfulTransactionEndTask implements TransactionEndTask + { + @Override + public void execute(SQLiteDatabase database) + { + database.setTransactionSuccessful(); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java new file mode 100644 index 0000000..a78d8c7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java @@ -0,0 +1,895 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.predicate.elementary.Equals; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.processors.NoOpProcessor; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.provider.tasks.utils.TableColumns; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.Property.Alarm; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Locale; + + +/** + * Task database helper takes care of creating and updating the task database, including tables, indices and triggers. + * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public class TaskDatabaseHelper extends SQLiteOpenHelper +{ + + /** + * Interface of a listener that's called when the database has been created or migrated. + */ + public interface OnDatabaseOperationListener + { + void onDatabaseCreated(SQLiteDatabase db); + + void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion); + } + + + private static final String TAG = "TaskDatabaseHelper"; + + /** + * The name of our database file. + */ + private static final String DATABASE_NAME = "tasks.db"; + + /** + * The database version. + */ + private static final int DATABASE_VERSION = 23; + + + /** + * List of all tables we provide. + */ + public interface Tables + { + String LISTS = "Lists"; + + String WRITEABLE_LISTS = "Writeable_Lists"; + + String TASKS = "Tasks"; + + String TASKS_VIEW = "Task_View"; + + String TASKS_PROPERTY_VIEW = "Task_Property_View"; + + String INSTANCES = "Instances"; + + String INSTANCE_VIEW = "Instance_View"; + + String INSTANCE_CLIENT_VIEW = "Instance_Client_View"; + + String INSTANCE_PROPERTY_VIEW = "Instance_Property_View"; + + String INSTANCE_CATEGORY_VIEW = "Instance_Cagetory_View"; + + String CATEGORIES = "Categories"; + + String CATEGORIES_MAPPING = "Categories_Mapping"; + + String PROPERTIES = "Properties"; + + String ALARMS = "Alarms"; + + String SYNCSTATE = "SyncState"; + } + + + /** + * Columns of internal table for the category mapping. + */ + public interface CategoriesMapping + { + String TASK_ID = "task_id"; + + String CATEGORY_ID = "category_id"; + + String PROPERTY_ID = "property_id"; + + } + + + /** + * SQL command to create a view that combines tasks with some data from the list they belong to. + */ + private final static String SQL_CREATE_TASK_VIEW = "create view " + Tables.TASKS_VIEW + " as select " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " from " + Tables.TASKS + " join " + Tables.LISTS + + " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ");"; + + /** + * SQL command to create a view that combines tasks with some data from the list they belong to. + */ + private final static String SQL_CREATE_TASK_PROPERTY_VIEW = "create view " + Tables.TASKS_PROPERTY_VIEW + " as select " + + Tables.TASKS + ".*, " + + Tables.PROPERTIES + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " from " + Tables.TASKS + " join " + Tables.LISTS + + " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ") " + + "left join " + Tables.PROPERTIES + " on (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; + + /** + * SQL command to drop the task view. + */ + private final static String SQL_DROP_TASK_VIEW = "DROP VIEW " + Tables.TASKS_VIEW + ";"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. + */ + private final static String SQL_CREATE_INSTANCE_VIEW = "CREATE VIEW " + Tables.INSTANCE_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. This replaces the task DTSTART, DUE and + * ORIGINAL_INSTANCE_TIME values with respective values of the instance. + *

+ * This is the instances view as seen by the content provider clients. + */ + private final static String SQL_CREATE_INSTANCE_CLIENT_VIEW = "CREATE VIEW " + Tables.INSTANCE_CLIENT_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + // override task due, start and original times with the instance values + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_START + " as " + Tasks.DTSTART + ", " + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_DUE + " as " + Tasks.DUE + ", " + + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " as " + Tasks.ORIGINAL_INSTANCE_TIME + ", " + // override task duration with null, we already have a due + + "null as " + Tasks.DURATION + ", " + // override recurrence values with null, instances themselves are not recurring + + "null as " + Tasks.RRULE + ", " + + "null as " + Tasks.RDATE + ", " + + "null as " + Tasks.EXDATE + ", " + // this instance is part of a recurring task if either it has recurrence values or overrides an instance + + "not (" + Tasks.RRULE + " is null and " + Tasks.RDATE + " is null and " + Tasks.ORIGINAL_INSTANCE_ID + " is null and " + Tasks.ORIGINAL_INSTANCE_SYNC_ID + " is null) as " + TaskContract.Instances.IS_RECURRING + ", " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.TaskLists._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances view with the belonging properties. + */ + private final static String SQL_CREATE_INSTANCE_PROPERTY_VIEW = "CREATE VIEW " + Tables.INSTANCE_PROPERTY_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.PROPERTIES + ".*, " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" + + " LEFT JOIN " + Tables.PROPERTIES + " ON (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; + + /** + * SQL command to create a view that combines task instances with some data from the list they belong to. + */ + private final static String SQL_CREATE_INSTANCE_CATEGORY_VIEW = "CREATE VIEW " + Tables.INSTANCE_CATEGORY_VIEW + " AS SELECT " + + Tables.INSTANCES + ".*, " + + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.CATEGORY_ID + ", " + + Tables.TASKS + ".*, " + + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + + Tables.LISTS + "." + Tasks.LIST_NAME + ", " + + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + + Tables.LISTS + "." + Tasks.VISIBLE + + " FROM " + Tables.TASKS + + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" + + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" + + " LEFT JOIN " + Tables.CATEGORIES_MAPPING + " ON (" + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.TASK_ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; + + /** + * SQL command to drop the instance view. + */ + private final static String SQL_DROP_INSTANCE_VIEW = "DROP VIEW " + Tables.INSTANCE_VIEW + ";"; + + /** + * SQL command to drop the instance property view. + */ + //private final static String SQL_DROP_INSTANCE_PROPERTY_VIEW = "DROP VIEW " + Tables.INSTANCE_PROPERTY_VIEW + ";"; + + /** + * SQL command to create the instances table. + */ + private final static String SQL_CREATE_SYNCSTATE_TABLE = + "CREATE TABLE " + Tables.SYNCSTATE + " ( " + + TaskContract.SyncState._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TaskContract.SyncState.ACCOUNT_NAME + " TEXT, " + + TaskContract.SyncState.ACCOUNT_TYPE + " TEXT, " + + TaskContract.SyncState.DATA + " TEXT " + + ");"; + + /** + * SQL command to create the instances table. + */ + private final static String SQL_CREATE_INSTANCES_TABLE = + "CREATE TABLE " + Tables.INSTANCES + " ( " + + TaskContract.Instances._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TaskContract.Instances.TASK_ID + " INTEGER NOT NULL, " // NOT NULL + + TaskContract.Instances.INSTANCE_START + " INTEGER, " + + TaskContract.Instances.INSTANCE_DUE + " INTEGER, " + + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER, " + + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER, " + + TaskContract.Instances.INSTANCE_DURATION + " INTEGER, " + + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " INTEGER DEFAULT 0, " + + TaskContract.Instances.DISTANCE_FROM_CURRENT + " INTEGER DEFAULT 0);"; + + /** + * SQL command to create a trigger to clean up data of removed tasks. + */ + private final static String SQL_CREATE_TASKS_CLEANUP_TRIGGER = + "CREATE TRIGGER task_cleanup_trigger AFTER DELETE ON " + Tables.TASKS + + " BEGIN " + + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + TaskContract.Properties.TASK_ID + "= old." + TaskContract.Tasks._ID + ";" + + " DELETE FROM " + Tables.INSTANCES + " WHERE " + TaskContract.Instances.TASK_ID + "=old." + TaskContract.Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up data of removed lists. + */ + private final static String SQL_CREATE_LISTS_CLEANUP_TRIGGER = + "CREATE TRIGGER list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS + + " BEGIN " + + " DELETE FROM " + Tables.TASKS + " WHERE " + Tasks.LIST_ID + "= old." + TaskLists._ID + ";" + + " END;"; + + /** + * SQL command to drop the clean up trigger. + */ + private final static String SQL_DROP_TASKS_CLEANUP_TRIGGER = + "DROP TRIGGER task_cleanup_trigger;"; + + /** + * SQL command that counts and sets the alarm on deletion + */ + private final static String SQL_COUNT_ALARMS_ON_DELETE = + " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS + + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES + + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = OLD." + Properties.TASK_ID + + ") WHERE " + Tasks._ID + " = OLD." + Properties.TASK_ID + + "; END;"; + + /** + * SQL command that counts and sets the alarm on insert and update + */ + private final static String SQL_COUNT_ALARMS = + " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS + + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES + + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = NEW." + Properties.TASK_ID + + ") WHERE " + Tasks._ID + " = NEW." + Properties.TASK_ID + + "; END;"; + + /** + * SQL command to create a trigger that counts the alarms for a task on create + */ + private final static String SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER = + "CREATE TRIGGER alarm_count_create_trigger AFTER INSERT ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS; + + /** + * SQL command to create a trigger that counts the alarms for a task on update + */ + private final static String SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER = + "CREATE TRIGGER alarm_count_update_trigger AFTER UPDATE ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS; + + /** + * SQL command to create a trigger that counts the alarms for a task on delete + */ + private final static String SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER = + "CREATE TRIGGER alarm_count_delete_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + SQL_COUNT_ALARMS_ON_DELETE; + + /** + * SQL command to create a trigger to clean up data of removed property. + */ + private final static String SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER alarm_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" + + " BEGIN " + + " DELETE FROM " + Tables.ALARMS + " WHERE " + TaskContract.Alarms.ALARM_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up data of removed property. + */ + private final static String SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER category_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Category.CONTENT_ITEM_TYPE + "'" + + " BEGIN " + + " DELETE FROM " + Tables.CATEGORIES_MAPPING + " WHERE " + CategoriesMapping.PROPERTY_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to clean up property data of removed task. + */ + private final static String SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER = + "CREATE TRIGGER task_property_cleanup_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " + + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + Properties.TASK_ID + "= OLD." + Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create a trigger to increment task version number on every update. + */ + private final static String SQL_CREATE_TASK_VERSION_TRIGGER = + "CREATE TRIGGER task_version_trigger BEFORE UPDATE ON " + Tables.TASKS + " BEGIN " + + " UPDATE " + Tables.TASKS + " SET " + Tasks.VERSION + " = OLD." + Tasks.VERSION + " + 1 where " + Tasks._ID + " = NEW." + Tasks._ID + ";" + + " END;"; + + /** + * SQL command to create the task list table. + */ + private final static String SQL_CREATE_LISTS_TABLE = + "CREATE TABLE " + Tables.LISTS + " ( " + + TaskContract.TaskLists._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.TaskLists.ACCOUNT_NAME + " TEXT," + + TaskContract.TaskLists.ACCOUNT_TYPE + " TEXT," + + TaskContract.TaskLists.LIST_NAME + " TEXT," + + TaskContract.TaskLists.LIST_COLOR + " INTEGER," + + TaskContract.TaskLists.ACCESS_LEVEL + " INTEGER," + + TaskContract.TaskLists.VISIBLE + " INTEGER," + + TaskContract.TaskLists.SYNC_ENABLED + " INTEGER," + + TaskContract.TaskLists.OWNER + " TEXT," + + TaskContract.TaskLists._DIRTY + " INTEGER DEFAULT 0," + + TaskContract.TaskLists._SYNC_ID + " TEXT," + + TaskContract.TaskLists.SYNC_VERSION + " TEXT," + + TaskContract.TaskLists.SYNC1 + " TEXT," + + TaskContract.TaskLists.SYNC2 + " TEXT," + + TaskContract.TaskLists.SYNC3 + " TEXT," + + TaskContract.TaskLists.SYNC4 + " TEXT," + + TaskContract.TaskLists.SYNC5 + " TEXT," + + TaskContract.TaskLists.SYNC6 + " TEXT," + + TaskContract.TaskLists.SYNC7 + " TEXT," + + TaskContract.TaskLists.SYNC8 + " TEXT);"; + + /** + * SQL command to create the task table. + */ + private final static String SQL_CREATE_TASKS_TABLE = + "CREATE TABLE " + Tables.TASKS + " ( " + + TaskContract.Tasks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Tasks.VERSION + " INTEGER DEFAULT 0," + + TaskContract.Tasks.LIST_ID + " INTEGER NOT NULL, " + + TaskContract.Tasks.TITLE + " TEXT," + + TaskContract.Tasks.LOCATION + " TEXT," + + TaskContract.Tasks.GEO + " TEXT," + + TaskContract.Tasks.DESCRIPTION + " TEXT," + + TaskContract.Tasks.URL + " TEXT," + + TaskContract.Tasks.ORGANIZER + " TEXT," + + TaskContract.Tasks.PRIORITY + " INTEGER, " + + TaskContract.Tasks.TASK_COLOR + " INTEGER," + + TaskContract.Tasks.CLASSIFICATION + " INTEGER," + + TaskContract.Tasks.COMPLETED + " INTEGER," + + TaskContract.Tasks.COMPLETED_IS_ALLDAY + " INTEGER," + + TaskContract.Tasks.PERCENT_COMPLETE + " INTEGER," + + TaskContract.Tasks.STATUS + " INTEGER DEFAULT " + TaskContract.Tasks.STATUS_DEFAULT + "," + + TaskContract.Tasks.IS_NEW + " INTEGER," + + TaskContract.Tasks.IS_CLOSED + " INTEGER," + + TaskContract.Tasks.DTSTART + " INTEGER," + + TaskContract.Tasks.CREATED + " INTEGER," + + TaskContract.Tasks.LAST_MODIFIED + " INTEGER," + + TaskContract.Tasks.IS_ALLDAY + " INTEGER," + + TaskContract.Tasks.TZ + " TEXT," + + TaskContract.Tasks.DUE + " INTEGER," + + TaskContract.Tasks.DURATION + " TEXT," + + TaskContract.Tasks.RDATE + " TEXT," + + TaskContract.Tasks.EXDATE + " TEXT," + + TaskContract.Tasks.RRULE + " TEXT," + + TaskContract.Tasks.PARENT_ID + " INTEGER," + + TaskContract.Tasks.SORTING + " TEXT," + + TaskContract.Tasks.HAS_ALARMS + " INTEGER," + + TaskContract.Tasks.HAS_PROPERTIES + " INTEGER," + + TaskContract.Tasks.PINNED + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + " TEXT," + + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME + " INTEGER," + + TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY + " INTEGER," + + TaskContract.Tasks._DIRTY + " INTEGER DEFAULT 1," // a new task is always dirty + + TaskContract.Tasks._DELETED + " INTEGER DEFAULT 0," // new tasks are not deleted by default + + TaskContract.Tasks._SYNC_ID + " TEXT," + + TaskContract.Tasks._UID + " TEXT," + + TaskContract.Tasks.SYNC_VERSION + " TEXT," + + TaskContract.Tasks.SYNC1 + " TEXT," + + TaskContract.Tasks.SYNC2 + " TEXT," + + TaskContract.Tasks.SYNC3 + " TEXT," + + TaskContract.Tasks.SYNC4 + " TEXT," + + TaskContract.Tasks.SYNC5 + " TEXT," + + TaskContract.Tasks.SYNC6 + " TEXT," + + TaskContract.Tasks.SYNC7 + " TEXT," + + TaskContract.Tasks.SYNC8 + " TEXT);"; + + /** + * SQL command to create the categories table. + */ + private final static String SQL_CREATE_CATEGORIES_TABLE = + "CREATE TABLE " + Tables.CATEGORIES + + " ( " + TaskContract.Categories._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Categories.ACCOUNT_NAME + " TEXT," + + TaskContract.Categories.ACCOUNT_TYPE + " TEXT," + + TaskContract.Categories.NAME + " TEXT," + + TaskContract.Categories.COLOR + " INTEGER);"; + + /** + * SQL command to create the categories table. + */ + private final static String SQL_CREATE_CATEGORIES_MAPPING_TABLE = + "CREATE TABLE " + Tables.CATEGORIES_MAPPING + + " ( " + CategoriesMapping.TASK_ID + " INTEGER," + + CategoriesMapping.CATEGORY_ID + " INTEGER," + + CategoriesMapping.PROPERTY_ID + " INTEGER," + + "FOREIGN KEY (" + CategoriesMapping.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskContract.Tasks._ID + ")," + + "FOREIGN KEY (" + CategoriesMapping.PROPERTY_ID + ") REFERENCES " + Tables.PROPERTIES + "(" + TaskContract.Properties.PROPERTY_ID + ")," + + "FOREIGN KEY (" + CategoriesMapping.CATEGORY_ID + ") REFERENCES " + Tables.CATEGORIES + "(" + TaskContract.Categories._ID + "));"; + + /** + * SQL command to create the alarms table the stores the already triggered alarms. + */ + private final static String SQL_CREATE_ALARMS_TABLE = + "CREATE TABLE " + Tables.ALARMS + + " ( " + TaskContract.Alarms.ALARM_ID + " INTEGER," + + TaskContract.Alarms.LAST_TRIGGER + " TEXT," + + TaskContract.Alarms.NEXT_TRIGGER + " TEXT);"; + + /** + * SQL command to create the table for extended properties. + */ + private final static String SQL_CREATE_PROPERTIES_TABLE = + "CREATE TABLE " + Tables.PROPERTIES + " ( " + + TaskContract.Properties.PROPERTY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + TaskContract.Properties.TASK_ID + " INTEGER," + + TaskContract.Properties.MIMETYPE + " INTEGER," + + TaskContract.Properties.VERSION + " INTEGER," + + TaskContract.Properties.DATA0 + " TEXT," + + TaskContract.Properties.DATA1 + " TEXT," + + TaskContract.Properties.DATA2 + " TEXT," + + TaskContract.Properties.DATA3 + " TEXT," + + TaskContract.Properties.DATA4 + " TEXT," + + TaskContract.Properties.DATA5 + " TEXT," + + TaskContract.Properties.DATA6 + " TEXT," + + TaskContract.Properties.DATA7 + " TEXT," + + TaskContract.Properties.DATA8 + " TEXT," + + TaskContract.Properties.DATA9 + " TEXT," + + TaskContract.Properties.DATA10 + " TEXT," + + TaskContract.Properties.DATA11 + " TEXT," + + TaskContract.Properties.DATA12 + " TEXT," + + TaskContract.Properties.DATA13 + " TEXT," + + TaskContract.Properties.DATA14 + " TEXT," + + TaskContract.Properties.DATA15 + " TEXT," + + TaskContract.Properties.SYNC1 + " TEXT," + + TaskContract.Properties.SYNC2 + " TEXT," + + TaskContract.Properties.SYNC3 + " TEXT," + + TaskContract.Properties.SYNC4 + " TEXT," + + TaskContract.Properties.SYNC5 + " TEXT," + + TaskContract.Properties.SYNC6 + " TEXT," + + TaskContract.Properties.SYNC7 + " TEXT," + + TaskContract.Properties.SYNC8 + " TEXT);"; + + /** + * SQL command to drop the task view. + */ + private final static String SQL_DROP_PROPERTIES_TABLE = "DROP TABLE " + Tables.PROPERTIES + ";"; + + + /** + * Builds a string that creates an index on the given table for the given columns. + * + * @param table + * The table to create the index on. + * @param fields + * The fields to index. + * + * @return An SQL command string. + */ + public static String createIndexString(String table, boolean unique, String... fields) + { + if (fields == null || fields.length < 1) + { + throw new IllegalArgumentException("need at least one field to build an index!"); + } + + StringBuffer buffer = new StringBuffer(); + + // Index name is constructed like this: tablename_fields[0]_idx + buffer.append("CREATE "); + if (unique) + { + buffer.append(" UNIQUE "); + } + buffer.append("INDEX IF NOT EXISTS "); + buffer.append(table).append("_").append(fields[0]).append("_idx ON "); + buffer.append(table).append(" ("); + buffer.append(fields[0]); + for (int i = 1; i < fields.length; i++) + { + buffer.append(", ").append(fields[i]); + } + buffer.append(");"); + + return buffer.toString(); + + } + + + private final OnDatabaseOperationListener mListener; + + + TaskDatabaseHelper(Context context, OnDatabaseOperationListener listener) + { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + mListener = listener; + } + + + /** + * Creates the tables, views, triggers and indices. + *

+ * TODO: move all strings to separate final static variables. + */ + @Override + public void onCreate(SQLiteDatabase db) + { + + // create task list table + db.execSQL(SQL_CREATE_LISTS_TABLE); + + // trigger that removes tasks of a list that has been removed + db.execSQL("CREATE TRIGGER task_list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS + " BEGIN DELETE FROM " + Tables.TASKS + " WHERE " + + TaskContract.Tasks.LIST_ID + "= old." + TaskContract.TaskLists._ID + "; END"); + + // create task table + db.execSQL(SQL_CREATE_TASKS_TABLE); + + // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted + db.execSQL("CREATE TRIGGER task_list_make_dirty_on_update AFTER UPDATE ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " + + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." + + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); + + // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted + db.execSQL("CREATE TRIGGER task_list_make_dirty_on_insert AFTER INSERT ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " + + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." + + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); + + // create task version update trigger + db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); + + // create instances table and view + db.execSQL(SQL_CREATE_INSTANCES_TABLE); + + // create categories table + db.execSQL(SQL_CREATE_CATEGORIES_TABLE); + + // create categories mapping table + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + + // create alarms table + db.execSQL(SQL_CREATE_ALARMS_TABLE); + + // create properties table + db.execSQL(SQL_CREATE_PROPERTIES_TABLE); + + // create syncstate table + db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); + + // create views + db.execSQL(SQL_CREATE_TASK_VIEW); + db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); + + // create indices + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.TASK_ID, TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_DUE)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); + db.execSQL(createIndexString(Tables.LISTS, false, TaskContract.TaskLists.ACCOUNT_NAME, // not sure if necessary + TaskContract.TaskLists.ACCOUNT_TYPE)); + db.execSQL(createIndexString(Tables.TASKS, false, TaskContract.Tasks.STATUS, TaskContract.Tasks.LIST_ID, TaskContract.Tasks._SYNC_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, + TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); + + // trigger that removes properties of a task that has been removed + db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); + + // trigger that removes alarms when an alarm property was deleted + db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); + + // trigger that removes tasks when a list was removed + db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); + + // trigger that counts the alarms for tasks + db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); + + // add cleanup trigger for orphaned properties + db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); + + // initialize FTS + FTSDatabaseHelper.onCreate(db); + + if (mListener != null) + { + mListener.onDatabaseCreated(db); + } + } + + + /** + * Manages the database schema migration. + */ + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) + { + Log.i(TAG, "updgrading db from " + oldVersion + " to " + newVersion); + if (oldVersion < 2) + { + // add IS_NEW and IS_CLOSED columns and update their values + db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_NEW + " INTEGER"); + db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_CLOSED + " INTEGER"); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 1 WHERE " + TaskContract.Tasks.STATUS + " = " + + TaskContract.Tasks.STATUS_NEEDS_ACTION); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 0 WHERE " + TaskContract.Tasks.STATUS + " != " + + TaskContract.Tasks.STATUS_NEEDS_ACTION); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 1 WHERE " + TaskContract.Tasks.STATUS + " > " + + TaskContract.Tasks.STATUS_IN_PROCESS); + db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 0 WHERE " + TaskContract.Tasks.STATUS + " <= " + + TaskContract.Tasks.STATUS_IN_PROCESS); + } + + if (oldVersion < 3) + { + // add instance sortings + db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER"); + db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER"); + db.execSQL("UPDATE " + Tables.INSTANCES + " SET " + TaskContract.Instances.INSTANCE_START_SORTING + " = " + TaskContract.Instances.INSTANCE_START + + ", " + TaskContract.Instances.INSTANCE_DUE_SORTING + " = " + TaskContract.Instances.INSTANCE_DUE); + } + if (oldVersion < 4) + { + // drop old view before altering the schema + db.execSQL(SQL_DROP_TASK_VIEW); + db.execSQL(SQL_DROP_INSTANCE_VIEW); + + // change property id column name to work with the left join in task view + db.execSQL(SQL_DROP_TASKS_CLEANUP_TRIGGER); + db.execSQL(SQL_DROP_PROPERTIES_TABLE); + db.execSQL(SQL_CREATE_PROPERTIES_TABLE); + db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); + + // create categories mapping table + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + + // create alarms table + db.execSQL(SQL_CREATE_ALARMS_TABLE); + + // update views + db.execSQL(SQL_CREATE_TASK_VIEW); + db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); + db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); + + // create Indices + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, + TaskContract.Categories.NAME)); + db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); + + // add new triggers + db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); + db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); + + } + if (oldVersion < 6) + { + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PARENT_ID + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_ALARMS + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.SORTING + " text;"); + } + if (oldVersion < 7) + { + db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); + } + if (oldVersion < 8) + { + // replace priority 0 by null. We need this to sort the widget properly. Since 0 is the default this is no problem when syncing. + db.execSQL("update " + Tables.TASKS + " set " + Tasks.PRIORITY + "=null where " + Tasks.PRIORITY + "=0;"); + } + if (oldVersion < 9) + { + // add missing column _UID + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks._UID + " integer;"); + // add cleanup trigger for orphaned properties + db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); + } + if (oldVersion < 10) + { + // add property column to categories_mapping table. Since adding a constraint is not supported by SQLite we have to remove and recreate the entire + // table + db.execSQL("drop table " + Tables.CATEGORIES_MAPPING); + db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); + db.execSQL(SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER); + } + if (oldVersion < 11) + { + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PINNED + " integer;"); + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_PROPERTIES + " integer;"); + } + + if (oldVersion < 12) + { + // rename the local account type + ContentValues values = new ContentValues(1); + values.put(TaskLists.ACCOUNT_TYPE, TaskContract.LOCAL_ACCOUNT_TYPE); + db.update(Tables.LISTS, values, TaskLists.ACCOUNT_TYPE + "=?", new String[] { "LOCAL" }); + } + + if (oldVersion < 13) + { + db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); + } + + if (oldVersion < 14) + { + // create a unique index for account name and account type on the sync state table + db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); + } + + if (oldVersion < 16) + { + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); + } + + if (oldVersion < 17) + { + db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " integer default 0;"); + db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); + } + + if (oldVersion < 18) + { + db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.DISTANCE_FROM_CURRENT + " integer default 0;"); + } + + if (oldVersion < 19) + { + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + } + + if (oldVersion < 22) + { + // create version column, unless it already exists + if (!new First<>(new TableColumns(Tables.TASKS).value(db), new Equals<>(Tasks.VERSION)).isPresent()) + { + // create task version column and update trigger + db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.VERSION + " Integer default 0;"); + db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); + } + } + + if (oldVersion < 22) + { + db.beginTransaction(); + try + { + // make sure we upgrade the instances of every recurring task + EntityProcessor processor = new Instantiating(new NoOpProcessor<>()); + try (Cursor c = db.query(Tables.TASKS, + new String[] { + TaskContract.Tasks._ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_CLOSED, Tasks.TZ, + Tasks.IS_ALLDAY, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.ORIGINAL_INSTANCE_TIME, Tasks.ORIGINAL_INSTANCE_ALLDAY }, + String.format(Locale.ENGLISH, "%s is null", TaskContract.Tasks.ORIGINAL_INSTANCE_ID), + null, null, null, null)) + { + while (c.moveToNext()) + { + ContentValues values = new ContentValues(); + Instantiating.addUpdateRequest(values); + TaskAdapter adapter = new CursorContentValuesTaskAdapter(c, values); + processor.update(db, adapter, false); + } + } + db.setTransactionSuccessful(); + } + finally + { + db.endTransaction(); + } + } + + if (oldVersion < 23) + { + db.execSQL("drop view " + Tables.INSTANCE_CLIENT_VIEW + ";"); + db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); + } + + // upgrade FTS + FTSDatabaseHelper.onUpgrade(db, oldVersion, newVersion); + + if (mListener != null) + { + mListener.onDatabaseUpdate(db, oldVersion, newVersion); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java new file mode 100644 index 0000000..6bddccd --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java @@ -0,0 +1,1408 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.OnAccountsUpdateListener; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.text.TextUtils; +import android.util.Log; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.provider.tasks.TaskDatabaseHelper.OnDatabaseOperationListener; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.handler.PropertyHandler; +import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; +import org.dmfs.provider.tasks.model.ContentValuesListAdapter; +import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesListAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.processors.instances.Detaching; +import org.dmfs.provider.tasks.processors.instances.TaskValueDelegate; +import org.dmfs.provider.tasks.processors.lists.ListCommitProcessor; +import org.dmfs.provider.tasks.processors.tasks.AutoCompleting; +import org.dmfs.provider.tasks.processors.tasks.Instantiating; +import org.dmfs.provider.tasks.processors.tasks.Moving; +import org.dmfs.provider.tasks.processors.tasks.Originating; +import org.dmfs.provider.tasks.processors.tasks.Relating; +import org.dmfs.provider.tasks.processors.tasks.Reparenting; +import org.dmfs.provider.tasks.processors.tasks.Searchable; +import org.dmfs.provider.tasks.processors.tasks.TaskCommitProcessor; +import org.dmfs.provider.tasks.processors.tasks.Validating; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Alarms; +import org.dmfs.tasks.contract.TaskContract.Categories; +import org.dmfs.tasks.contract.TaskContract.CategoriesColumns; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.PropertyColumns; +import org.dmfs.tasks.contract.TaskContract.SyncState; +import org.dmfs.tasks.contract.TaskContract.TaskColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + + +/** + * The provider for tasks. + *

+ * TODO: add support for recurring tasks + *

+ * TODO: add support for reminders + *

+ * TODO: add support for attendees + *

+ * TODO: refactor the selection stuff + * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public final class TaskProvider extends SQLiteContentProvider implements OnAccountsUpdateListener, OnDatabaseOperationListener +{ + + private static final int LISTS = 1; + private static final int LIST_ID = 2; + private static final int TASKS = 101; + private static final int TASK_ID = 102; + private static final int INSTANCES = 103; + private static final int INSTANCE_ID = 104; + private static final int CATEGORIES = 1001; + private static final int CATEGORY_ID = 1002; + private static final int PROPERTIES = 1003; + private static final int PROPERTY_ID = 1004; + private static final int ALARMS = 1005; + private static final int ALARM_ID = 1006; + private static final int SEARCH = 1007; + private static final int SYNCSTATE = 1008; + private static final int SYNCSTATE_ID = 1009; + + private static final int OPERATIONS = 100000; + + private final static Set TASK_LIST_SYNC_COLUMNS = new HashSet(Arrays.asList(TaskLists.SYNC_ADAPTER_COLUMNS)); + private static final String TAG = "TaskProvider"; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the instances table. + */ + private EntityProcessor mInstanceProcessorChain; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the tasks table. + */ + private EntityProcessor mTaskProcessorChain; + + /** + * A list of {@link EntityProcessor}s to execute when doing operations on the task lists table. + */ + private EntityProcessor mListProcessorChain; + + /** + * Our authority. + */ + String mAuthority; + + /** + * The {@link UriMatcher} we use. + */ + private UriMatcher mUriMatcher; + + /** + * A handler to execute asynchronous jobs. + */ + Handler mAsyncHandler; + + /** + * Boolean to track if there are changes within a transaction. + *

+ * This can be shared by multiple threads, hence the {@link AtomicBoolean}. + */ + private AtomicBoolean mChanged = new AtomicBoolean(false); + + /** + * This is a per transaction/thread flag which indicates whether new lists with an unknown account have been added. + * If this holds true at the end of a transaction a window should be shown to ask the user for access to that account. + */ + private ThreadLocal mStaleListCreated = new ThreadLocal<>(); + + /** + * The currently known accounts. This may be accessed from various threads, hence the AtomicReference. + * By statring with an empty set, we can always guarantee a non-null reference. + */ + private AtomicReference> mAccountCache = new AtomicReference<>(Collections.emptySet()); + + + public TaskProvider() + { + // for now we don't have anything specific to execute before the transaction ends. + super(EmptyIterable.instance()); + } + + + @Override + public boolean onCreate() + { + mAuthority = AuthorityUtil.taskAuthority(getContext()); + + mTaskProcessorChain = new Validating( + new AutoCompleting(new Relating(new Reparenting(new Instantiating(new Searchable(new Moving(new Originating(new TaskCommitProcessor())))))))); + + mListProcessorChain = new org.dmfs.provider.tasks.processors.lists.Validating(new ListCommitProcessor()); + + mInstanceProcessorChain = new org.dmfs.provider.tasks.processors.instances.Validating( + new Detaching(new TaskValueDelegate(mTaskProcessorChain), mTaskProcessorChain)); + + mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH, LISTS); + + mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH + "/#", LIST_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH, TASKS); + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH + "/#", TASK_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH, INSTANCES); + mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH + "/#", INSTANCE_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH, PROPERTIES); + mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH + "/#", PROPERTY_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH, CATEGORIES); + mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH + "/#", CATEGORY_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH, ALARMS); + mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH + "/#", ALARM_ID); + + mUriMatcher.addURI(mAuthority, TaskContract.Tasks.SEARCH_URI_PATH, SEARCH); + + mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH, SYNCSTATE); + mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH + "/#", SYNCSTATE_ID); + + ContentOperation.register(mUriMatcher, mAuthority, OPERATIONS); + + boolean result = super.onCreate(); + + // create a HandlerThread to perform async operations + HandlerThread thread = new HandlerThread("backgroundHandler"); + thread.start(); + mAsyncHandler = new Handler(thread.getLooper()); + + AccountManager accountManager = AccountManager.get(getContext()); + accountManager.addOnAccountsUpdatedListener(this, mAsyncHandler, true); + + updateNotifications(); + + return result; + } + + + /** + * Return true if the caller is a sync adapter (i.e. if the Uri contains the query parameter {@link TaskContract#CALLER_IS_SYNCADAPTER} and its value is + * true). + * + * @param uri + * The {@link Uri} to check. + * + * @return true if the caller pretends to be a sync adapter, false otherwise. + */ + @Override + public boolean isCallerSyncAdapter(Uri uri) + { + String param = uri.getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER); + return param != null && !"false".equals(param); + } + + + /** + * Return true if the URI indicates to a load extended properties with {@link TaskContract#LOAD_PROPERTIES}. + * + * @param uri + * The {@link Uri} to check. + * + * @return true if the URI requests to load extended properties, false otherwise. + */ + public boolean shouldLoadProperties(Uri uri) + { + String param = uri.getQueryParameter(TaskContract.LOAD_PROPERTIES); + return param != null && !"false".equals(param); + } + + + /** + * Get the account name from the given {@link Uri}. + * + * @param uri + * The Uri to check. + * + * @return The account name or null if no account name has been specified. + */ + protected String getAccountName(Uri uri) + { + return uri.getQueryParameter(TaskContract.ACCOUNT_NAME); + } + + + /** + * Get the account type from the given {@link Uri}. + * + * @param uri + * The Uri to check. + * + * @return The account type or null if no account type has been specified. + */ + protected String getAccountType(Uri uri) + { + return uri.getQueryParameter(TaskContract.ACCOUNT_TYPE); + } + + + /** + * Get any id from the given {@link Uri}. + * + * @param uri + * The Uri. + * + * @return The last path segment (which should contain the id). + */ + private long getId(Uri uri) + { + return Long.parseLong(uri.getPathSegments().get(1)); + } + + + /** + * Build a selection string that selects the account specified in uri. + * + * @param uri + * A {@link Uri} that specifies an account. + * + * @return A {@link StringBuilder} with a selection string for the account. + */ + protected StringBuilder selectAccount(Uri uri) + { + StringBuilder sb = new StringBuilder(256); + return selectAccount(sb, uri); + } + + + /** + * Append the selection of the account specified in uri to the {@link StringBuilder} sb. + * + * @param sb + * A {@link StringBuilder} that the selection is appended to. + * @param uri + * A {@link Uri} that specifies an account. + * + * @return sb. + */ + protected StringBuilder selectAccount(StringBuilder sb, Uri uri) + { + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + if (accountName != null || accountType != null) + { + + if (accountName != null) + { + if (sb.length() > 0) + { + sb.append(" AND "); + } + + sb.append(TaskListSyncColumns.ACCOUNT_NAME); + sb.append("="); + DatabaseUtils.appendEscapedSQLString(sb, accountName); + } + if (accountType != null) + { + + if (sb.length() > 0) + { + sb.append(" AND "); + } + + sb.append(TaskListSyncColumns.ACCOUNT_TYPE); + sb.append("="); + DatabaseUtils.appendEscapedSQLString(sb, accountType); + } + } + return sb; + } + + + /** + * Append the selection of the account specified in uri to the an {@link SQLiteQueryBuilder}. + * + * @param sqlBuilder + * A {@link SQLiteQueryBuilder} that the selection is appended to. + * @param uri + * A {@link Uri} that specifies an account. + */ + protected void selectAccount(SQLiteQueryBuilder sqlBuilder, Uri uri) + { + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + if (accountName != null) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_NAME); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhereEscapeString(accountName); + } + if (accountType != null) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_TYPE); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhereEscapeString(accountType); + } + } + + + private StringBuilder _selectId(StringBuilder sb, long id, String key) + { + if (sb.length() > 0) + { + sb.append(" AND "); + } + sb.append(key); + sb.append("="); + sb.append(id); + return sb; + } + + + protected StringBuilder selectId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectId(sb, uri); + } + + + protected StringBuilder selectId(StringBuilder sb, Uri uri) + { + return _selectId(sb, getId(uri), TaskListColumns._ID); + } + + + protected StringBuilder selectTaskId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectTaskId(sb, uri); + } + + + protected StringBuilder selectTaskId(long id) + { + StringBuilder sb = new StringBuilder(128); + return selectTaskId(sb, id); + } + + + protected StringBuilder selectTaskId(StringBuilder sb, Uri uri) + { + return selectTaskId(sb, getId(uri)); + } + + + protected StringBuilder selectTaskId(StringBuilder sb, long id) + { + return _selectId(sb, id, Instances.TASK_ID); + + } + + + protected StringBuilder selectPropertyId(Uri uri) + { + StringBuilder sb = new StringBuilder(128); + return selectPropertyId(sb, uri); + } + + + protected StringBuilder selectPropertyId(StringBuilder sb, Uri uri) + { + return selectPropertyId(sb, getId(uri)); + } + + + protected StringBuilder selectPropertyId(long id) + { + StringBuilder sb = new StringBuilder(128); + return selectPropertyId(sb, id); + } + + + protected StringBuilder selectPropertyId(StringBuilder sb, long id) + { + return _selectId(sb, id, PropertyColumns.PROPERTY_ID); + } + + + /** + * Add a selection by ID to the given {@link SQLiteQueryBuilder}. The id is taken from the given Uri. + * + * @param sqlBuilder + * The {@link SQLiteQueryBuilder} to append the selection to. + * @param idColumn + * The column that must match the id. + * @param uri + * An {@link Uri} that contains the id. + */ + protected void selectId(SQLiteQueryBuilder sqlBuilder, String idColumn, Uri uri) + { + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(idColumn); + sqlBuilder.appendWhere("="); + sqlBuilder.appendWhere(String.valueOf(getId(uri))); + } + + + /** + * Append any arbitrary selection string to the selection in sb + * + * @param sb + * A {@link StringBuilder} that already contains a selection string. + * @param selection + * A valid SQL selection string. + * + * @return A string with the final selection. + */ + protected String updateSelection(StringBuilder sb, String selection) + { + if (selection != null) + { + if (sb.length() > 0) + { + sb.append(" AND ( ").append(selection).append(" ) "); + } + else + { + sb.append(" ( ").append(selection).append(" ) "); + } + } + return sb.toString(); + } + + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) + { + final SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + SQLiteQueryBuilder sqlBuilder = new SQLiteQueryBuilder(); + // initialize appendWhere, this allows us to append all other selections with a preceding "AND" + sqlBuilder.appendWhere(" 1=1 "); + boolean isSyncAdapter = isCallerSyncAdapter(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.SYNCSTATE); + break; + } + case LISTS: + // add account to selection if any + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.LISTS); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; + } + break; + + case LIST_ID: + // add account to selection if any + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.LISTS); + selectId(sqlBuilder, TaskListColumns._ID, uri); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; + } + break; + + case TASKS: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to task view that includes these properties + sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.TASKS_VIEW); + } + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; + } + break; + + case TASK_ID: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to task view that includes these properties + sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.TASKS_VIEW); + } + selectId(sqlBuilder, TaskColumns._ID, uri); + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; + } + break; + + case INSTANCES: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to instance view that includes these properties + sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); + } + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; + } + break; + + case INSTANCE_ID: + if (shouldLoadProperties(uri)) + { + // extended properties were requested, therefore change to instance view that includes these properties + sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); + } + else + { + sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); + } + selectId(sqlBuilder, Instances._ID, uri); + if (!isSyncAdapter) + { + // do not return deleted rows if caller is not a sync adapter + sqlBuilder.appendWhere(" AND "); + sqlBuilder.appendWhere(Tasks._DELETED); + sqlBuilder.appendWhere("=0"); + } + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; + } + break; + + case CATEGORIES: + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.CATEGORIES); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; + } + break; + + case CATEGORY_ID: + selectAccount(sqlBuilder, uri); + sqlBuilder.setTables(Tables.CATEGORIES); + selectId(sqlBuilder, CategoriesColumns._ID, uri); + if (sortOrder == null || sortOrder.length() == 0) + { + sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; + } + break; + + case PROPERTIES: + sqlBuilder.setTables(Tables.PROPERTIES); + break; + + case PROPERTY_ID: + sqlBuilder.setTables(Tables.PROPERTIES); + selectId(sqlBuilder, PropertyColumns.PROPERTY_ID, uri); + break; + + case SEARCH: + String searchString = uri.getQueryParameter(Tasks.SEARCH_QUERY_PARAMETER); + searchString = Uri.decode(searchString); + Cursor searchCursor = FTSDatabaseHelper.getTaskSearchCursor(db, searchString, projection, selection, selectionArgs, sortOrder); + if (searchCursor != null) + { + // attach tasks uri for notifications, that way the search results are updated when a task changes + searchCursor.setNotificationUri(getContext().getContentResolver(), Tasks.getContentUri(mAuthority)); + } + return searchCursor; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + Cursor c = sqlBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); + + if (c != null) + { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + return c; + } + + + @Override + public int deleteInTransaction(final SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, final boolean isSyncAdapter) + { + int count = 0; + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + selection = updateSelection(selectAccount(uri), selection); + count = db.delete(Tables.SYNCSTATE, selection, selectionArgs); + break; + } + /* + * Deleting task lists is only allowed to sync adapters. They must provide ACCOUNT_NAME and ACCOUNT_TYPE. + */ + case LIST_ID: + // add _id to selection and fall through + selection = updateSelection(selectId(uri), selection); + case LISTS: + { + if (isSyncAdapter) + { + if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) + { + throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); + } + } + + // iterate over all lists that match the selection + final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + final ListAdapter list = new CursorContentValuesListAdapter(ListAdapter._ID.getFrom(cursor), cursor, new ContentValues()); + + mListProcessorChain.delete(db, list, isSyncAdapter); + mChanged.set(true); + count++; + } + } + finally + { + cursor.close(); + } + + break; + + } + /* + * Task won't be removed, just marked as deleted if the caller isn't a sync adapter. Sync adapters can remove tasks immediately. + */ + case TASK_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case TASKS: + { + // TODO: filter by account name and type if present in uri. + + if (isSyncAdapter) + { + if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) + { + throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); + } + } + + // iterate over all tasks that match the selection + final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, new ContentValues()); + + mTaskProcessorChain.delete(db, task, isSyncAdapter); + + mChanged.set(true); + count++; + } + } + finally + { + cursor.close(); + } + + break; + } + + case INSTANCE_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case INSTANCES: + { + // iterate over all instances that match the selection + try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) + { + while (cursor.moveToNext()) + { + mInstanceProcessorChain.delete(db, new CursorContentValuesInstanceAdapter(cursor, new ContentValues()), isSyncAdapter); + mChanged.set(true); + count++; + } + } + + break; + } + + case ALARM_ID: + // add id to selection and fall through + selection = updateSelection(selectId(uri), selection); + + case ALARMS: + + count = db.delete(Tables.ALARMS, selection, selectionArgs); + break; + + case PROPERTY_ID: + selection = updateSelection(selectPropertyId(uri), selection); + + case PROPERTIES: + // fetch all properties that match the selection + Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); + + try + { + int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); + int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); + int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); + while (cursor.moveToNext()) + { + long propertyId = cursor.getLong(propIdCol); + long taskId = cursor.getLong(taskIdCol); + String mimeType = cursor.getString(mimeTypeCol); + if (mimeType != null) + { + PropertyHandler handler = PropertyHandlerFactory.get(mimeType); + count += handler.delete(db, taskId, propertyId, cursor, isSyncAdapter); + } + } + } + finally + { + cursor.close(); + } + postNotifyUri(Properties.getContentUri(mAuthority)); + break; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + if (count > 0) + { + postNotifyUri(uri); + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + return count; + } + + + @Override + public Uri insertInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, final boolean isSyncAdapter) + { + long rowId; + Uri result_uri; + + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + values.put(SyncState.ACCOUNT_NAME, accountName); + values.put(SyncState.ACCOUNT_TYPE, accountType); + rowId = db.replace(Tables.SYNCSTATE, null, values); + result_uri = TaskContract.SyncState.getContentUri(mAuthority); + break; + } + case LISTS: + { + final ListAdapter list = new ContentValuesListAdapter(values); + list.set(ListAdapter.ACCOUNT_NAME, accountName); + list.set(ListAdapter.ACCOUNT_TYPE, accountType); + + mListProcessorChain.insert(db, list, isSyncAdapter); + mChanged.set(true); + + rowId = list.id(); + result_uri = TaskContract.TaskLists.getContentUri(mAuthority); + // if the account is unknown we need to ask the user + // + // AGENDULA CHANGE: also require the type to be one we authenticate ourselves, for + // the same reason Utils.cleanUpLists does. Without GET_ACCOUNTS the cache only ever + // holds our own accounts, so upstream's test would call *every* externally-synced + // list stale and fire a broadcast about it on each insert. Nothing listens today; + // the point is that whatever listens tomorrow gets a signal that means something. + if (Build.VERSION.SDK_INT >= 26 && + !TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && + Utils.isOwnAccountType(getContext(), accountType) && + !mAccountCache.get().contains(new Account(accountName, accountType))) + { + // store the fact that we have an unknown account in this transaction + mStaleListCreated.set(true); + Log.d(TAG, String.format("List with unknown account %s inserted.", new Account(accountName, accountType))); + } + break; + } + case TASKS: + final TaskAdapter task = new ContentValuesTaskAdapter(values); + + mTaskProcessorChain.insert(db, task, isSyncAdapter); + + mChanged.set(true); + + rowId = task.id(); + result_uri = TaskContract.Tasks.getContentUri(mAuthority); + + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + + break; + + // inserting instances is currently disabled because we only expand one instance, + // so even though a new task (exception) would be created, no instance might show up + // we need to resolve this discrepancy. Until then this feature remains disabled. +// case INSTANCES: +// { +// InstanceAdapter instance = mInstanceProcessorChain.insert(db, new ContentValuesInstanceAdapter(values), isSyncAdapter); +// rowId = instance.id(); +// result_uri = TaskContract.Instances.getContentUri(mAuthority); +// +// postNotifyUri(Instances.getContentUri(mAuthority)); +// postNotifyUri(Tasks.getContentUri(mAuthority)); +// +// break; +// } + case PROPERTIES: + String mimetype = values.getAsString(Properties.MIMETYPE); + + if (mimetype == null) + { + throw new IllegalArgumentException("missing mimetype in property values"); + } + + Long taskId = values.getAsLong(Properties.TASK_ID); + if (taskId == null) + { + throw new IllegalArgumentException("missing task id in property values"); + } + + if (values.containsKey(Properties.PROPERTY_ID)) + { + throw new IllegalArgumentException("property id can not be written"); + } + + PropertyHandler handler = PropertyHandlerFactory.get(mimetype); + rowId = handler.insert(db, taskId, values, isSyncAdapter); + result_uri = TaskContract.Properties.getContentUri(mAuthority); + if (rowId >= 0) + { + postNotifyUri(Tasks.getContentUri(mAuthority)); + postNotifyUri(Instances.getContentUri(mAuthority)); + } + break; + + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + + if (rowId > 0 && result_uri != null) + { + result_uri = ContentUris.withAppendedId(result_uri, rowId); + postNotifyUri(result_uri); + postNotifyUri(uri); + return result_uri; + } + throw new SQLException("Failed to insert row into " + uri); + } + + + @Override + public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, String selection, String[] selectionArgs, + final boolean isSyncAdapter) + { + int count = 0; + boolean dataChanged = false; + switch (mUriMatcher.match(uri)) + { + case SYNCSTATE_ID: + // the id is ignored, we only match by account type and name given in the Uri + case SYNCSTATE: + { + if (!isSyncAdapter) + { + throw new IllegalAccessError("only sync adapters may access syncstate"); + } + + String accountName = getAccountName(uri); + String accountType = getAccountType(uri); + if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) + { + throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); + } + + if (values.size() == 0) + { + // we're done + break; + } + + values.put(SyncState.ACCOUNT_NAME, accountName); + values.put(SyncState.ACCOUNT_TYPE, accountType); + + long id = db.replace(Tables.SYNCSTATE, null, values); + if (id >= 0) + { + count = 1; + } + break; + } + case LIST_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case LISTS: + { + // iterate over all task lists that match the selection + final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); + + int idCol = cursor.getColumnIndex(TaskContract.TaskLists._ID); + + try + { + while (cursor.moveToNext()) + { + final long listId = cursor.getLong(idCol); + + // clone list values if we have more than one list to update + // we need this, because the processors may change the values + final ListAdapter list = new CursorContentValuesListAdapter(listId, cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (list.hasUpdates()) + { + mListProcessorChain.update(db, list, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + } + // note we still count the row even if no update was necessary + count++; + } + } + finally + { + cursor.close(); + } + break; + } + case TASK_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case TASKS: + { + // iterate over all tasks that match the selection + final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); + + try + { + while (cursor.moveToNext()) + { + // clone task values if we have more than one task to update + // we need this, because the processors may change the values + final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (task.hasUpdates()) + { + mTaskProcessorChain.update(db, task, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + } + // note we still count the row even if no update was necessary + count++; + } + } + finally + { + cursor.close(); + } + + if (dataChanged) + { + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + break; + } + + case INSTANCE_ID: + // update selection and fall through + selection = updateSelection(selectId(uri), selection); + + case INSTANCES: + { + // iterate over all instances that match the selection + + try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) + { + while (cursor.moveToNext()) + { + // clone task values if we have more than one task to update + // we need this, because the processors may change the values + final InstanceAdapter instance = new CursorContentValuesInstanceAdapter(cursor, + cursor.getCount() > 1 ? new ContentValues(values) : values); + + if (instance.hasUpdates()) + { + mInstanceProcessorChain.update(db, instance, isSyncAdapter); + dataChanged = true; + } + // note we still count the row even if no update was necessary + count++; + } + } + + if (dataChanged) + { + postNotifyUri(Instances.getContentUri(mAuthority)); + postNotifyUri(Tasks.getContentUri(mAuthority)); + } + break; + } + case PROPERTY_ID: + selection = updateSelection(selectPropertyId(uri), selection); + + case PROPERTIES: + if (values.containsKey(Properties.MIMETYPE)) + { + throw new IllegalArgumentException("property mimetypes can not be modified"); + } + + if (values.containsKey(Properties.TASK_ID)) + { + throw new IllegalArgumentException("task id can not be changed"); + } + + if (values.containsKey(Properties.PROPERTY_ID)) + { + throw new IllegalArgumentException("property id can not be changed"); + } + + // fetch all properties that match the selection + Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); + + try + { + int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); + int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); + int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); + while (cursor.moveToNext()) + { + long propertyId = cursor.getLong(propIdCol); + long taskId = cursor.getLong(taskIdCol); + String mimeType = cursor.getString(mimeTypeCol); + if (mimeType != null) + { + PropertyHandler handler = PropertyHandlerFactory.get(mimeType); + count += handler.update(db, taskId, propertyId, values, cursor, isSyncAdapter); + } + } + } + finally + { + cursor.close(); + } + postNotifyUri(Properties.getContentUri(mAuthority)); + break; + + case CATEGORY_ID: + String newCategorySelection = updateSelection(selectId(uri), selection); + validateCategoryValues(values, false, isSyncAdapter); + count = db.update(Tables.CATEGORIES, values, newCategorySelection, selectionArgs); + break; + case ALARM_ID: + String newAlarmSelection = updateSelection(selectId(uri), selection); + validateAlarmValues(values, false, isSyncAdapter); + count = db.update(Tables.ALARMS, values, newAlarmSelection, selectionArgs); + break; + default: + ContentOperation operation = ContentOperation.get(mUriMatcher.match(uri), OPERATIONS); + + if (operation == null) + { + throw new IllegalArgumentException("Unknown URI " + uri); + } + + operation.run(getContext(), mAsyncHandler, uri, db, values); + } + + if (dataChanged) + { + // send notifications, because non-sync columns have been updated + postNotifyUri(uri); + mChanged.set(true); + } + + return count; + } + + + /** + * Update task due and task start notifications. + */ + private void updateNotifications() + { + mAsyncHandler.post(new Runnable() + { + + @Override + public void run() + { + ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(getContext(), null); + } + }); + } + + + /** + * Validate the given category values. + * + * @param values + * The category properties to validate. + * + * @throws IllegalArgumentException + * if any of the values is invalid. + */ + private void validateCategoryValues(ContentValues values, boolean isNew, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (values.containsKey(Categories._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (isNew != values.containsKey(Categories.ACCOUNT_NAME) && (!isNew || values.get(Categories.ACCOUNT_NAME) != null)) + { + throw new IllegalArgumentException("ACCOUNT_NAME is write-once and required on INSERT"); + } + + if (isNew != values.containsKey(Categories.ACCOUNT_TYPE) && (!isNew || values.get(Categories.ACCOUNT_TYPE) != null)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is write-once and required on INSERT"); + } + } + + + /** + * Validate the given alarm values. + * + * @param values + * The alarm values to validate + * + * @throws IllegalArgumentException + * if any of the values is invalid. + */ + private void validateAlarmValues(ContentValues values, boolean isNew, boolean isSyncAdapter) + { + if (values.containsKey(Alarms.ALARM_ID)) + { + throw new IllegalArgumentException("ALARM_ID can not be set manually"); + } + } + + + @Override + public String getType(Uri uri) + { + switch (mUriMatcher.match(uri)) + { + case LISTS: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; + case LIST_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; + case TASKS: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; + case TASK_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; + case INSTANCES: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; + case INSTANCE_ID: + return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; + default: + throw new IllegalArgumentException("Unsupported URI: " + uri); + } + } + + + @Override + protected void onEndTransaction(boolean callerIsSyncAdapter) + { + super.onEndTransaction(callerIsSyncAdapter); + if (mChanged.compareAndSet(true, false)) + { + updateNotifications(); + Utils.sendActionProviderChangedBroadCast(getContext(), mAuthority); + } + + if (Boolean.TRUE.equals(mStaleListCreated.get())) + { + // notify UI about the stale lists, it's up the UI to deal with this, either by showing a notification or an instant popup. + Intent visbilityRequest = new Intent("org.dmfs.tasks.action.STALE_LIST_BROADCAST").setPackage(getContext().getPackageName()); + getContext().sendBroadcast(visbilityRequest); + } + } + + + @Override + public SQLiteOpenHelper getDatabaseHelper(Context context) + { + TaskDatabaseHelper helper = new TaskDatabaseHelper(context, this); + + return helper; + } + + + @Override + public void onDatabaseCreated(SQLiteDatabase db) + { + // notify listeners that the database has been created + Intent dbInitializedIntent = new Intent(TaskContract.ACTION_DATABASE_INITIALIZED); + dbInitializedIntent.setDataAndType(TaskContract.getContentUri(mAuthority), TaskContract.MIMETYPE_AUTHORITY); + // Android SDK 26 doesn't allow us to send implicit broadcasts, this particular brodcast is only for internal use, so just make it explicit by setting our package name + dbInitializedIntent.setPackage(getContext().getPackageName()); + getContext().sendBroadcast(dbInitializedIntent); + } + + + @Override + public void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion) + { + if (oldVersion < 15) + { + mAsyncHandler.post(() -> ContentOperation.UPDATE_TIMEZONE.fire(getContext(), null)); + } + } + + + @Override + protected boolean syncToNetwork(Uri uri) + { + return true; + } + + + @Override + public void onAccountsUpdated(Account[] accounts) + { + // cache the known accounts so we can check whether we know accounts for which new lists are added + mAccountCache.set(new HashSet<>(Arrays.asList(accounts))); + // TODO: we probably can move the cleanup code here and get rid of the Utils class + Utils.cleanUpLists(getContext(), getDatabaseHelper().getWritableDatabase(), accounts, mAuthority); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java new file mode 100644 index 0000000..e9b6a07 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java @@ -0,0 +1,134 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * A receiver for all task provider related broadcasts. This receiver merely forwards all incoming broadcasts to the provider, so they can be handled + * asynchronously in the provider context. + * + * @author Marten Gajda + */ +public class TaskProviderBroadcastReceiver extends BroadcastReceiver +{ + private final static int REQUEST_CODE_ALARM = 1337; + + // AGENDULA CHANGE: renamed into our namespace. Only ever used for a PendingIntent we address to + // this very class, so it collides with nothing — but a device may have OpenTasks installed too, + // and identical action strings across two apps are the kind of thing that is very confusing to + // read in a bug report. + private final static String ACTION_NOTIFICATION_ALARM = "de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM"; + + + /** + * Registers a system alarm to update notifications at a specific time. + * + * @param context + * A Context. + * @param updateTime + * When to fire the alarm. + */ + // AGENDULA CHANGE: MissingPermission added. Lint cannot see that the setExact call below is already + // guarded by canScheduleExactAlarms(), with a set() fallback when it returns false. + @SuppressLint({ "NewApi", "MissingPermission" }) + static void planNotificationUpdate(Context context, DateTime updateTime) + { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent alarmIntent = new Intent(context, TaskProviderBroadcastReceiver.class); + alarmIntent.setAction(ACTION_NOTIFICATION_ALARM); + + // AGENDULA CHANGE: FLAG_IMMUTABLE added. Since Android 12 a PendingIntent must state its + // mutability, and this call throws IllegalArgumentException without it once targetSdk >= 31. + // Upstream targets 29, so it never hit this; we target 36. Nothing fills the intent in later, + // so immutable is also the correct choice on the merits. + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, REQUEST_CODE_ALARM, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + // cancel any previous alarm + am.cancel(pendingIntent); + + if (updateTime.isFloating()) + { + // convert floating times to absolute times + updateTime = new DateTime(TimeZone.getDefault(), updateTime.getYear(), updateTime.getMonth(), updateTime.getDayOfMonth(), updateTime.getHours(), + updateTime.getMinutes(), updateTime.getSeconds()); + } + + // AlarmManager API changed in v19 (KitKat) and the "set" method is not called at the exact time anymore + // + // AGENDULA CHANGE: fall back to an inexact alarm when exact ones aren't permitted. On API + // 31-32 SCHEDULE_EXACT_ALARM is revocable, and setExact then throws SecurityException — here, + // inside a receiver handling a system broadcast, which means the app dies every time the + // timezone changes. This alarm only re-runs the provider's own bookkeeping, so a few minutes + // of drift costs nothing; Agendula's user-visible due reminders are armed by ReminderScheduler, + // which asks for the permission properly. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()) + { + am.setExact(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); + } + else + { + am.set(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); + } + } + + + @Override + public void onReceive(Context context, Intent intent) + { + String action = intent.getAction(); + if (action == null) + { + return; + } + + // AGENDULA CHANGE: the cases below are upstream's, written out rather than reached by + // fall-through. Upstream's switch has no `break` anywhere, so TIMEZONE_CHANGED already runs + // all three operations and ACTION_NOTIFICATION_ALARM runs the last two — which is what this + // does, unchanged. It is spelled out because the comment upstream attaches to the first case + // ("don't trigger the notifications update yet") describes breaks that were never written, + // so the code and the stated intent disagree and only one of them can be preserved. + // + // Behaviour wins: a vendored fork is the wrong place to act on a guess. Whether the missing + // breaks are the bug, or the comment is, wants a device with a task due across a timezone + // change to settle — see provider/PROVENANCE.md. + if (Intent.ACTION_TIMEZONE_CHANGED.equals(action)) + { + // the local timezone has been changed, notify the provider to take the necessary steps. + ContentOperation.UPDATE_TIMEZONE.fire(context, null); + } + if (Intent.ACTION_TIMEZONE_CHANGED.equals(action) || ACTION_NOTIFICATION_ALARM.equals(action)) + { + // it's time for the next notification + ContentOperation.POST_NOTIFICATIONS.fire(context, null); + } + // at this time all other actions trigger an update of the notification alarm + ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(context, null); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/Utils.java b/provider/src/main/java/org/dmfs/provider/tasks/Utils.java new file mode 100644 index 0000000..febcd52 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/Utils.java @@ -0,0 +1,214 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorDescription; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.procedure.composite.Batch; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.provider.tasks.utils.ResourceArray; +import org.dmfs.provider.tasks.utils.With; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.SyncState; +import org.dmfs.tasks.contract.TaskContract.TaskListColumns; +import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; +import de.jeanlucmakiola.agendula.provider.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + + +/** + * The Class Utils. + * + * @author Tobias Reinsch + * @author Marten Gajda + */ +public class Utils +{ + private static final AtomicReference> sOwnAccountTypes = new AtomicReference<>(null); + + + public static void sendActionProviderChangedBroadCast(Context context, String authority) + { + // TODO: Using the TaskContract content uri results in a "Unknown URI content" error message. Using the Tasks content uri instead will break the + // broadcast receiver. We have to find away around this + // TODO: coalesce fast consecutive broadcasts, a delay of up to 1 second should be acceptable + + new With<>(new Intent(Intent.ACTION_PROVIDER_CHANGED, TaskContract.getContentUri(authority))) + .process(providerChangedIntent -> + new Batch(context::sendBroadcast) + .process(new Mapped<>( + packageName -> new Intent(providerChangedIntent).setPackage(packageName), + // TODO: fow now we hard code 3rd party package names, this should be replaced by some sort or registry + // see https://github.com/dmfs/opentasks/issues/824 + new Joined<>( + new SingletonIterable<>(context.getPackageName()), + new ResourceArray(context, R.array.agendula_provider_changed_receivers))))); + } + + + /** + * The account types this package owns an authenticator for. + *

+ * AGENDULA CHANGE. Upstream held {@code android.permission.GET_ACCOUNTS} and so could enumerate every account on the device; we dropped it, because + * since API 26 an authenticator already makes its own accounts visible to its own package and nothing else concerns us. + *

+ * That deletion is only safe together with {@link #cleanUpLists}: an account we cannot see is indistinguishable from an account that has been removed, + * and the upstream cleanup treats the latter as a licence to delete the lists hanging off it. Restricting the cleanup to types in this set makes that + * failure impossible by construction rather than by care — the provider can only ever prune accounts it is authoritative about. + *

+ * While Agendula ships no sync adapter this set is empty and no list is ever pruned. Our own account type joins it automatically the moment the + * authenticator is declared, with no further change here. + * + * @return the account types authenticated by this very package, possibly empty — never null. + */ + static Set ownAccountTypes(Context context) + { + Set cached = sOwnAccountTypes.get(); + if (cached != null) + { + return cached; + } + String ownPackage = context.getPackageName(); + Set types = new HashSet<>(); + for (AuthenticatorDescription description : AccountManager.get(context).getAuthenticatorTypes()) + { + if (ownPackage.equals(description.packageName)) + { + types.add(description.type); + } + } + // Which authenticators our own package declares is fixed at install time, so this cannot go + // stale within a process. Worth caching: isOwnAccountType runs on every task-list insert and + // getAuthenticatorTypes is a binder round trip. + Set result = Collections.unmodifiableSet(types); + sOwnAccountTypes.compareAndSet(null, result); + return sOwnAccountTypes.get(); + } + + + /** + * Whether {@code accountType} is authenticated by this package. See {@link #ownAccountTypes}. + */ + static boolean isOwnAccountType(Context context, String accountType) + { + return ownAccountTypes(context).contains(accountType); + } + + + /** + * Drops the {@link #ownAccountTypes} cache. Tests only — in a real process the answer is fixed at install time, which is the entire reason it is cached. + */ + static void clearOwnAccountTypesCache() + { + sOwnAccountTypes.set(null); + } + + + public static void cleanUpLists(Context context, SQLiteDatabase db, Account[] accounts, String authority) + { + // make a list of the accounts array + List accountList = Arrays.asList(accounts); + // AGENDULA CHANGE — see ownAccountTypes: only types we authenticate ourselves may be pruned. + Set prunableTypes = ownAccountTypes(context); + + db.beginTransaction(); + + try + { + Cursor c = db.query(Tables.LISTS, new String[] { TaskListColumns._ID, TaskListSyncColumns.ACCOUNT_NAME, TaskListSyncColumns.ACCOUNT_TYPE }, null, + null, null, null, null); + + // build a list of all task list ids that no longer have an account + List obsoleteLists = new ArrayList(); + try + { + while (c.moveToNext()) + { + String accountType = c.getString(2); + // mark list for removal if it is non-local, of a type we authenticate + // ourselves, and the account is not in accountList + if (!TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && prunableTypes.contains(accountType)) + { + Account account = new Account(c.getString(1), accountType); + if (!accountList.contains(account)) + { + obsoleteLists.add(c.getLong(0)); + + // remove syncstate for this account right away + db.delete(Tables.SYNCSTATE, SyncState.ACCOUNT_NAME + "=? and " + SyncState.ACCOUNT_TYPE + "=?", new String[] { + account.name, + account.type }); + } + } + } + } + finally + { + c.close(); + } + + if (obsoleteLists.size() == 0) + { + // nothing to do here + return; + } + + // remove all accounts in the list + for (Long id : obsoleteLists) + { + if (id != null) + { + db.delete(Tables.LISTS, TaskListColumns._ID + "=" + id, null); + } + } + db.setTransactionSuccessful(); + } + finally + { + db.endTransaction(); + } + // notify all observers + + ContentResolver cr = context.getContentResolver(); + cr.notifyChange(TaskLists.getContentUri(authority), null); + cr.notifyChange(Tasks.getContentUri(authority), null); + cr.notifyChange(Instances.getContentUri(authority), null); + + Utils.sendActionProviderChangedBroadCast(context, authority); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java new file mode 100644 index 0000000..4882d21 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java @@ -0,0 +1,133 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.tasks.contract.TaskContract.Property; + + +/** + * This class is used to handle alarm property values during database transactions. + * + * @author Tobias Reinsch + */ +public class AlarmHandler extends PropertyHandler +{ + + // private static final String[] ALARM_ID_PROJECTION = { Alarms.ALARM_ID }; + // private static final String ALARM_SELECTION = Alarms.ALARM_ID + " =?"; + + + /** + * Validates the content of the alarm prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (values.containsKey(Property.Alarm.PROPERTY_ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (!values.containsKey(Property.Alarm.MINUTES_BEFORE)) + { + throw new IllegalArgumentException("alarm property requires a time offset"); + } + + if (!values.containsKey(Property.Alarm.REFERENCE) || values.getAsInteger(Property.Alarm.REFERENCE) < 0) + { + throw new IllegalArgumentException("alarm property requires a valid reference date "); + } + + if (!values.containsKey(Property.Alarm.ALARM_TYPE)) + { + throw new IllegalArgumentException("alarm property requires an alarm type"); + } + + return values; + } + + + /** + * Inserts the alarm into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new alarm as long + */ + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + values = validateValues(db, taskId, -1, true, values, isSyncAdapter); + return super.insert(db, taskId, values, isSyncAdapter); + } + + + /** + * Updates the alarm in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java new file mode 100644 index 0000000..8c129cb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java @@ -0,0 +1,277 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper.CategoriesMapping; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.tasks.contract.TaskContract.Categories; +import org.dmfs.tasks.contract.TaskContract.Properties; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * This class is used to handle category property values during database transactions. + * + * @author Tobias Reinsch + */ +public class CategoryHandler extends PropertyHandler +{ + + private static final String[] CATEGORY_ID_PROJECTION = { Categories._ID, Categories.NAME, Categories.COLOR }; + + private static final String CATEGORY_ID_SELECTION = Categories._ID + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; + private static final String CATEGORY_NAME_SELECTION = Categories.NAME + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; + + public static final String IS_NEW_CATEGORY = "is_new_category"; + + + /** + * Validates the content of the category prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + // the category requires a name or an id + if (!values.containsKey(Category.CATEGORY_ID) && !values.containsKey(Category.CATEGORY_NAME)) + { + throw new IllegalArgumentException("Neiter an id nor a category name was supplied for the category property."); + } + + // get the matching task & account for the property + if (!values.containsKey(Properties.TASK_ID)) + { + throw new IllegalArgumentException("No task id was supplied for the category property"); + } + String[] queryArgs = { values.getAsString(Properties.TASK_ID) }; + String[] queryProjection = { Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE }; + String querySelection = Tasks._ID + "=?"; + Cursor taskCursor = db.query(Tables.TASKS_VIEW, queryProjection, querySelection, queryArgs, null, null, null); + + String accountName = null; + String accountType = null; + try + { + if (taskCursor.moveToNext()) + { + accountName = taskCursor.getString(0); + accountType = taskCursor.getString(1); + + values.put(Categories.ACCOUNT_NAME, accountName); + values.put(Categories.ACCOUNT_TYPE, accountType); + } + } + finally + { + if (taskCursor != null) + { + taskCursor.close(); + } + } + + if (accountName != null && accountType != null) + { + // search for matching categories + String[] categoryArgs; + Cursor cursor; + + if (values.containsKey(Categories._ID)) + { + // serach by ID + categoryArgs = new String[] { values.getAsString(Category.CATEGORY_ID), accountName, accountType }; + cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_ID_SELECTION, categoryArgs, null, null, null); + } + else + { + // search by name + categoryArgs = new String[] { values.getAsString(Category.CATEGORY_NAME), accountName, accountType }; + cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_NAME_SELECTION, categoryArgs, null, null, null); + } + try + { + if (cursor != null && cursor.getCount() == 1) + { + cursor.moveToNext(); + Long categoryID = cursor.getLong(0); + String categoryName = cursor.getString(1); + int color = cursor.getInt(2); + + values.put(Category.CATEGORY_ID, categoryID); + values.put(Category.CATEGORY_NAME, categoryName); + values.put(Category.CATEGORY_COLOR, color); + values.put(IS_NEW_CATEGORY, false); + } + else + { + values.put(IS_NEW_CATEGORY, true); + } + } + finally + { + if (cursor != null) + { + cursor.close(); + } + } + + } + + return values; + } + + + /** + * Inserts the category into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new category as long + */ + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + values = validateValues(db, taskId, -1, true, values, isSyncAdapter); + values = getOrInsertCategory(db, values); + + // insert property row and create relation + long id = super.insert(db, taskId, values, isSyncAdapter); + insertRelation(db, taskId, values.getAsLong(Category.CATEGORY_ID), id); + + // update FTS entry with category name + updateFTSEntry(db, taskId, id, values.getAsString(Category.CATEGORY_NAME)); + return id; + } + + + /** + * Updates the category in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + values = getOrInsertCategory(db, values); + + if (values.containsKey(Category.CATEGORY_NAME)) + { + // update FTS entry with new category name + updateFTSEntry(db, taskId, propertyId, values.getAsString(Category.CATEGORY_NAME)); + } + + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } + + + /** + * Check if a category with matching {@link ContentValues} exists and returns the existing category or creates a new category in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param values + * The {@link ContentValues} of the category. + * + * @return The {@link ContentValues} of the existing or new category. + */ + private ContentValues getOrInsertCategory(SQLiteDatabase db, ContentValues values) + { + if (values.getAsBoolean(IS_NEW_CATEGORY)) + { + // insert new category in category table + ContentValues newCategoryValues = new ContentValues(4); + newCategoryValues.put(Categories.ACCOUNT_NAME, values.getAsString(Categories.ACCOUNT_NAME)); + newCategoryValues.put(Categories.ACCOUNT_TYPE, values.getAsString(Categories.ACCOUNT_TYPE)); + newCategoryValues.put(Categories.NAME, values.getAsString(Category.CATEGORY_NAME)); + newCategoryValues.put(Categories.COLOR, values.getAsInteger(Category.CATEGORY_COLOR)); + + long categoryID = db.insert(Tables.CATEGORIES, "", newCategoryValues); + values.put(Category.CATEGORY_ID, categoryID); + } + + // remove redundant values + values.remove(IS_NEW_CATEGORY); + values.remove(Categories.ACCOUNT_NAME); + values.remove(Categories.ACCOUNT_TYPE); + + return values; + } + + + /** + * Inserts a relation entry in the database to link task and category. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The row id of the task. + * @param categoryId + * The row id of the category. + * + * @return The row id of the inserted relation. + */ + private long insertRelation(SQLiteDatabase db, long taskId, long categoryId, long propertyId) + { + ContentValues relationValues = new ContentValues(3); + relationValues.put(CategoriesMapping.TASK_ID, taskId); + relationValues.put(CategoriesMapping.CATEGORY_ID, categoryId); + relationValues.put(CategoriesMapping.PROPERTY_ID, propertyId); + return db.insert(Tables.CATEGORIES_MAPPING, "", relationValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java new file mode 100644 index 0000000..52d32d3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + + +/** + * This class is used to handle properties with unknown / unsupported mime-types. + * + * @author Tobias Reinsch + */ +public class DefaultPropertyHandler extends PropertyHandler +{ + + /** + * Validates the content of the alarm prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + return values; + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java new file mode 100644 index 0000000..a01e39d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java @@ -0,0 +1,155 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.FTSDatabaseHelper; +import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; +import org.dmfs.tasks.contract.TaskContract.Properties; + + +/** + * Abstract class that is used as template for specific property handlers. + * + * @author Tobias Reinsch + */ +public abstract class PropertyHandler +{ + + /** + * Validates the content of the property prior to insert and update transactions. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property if isNew is false. If isNew is true this value is ignored. + * @param isNew + * Indicates that the content is new and not an update. + * @param values + * The {@link ContentValues} to validate. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The valid {@link ContentValues}. + * + * @throws IllegalArgumentException + * if the {@link ContentValues} are invalid. + */ + public abstract ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter); + + + /** + * Inserts the property {@link ContentValues} into the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task the new property belongs to. + * @param values + * The {@link ContentValues} to insert. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The row id of the new property as long + */ + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + return db.insert(Tables.PROPERTIES, "", values); + } + + + /** + * Updates the property {@link ContentValues} in the database. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param values + * The {@link ContentValues} to update. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return The number of rows affected. + */ + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + return db.update(Tables.PROPERTIES, values, Properties.PROPERTY_ID + "=" + propertyId, null); + } + + + /** + * Deletes the property in the database. + * + * @param db + * The belonging database. + * @param taskId + * The id of the task this property belongs to. + * @param propertyId + * The id of the property. + * @param oldValues + * A {@link Cursor} pointing to the old values in the database. + * @param isSyncAdapter + * Indicates that the transaction was triggered from a SyncAdapter. + * + * @return + */ + public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) + { + return db.delete(Tables.PROPERTIES, Properties.PROPERTY_ID + "=" + propertyId, null); + + } + + + /** + * Method hook to insert FTS entries on database migration. + * + * @param db + * The {@link SQLiteDatabase}. + * @param taskId + * the row id of the task this property belongs to + * @param propertyId + * the id of the property + * @param text + * the searchable text of the property. If the property has multiple text snippets to search in, concat them separated by a space. + */ + protected void updateFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String text) + { + FTSDatabaseHelper.updatePropertyFTSEntry(db, taskId, propertyId, text); + } + + + public ContentValues cloneForNewTask(long newTaskId, ContentValues values) + { + ContentValues newValues = new ContentValues(values); + newValues.remove(Properties.PROPERTY_ID); + newValues.put(Properties.TASK_ID, newTaskId); + return newValues; + } + + + ; +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java new file mode 100644 index 0000000..0e19463 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import org.dmfs.tasks.contract.TaskContract.Property.Alarm; +import org.dmfs.tasks.contract.TaskContract.Property.Category; +import org.dmfs.tasks.contract.TaskContract.Property.Relation; + + +/** + * A factory that creates the matching {@link PropertyHandler} for the given mimetype. + * + * @author Tobias Reinsch + */ +public class PropertyHandlerFactory +{ + private final static PropertyHandler CATEGORY_HANDLER = new CategoryHandler(); + private final static PropertyHandler ALARM_HANDLER = new AlarmHandler(); + private final static PropertyHandler RELATION_HANDLER = new RelationHandler(); + private final static PropertyHandler DEFAULT_PROPERTY_HANDLER = new DefaultPropertyHandler(); + + + /** + * Creates a specific {@link PropertyHandler}. + * + * @param mimeType + * The mimetype of the property. + * + * @return The matching {@link PropertyHandler} for the given mimetype or null + */ + public static PropertyHandler get(String mimeType) + { + if (Category.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return CATEGORY_HANDLER; + } + if (Alarm.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return ALARM_HANDLER; + } + if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) + { + return RELATION_HANDLER; + } + return DEFAULT_PROPERTY_HANDLER; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java new file mode 100644 index 0000000..8f896c5 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java @@ -0,0 +1,276 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.handler; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.tasks.contract.TaskContract.Property.Relation; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * Handles any inserts, updates and deletes on the relations table. + * + * @author Marten Gajda + */ +public class RelationHandler extends PropertyHandler +{ + + @Override + public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) + { + if (values.containsKey(Relation.RELATED_CONTENT_URI)) + { + throw new IllegalArgumentException("setting of RELATED_CONTENT_URI not allowed"); + } + + Long id = values.getAsLong(Relation.RELATED_ID); + String uid = values.getAsString(Relation.RELATED_UID); + + if (id == null && uid != null) + { + values.putNull(Relation.RELATED_ID); + } + else if (id != null && uid == null) + { + values.putNull(Relation.RELATED_UID); + } + else + { + throw new IllegalArgumentException("exactly one of RELATED_ID, RELATED_UID and RELATED_URI must be non-null"); + } + + return values; + } + + + @Override + public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) + { + validateValues(db, taskId, -1, true, values, isSyncAdapter); + resolveFields(db, values); + updateParentId(db, taskId, values, null); + return super.insert(db, taskId, values, isSyncAdapter); + } + + + @Override + public ContentValues cloneForNewTask(long newTaskId, ContentValues values) + { + ContentValues newValues = super.cloneForNewTask(newTaskId, values); + newValues.remove(Relation.RELATED_CONTENT_URI); + return newValues; + } + + + @Override + public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) + { + validateValues(db, taskId, propertyId, false, values, isSyncAdapter); + resolveFields(db, values); + updateParentId(db, taskId, values, oldValues); + return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); + } + + + @Override + public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) + { + clearParentId(db, taskId, oldValues); + return super.delete(db, taskId, propertyId, oldValues, isSyncAdapter); + } + + + /** + * Resolve _id or _uid, depending of which value is given. + *

+ * TODO: store links into the calendar provider if we find an event that matches the UID. + *

+ * + * @param db + * The task database. + * @param values + * The {@link ContentValues}. + */ + private void resolveFields(SQLiteDatabase db, ContentValues values) + { + Long id = values.getAsLong(Relation.RELATED_ID); + String uid = values.getAsString(Relation.RELATED_UID); + + if (id != null) + { + values.put(Relation.RELATED_UID, resolveTaskStringField(db, Tasks._ID, id.toString(), Tasks._UID)); + } + else if (uid != null) + { + values.put(Relation.RELATED_ID, resolveTaskLongField(db, Tasks._UID, uid, Tasks._ID)); + } + } + + + private Long resolveTaskLongField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) + { + String result = resolveTaskStringField(db, selectionField, selectionValue, resultField); + if (result != null) + { + return Long.parseLong(result); + } + return null; + } + + + private String resolveTaskStringField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) + { + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, new String[] { resultField }, selectionField + "=?", new String[] { selectionValue }, null, null, + null); + if (c != null) + { + try + { + if (c.moveToNext()) + { + return c.getString(0); + } + } + finally + { + c.close(); + } + } + return null; + } + + + /** + * Update {@link Tasks#PARENT_ID} when a parent is assigned to a child. + * + * @param db + * @param taskId + * @param values + * @param oldValues + */ + // AGENDULA CHANGE: lint's Range check flags getInt(getColumnIndex(...)), since getColumnIndex returns -1 for an absent column. Both callers pass a cursor + // over a property row selected with a projection that contains RELATED_TYPE, so the index is never -1 in practice. Suppressed rather than "fixed": + // inventing a fallback value would change what the provider does on a path upstream chose to let fail loudly, and this is a fork, not our design. + @SuppressLint("Range") + private void updateParentId(SQLiteDatabase db, long taskId, ContentValues values, Cursor oldValues) + { + int type; + if (values.containsKey(Relation.RELATED_TYPE)) + { + type = values.getAsInteger(Relation.RELATED_TYPE); + } + else + { + type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); + } + + if (type == Relation.RELTYPE_PARENT) + { + // this is a link to the parent, we need to update the PARENT_ID of this task, if we can + + if (values.containsKey(Relation.RELATED_ID)) + { + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, values.getAsLong(Relation.RELATED_ID)); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + // else: the parent task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + else if (type == Relation.RELTYPE_CHILD) + { + // this is a link to a child, we need to update the PARENT_ID of the linked task + + if (values.getAsLong(Relation.RELATED_ID) != null) + { + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, taskId); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + values.getAsLong(Relation.RELATED_ID), null); + } + // else: the child task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + else if (type == Relation.RELTYPE_SIBLING) + { + // this is a link to a sibling, we need to copy the PARENT_ID of the linked task to this task + if (values.getAsLong(Relation.RELATED_ID) != null) + { + // get the parent of the other task first + Long otherParent = resolveTaskLongField(db, Tasks._ID, values.getAsString(Relation.RELATED_ID), Tasks.PARENT_ID); + + ContentValues taskValues = new ContentValues(1); + taskValues.put(Tasks.PARENT_ID, otherParent); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + // else: the sibling task is probably not synced yet, we have to fix this in RelationUpdaterHook + } + } + + + /** + * Clear {@link Tasks#PARENT_ID} if a link is removed. + * + * @param db + * @param taskId + * @param oldValues + */ + // AGENDULA CHANGE: see updateParentId — same Range suppression, same reason. + @SuppressLint("Range") + private void clearParentId(SQLiteDatabase db, long taskId, Cursor oldValues) + { + int type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); + + /* + * This is more complicated than it may sound. We don't know the order in which relations are created, updated or removed. So it's possible that a new + * parent relationship has been created and the old one is removed afterwards. In that case we can not simply clear the PARENT_ID. + * + * FIXME: For now we ignore that fact. But we should fix it. + */ + + if (type == Relation.RELTYPE_PARENT) + { + // this was a link to the parent, we're orphaned now, so clear PARENT_ID of this task + + ContentValues taskValues = new ContentValues(1); + taskValues.putNull(Tasks.PARENT_ID); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); + } + else if (type == Relation.RELTYPE_CHILD) + { + // this was a link to a child, the child is orphaned now, clear its PARENT_ID + + int relIdCol = oldValues.getColumnIndex(Relation.RELATED_ID); + if (!oldValues.isNull(relIdCol)) + { + ContentValues taskValues = new ContentValues(1); + taskValues.putNull(Tasks.PARENT_ID); + db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + oldValues.getLong(relIdCol), null); + } + } + // else if (type == Relation.RELTYPE_SIBLING) + // { + /* + * This was a link to a sibling, since it's no longer our sibling either it or we're orphaned now We won't know unless we check all relations. + * + * FIXME: properly handle this case + */ + // } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java new file mode 100644 index 0000000..4cae1c8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.net.Uri; + +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link InstanceAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractInstanceAdapter implements InstanceAdapter +{ + @Override + public final Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.Instances.getContentUri(authority), id()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java new file mode 100644 index 0000000..80478e3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link ListAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractListAdapter implements ListAdapter +{ + private final ContentValues mState = new ContentValues(10); + + + @Override + public Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.TaskLists.getContentUri(authority), id()); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return stateFieldAdater.getFrom(mState); + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + stateFieldAdater.setIn(mState, value); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java new file mode 100644 index 0000000..1f23f77 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An abstract implementation of a {@link TaskAdapter} to server as the base for more concrete adapters. + * + * @author Marten Gajda + */ +public abstract class AbstractTaskAdapter implements TaskAdapter +{ + private final ContentValues mState = new ContentValues(10); + + + @Override + public Uri uri(String authority) + { + return ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(authority), id()); + } + + + @Override + public boolean isRecurring() + { + // recurring tasks must have an RRULE or RDATEs and at least one of DTSTART and DUE date + return (valueOf(RRULE) != null || valueOf(RDATE).iterator().hasNext()) && (valueOf(DTSTART) != null || valueOf(DUE) != null); + } + + + @Override + public boolean recurrenceUpdated() + { + return isUpdated(RRULE) || isUpdated(DTSTART) || isUpdated(DUE) || isUpdated(DURATION) || isUpdated(RDATE) || isUpdated(EXDATE); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return stateFieldAdater.getFrom(mState); + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + stateFieldAdater.setIn(mState, value); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java new file mode 100644 index 0000000..58a7e54 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java @@ -0,0 +1,161 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.single.elementary.Reduced; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. + * + * @author Marten Gajda + */ +public class ContentValuesInstanceAdapter extends AbstractInstanceAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesInstanceAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesInstanceAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return null; + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + + } + + + @Override + public InstanceAdapter duplicate() + { + return new ContentValuesInstanceAdapter(new ContentValues(mValues)); + } + + + @Override + public TaskAdapter taskAdapter() + { + // make sure we remove any instance fields + return new ContentValuesTaskAdapter(new Reduced( + () -> new ContentValues(mValues), + (contentValues, column) -> { + contentValues.remove(column); + return contentValues; + }, + INSTANCE_COLUMN_NAMES).value()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java new file mode 100644 index 0000000..d441848 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java @@ -0,0 +1,130 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * @author Marten Gajda + */ +public class ContentValuesListAdapter extends AbstractListAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesListAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesListAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.LISTS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); + } + } + + + @Override + public ListAdapter duplicate() + { + return new ContentValuesListAdapter(new ContentValues(mValues)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java new file mode 100644 index 0000000..c8a3b8d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java @@ -0,0 +1,132 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. + * + * @author Marten Gajda + */ +public class ContentValuesTaskAdapter extends AbstractTaskAdapter +{ + private long mId; + private final ContentValues mValues; + + + public ContentValuesTaskAdapter(ContentValues values) + { + this(-1L, values); + } + + + public ContentValuesTaskAdapter(long id, ContentValues values) + { + mId = id; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return null; + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + return fieldAdapter.isSetIn(mValues); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues.size() > 0; + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + if (mId < 0) + { + mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); + return mId > 0 ? 1 : 0; + } + else + { + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + } + + + @Override + public TaskAdapter duplicate() + { + return new ContentValuesTaskAdapter(new ContentValues(mValues)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java new file mode 100644 index 0000000..6213cb3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java @@ -0,0 +1,212 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.single.elementary.Collected; +import org.dmfs.jems.single.elementary.Reduced; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.ArrayList; + + +/** + * An {@link InstanceAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be + * stored in the database with {@link #commit(SQLiteDatabase)}. + * + * @author Marten Gajda + */ +public class CursorContentValuesInstanceAdapter extends AbstractInstanceAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesInstanceAdapter(Cursor cursor, ContentValues values) + { + if (cursor == null && !_ID.existsIn(values)) + { + mId = -1L; + } + else + { + mId = _ID.getFrom(cursor); + } + mCursor = cursor; + mValues = values; + } + + + public CursorContentValuesInstanceAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + if (mValues == null) + { + return fieldAdapter.getFrom(mCursor); + } + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.getFrom(mCursor); + Object newValue = fieldAdapter.getFrom(mValues); + + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + + + @Override + public boolean isWriteable() + { + return mValues != null; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + + + @Override + public T getState(FieldAdapter stateFieldAdater) + { + return null; + } + + + @Override + public void setState(FieldAdapter stateFieldAdater, T value) + { + + } + + + @Override + public InstanceAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Instances._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesInstanceAdapter(newValues); + } + + + @Override + public TaskAdapter taskAdapter() + { + // make sure we remove any instance fields + ContentValues values = new Reduced( + () -> new ContentValues(mValues), + (contentValues, column) -> { + contentValues.remove(column); + return contentValues; + }, + INSTANCE_COLUMN_NAMES).value(); + + // create a new cursor which doesn't contain the instance columns + String[] cursorColumns = new Collected<>( + ArrayList::new, + new Sieved<>(col -> !INSTANCE_COLUMN_NAMES.contains(col), new Seq<>(mCursor.getColumnNames()))) + .value().toArray(new String[0]); + MatrixCursor cursor = new MatrixCursor(cursorColumns); + cursor.addRow( + new Mapped<>( + column -> mCursor.getType(column) == Cursor.FIELD_TYPE_BLOB ? mCursor.getBlob(column) : mCursor.getString(column), + new Mapped<>( + mCursor::getColumnIndex, + new Seq<>(cursorColumns)))); + cursor.moveToFirst(); + return new CursorContentValuesTaskAdapter(valueOf(InstanceAdapter.TASK_ID), cursor, values); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java new file mode 100644 index 0000000..4bdffb5 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java @@ -0,0 +1,139 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * @author Marten Gajda + */ +public class CursorContentValuesListAdapter extends AbstractListAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesListAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.getFrom(mCursor); + Object newValue = fieldAdapter.getFrom(mValues); + + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + + + @Override + public boolean isWriteable() + { + return true; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); + } + + + @Override + public ListAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesListAdapter(newValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java new file mode 100644 index 0000000..0ed20df --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java @@ -0,0 +1,169 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link TaskAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be + * stored in the database with {@link #commit(SQLiteDatabase)}. + * + * @author Marten Gajda + */ +public class CursorContentValuesTaskAdapter extends AbstractTaskAdapter +{ + private final long mId; + private final Cursor mCursor; + private final ContentValues mValues; + + + public CursorContentValuesTaskAdapter(Cursor cursor, ContentValues values) + { + if (cursor == null && !_ID.existsIn(values)) + { + mId = -1L; + } + else + { + mId = _ID.getFrom(cursor); + } + mCursor = cursor; + mValues = values; + } + + + public CursorContentValuesTaskAdapter(long id, Cursor cursor, ContentValues values) + { + mId = id; + mCursor = cursor; + mValues = values; + } + + + @Override + public long id() + { + return mId; + } + + + @Override + public T valueOf(FieldAdapter fieldAdapter) + { + if (mValues == null) + { + return fieldAdapter.getFrom(mCursor); + } + return fieldAdapter.getFrom(mCursor, mValues); + } + + + @Override + public T oldValueOf(FieldAdapter fieldAdapter) + { + return fieldAdapter.getFrom(mCursor); + } + + + @Override + public boolean isUpdated(FieldAdapter fieldAdapter) + { + if (mValues == null || !fieldAdapter.isSetIn(mValues)) + { + return false; + } + Object oldValue = fieldAdapter.existsIn(mCursor) ? fieldAdapter.getFrom(mCursor) : null; + Object newValue = fieldAdapter.getFrom(mValues); + // we need to special case RRULE, because RecurrenceRule doesn't support `equals` + if (fieldAdapter != TaskAdapter.RRULE) + { + return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); + } + else + { + // in case of RRULE we compare the String values. + return oldValue == null && newValue != null || oldValue != null && (newValue == null || !oldValue.toString().equals(newValue.toString())); + } + } + + + @Override + public boolean isWriteable() + { + return mValues != null; + } + + + @Override + public boolean hasUpdates() + { + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); + } + + + @Override + public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException + { + fieldAdapter.setIn(mValues, value); + } + + + @Override + public void unset(FieldAdapter fieldAdapter) throws IllegalStateException + { + fieldAdapter.removeFrom(mValues); + } + + + @Override + public int commit(SQLiteDatabase db) + { + if (mValues.size() == 0) + { + return 0; + } + + return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); + } + + + @Override + public TaskAdapter duplicate() + { + ContentValues newValues = new ContentValues(mValues); + + // copy all columns (except _ID) that are not in the values yet + for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) + { + String column = mCursor.getColumnName(i); + if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) + { + newValues.put(column, mCursor.getString(i)); + } + } + + return new ContentValuesTaskAdapter(newValues); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java new file mode 100644 index 0000000..b3d6c7e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; + + +/** + * Adapter to read values of a specific entity type from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface EntityAdapter +{ + /** + * Returns the row id of the entity or -1 if the entity has not been stored yet. + * + * @return The entity row id or -1. + */ + long id(); + + /** + * Returns the {@link Uri} of the entity using the given authority. + * + * @param authority + * The authority of this provider. + * + * @return A {@link Uri} or null if this entity has not been stored yet. + */ + Uri uri(String authority); + + /** + * Returns the value identified by the given {@link FieldAdapter}. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to return. + * + * @return The value, maybe be null. + */ + T valueOf(FieldAdapter fieldAdapter); + + /** + * Returns the old value identified by the given {@link FieldAdapter}. This will be equal to the value returned by {@link #valueOf(FieldAdapter)} unless it + * has been overridden, in which case this returns the former value. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to return. + * + * @return The value, maybe be null. + */ + T oldValueOf(FieldAdapter fieldAdapter); + + /** + * Returns whether the given field has been overridden or not. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the field to check. + * + * @return true if the field has been overridden, false otherwise. + */ + boolean isUpdated(FieldAdapter fieldAdapter); + + /** + * Returns whether this adapter supports modifying values. + * + * @return true if the task values can be changed by this adapter, false otherwise. + */ + boolean isWriteable(); + + /** + * Returns whether any value has been modified. + * + * @return true if there are modified values, false otherwise. + */ + boolean hasUpdates(); + + /** + * Sets a value of the adapted entity. The value is identified by a {@link FieldAdapter}. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the value to set. + * @param value + * The new value. + */ + void set(FieldAdapter fieldAdapter, T value); + + /** + * Remove a value from the change set. In effect the respective field will keep it's old value. + * + * @param fieldAdapter + * The {@link FieldAdapter} of the field to un-set. + */ + void unset(FieldAdapter fieldAdapter); + + /** + * Commit all changes to the database. + * + * @param db + * A writable database. + * + * @return The number of entries affected. This may be 0 if no fields have been changed. + */ + int commit(SQLiteDatabase db); + + /** + * Return the value of a temporary state field. The state of an entity is not committed to the database, it's only bound to the instances of this + * {@link EntityAdapter} and will be lost once it gets garbage collected. + * + * @param stateFieldAdater + * The {@link FieldAdapter} of a state field. + * + * @return The value of the state field. + */ + T getState(FieldAdapter stateFieldAdater); + + /** + * Set the value of a state field. This value is not stored in the database. Instead it only exists as long as this {@link EntityAdapter} exists. + * + * @param stateFieldAdater + * The {@link FieldAdapter} of the state field to set. + * @param value + * The new state value. + */ + void setState(FieldAdapter stateFieldAdater, T value); + + /*** + * Creates a {@link EntityAdapter} for a new entity initialized with the values of this entity (except for _ID). + * + * @return A new {@link EntityAdapter} having the same values. + */ + EntityAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java new file mode 100644 index 0000000..b2c9de3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java @@ -0,0 +1,109 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + +import java.util.Collection; +import java.util.HashSet; + +import static java.util.Arrays.asList; + + +/** + * Adapter to read instance values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface InstanceAdapter extends EntityAdapter +{ + + Collection INSTANCE_COLUMN_NAMES = new HashSet<>(asList( + TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_START_SORTING, + TaskContract.Instances.INSTANCE_DUE, + TaskContract.Instances.INSTANCE_DUE_SORTING, + TaskContract.Instances.INSTANCE_DURATION, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME, + TaskContract.Instances.TASK_ID, + TaskContract.Instances.DISTANCE_FROM_CURRENT, + "_id:1")); + + /** + * Adapter for the row id of a task instance. + */ + LongFieldAdapter _ID = new LongFieldAdapter(Instances._ID); + + /** + * Adapter for the due date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter<>(Instances.INSTANCE_DUE, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the start date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter<>(Instances.INSTANCE_START, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the start sorting of a task instance. + */ + LongFieldAdapter INSTANCE_START_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_START_SORTING); + + /** + * Adapter for the due sorting of a task instance. + */ + LongFieldAdapter INSTANCE_DUE_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_DUE_SORTING); + + /** + * Adapter for the original time of a task instance. + */ + DateTimeFieldAdapter INSTANCE_ORIGINAL_TIME = new DateTimeFieldAdapter<>(Instances.INSTANCE_ORIGINAL_TIME, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the distance of a task instance from the current instance. + */ + IntegerFieldAdapter DISTANCE_FROM_CURRENT = new IntegerFieldAdapter<>(Instances.DISTANCE_FROM_CURRENT); + + /** + * Adapter for the title of a task instance. + */ + StringFieldAdapter TITLE = new StringFieldAdapter<>(Tasks.TITLE); + + /** + * Adapter for the row id of the task. + */ + LongFieldAdapter TASK_ID = new LongFieldAdapter(Instances.TASK_ID); + + @Override + InstanceAdapter duplicate(); + + /** + * Returns a {@link TaskAdapter} for the task component of the instanced view. + * + * @return + */ + TaskAdapter taskAdapter(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java new file mode 100644 index 0000000..d72a673 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.tasks.contract.TaskContract.TaskLists; + + +/** + * Adapter to read list values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface ListAdapter extends EntityAdapter +{ + /** + * Adapter for the row id of a task list. + */ + LongFieldAdapter _ID = new LongFieldAdapter(TaskLists._ID); + + /** + * Adapter for the _sync_id of a list. + */ + StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskLists._SYNC_ID); + + /** + * Adapter for the sync version of a list. + */ + StringFieldAdapter SYNC_VERSION = new StringFieldAdapter(TaskLists.SYNC_VERSION); + + /** + * Adapter for the account name of a list. + */ + StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(TaskLists.ACCOUNT_NAME); + + /** + * Adapter for the account type of a list. + */ + StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(TaskLists.ACCOUNT_TYPE); + + /** + * Adapter for the owner of a list. + */ + StringFieldAdapter OWNER = new StringFieldAdapter(TaskLists.OWNER); + + /** + * Adapter for the name of a list. + */ + StringFieldAdapter LIST_NAME = new StringFieldAdapter(TaskLists.LIST_NAME); + + /** + * Adapter for the color of a list. + */ + IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskLists.LIST_COLOR); + + /*** + * Creates a {@link ListAdapter} for a new task initialized with the values of this task (except for _ID). + * + * @return A new task having the same values. + */ + @Override + ListAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java new file mode 100644 index 0000000..4668cce --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java @@ -0,0 +1,362 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.provider.tasks.model.adapters.BinaryFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DateTimeIterableFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.DurationFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.RRuleFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.UrlFieldAdapter; +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.Tasks; + + +/** + * Adapter to read task values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. + * + * @author Marten Gajda + */ +public interface TaskAdapter extends EntityAdapter +{ + /** + * Adapter for the row id of a task. + */ + LongFieldAdapter _ID = new LongFieldAdapter(Tasks._ID); + + /** + * Adapter for the version of a task. + */ + LongFieldAdapter VERSION = new LongFieldAdapter<>(Tasks.VERSION); + + /** + * Adapter for the task row id of as instance. + */ + LongFieldAdapter INSTANCE_TASK_ID = new LongFieldAdapter(Instances.TASK_ID); + + /** + * Adapter for the row id of the list of a task. + */ + LongFieldAdapter LIST_ID = new LongFieldAdapter(Tasks.LIST_ID); + + /** + * Adapter for the owner of the list of a task. + */ + StringFieldAdapter LIST_OWNER = new StringFieldAdapter(Tasks.LIST_OWNER); + + /** + * Adapter for the row id of original instance of a task. + */ + LongFieldAdapter ORIGINAL_INSTANCE_ID = new LongFieldAdapter(Tasks.ORIGINAL_INSTANCE_ID); + + /** + * Adapter for the sync_id of original instance of a task. + */ + StringFieldAdapter ORIGINAL_INSTANCE_SYNC_ID = new StringFieldAdapter(Tasks.ORIGINAL_INSTANCE_SYNC_ID); + + /** + * Adapter for the original instance all day flag of a task. + */ + BooleanFieldAdapter ORIGINAL_INSTANCE_ALLDAY = new BooleanFieldAdapter(Tasks.ORIGINAL_INSTANCE_ALLDAY); + + /** + * Adapter for the parent_id of a task. + */ + LongFieldAdapter PARENT_ID = new LongFieldAdapter(Tasks.PARENT_ID); + + /** + * Adapter for the all day flag of a task. + */ + BooleanFieldAdapter IS_ALLDAY = new BooleanFieldAdapter(Tasks.IS_ALLDAY); + + /** + * Adapter for the percent complete value of a task. + */ + IntegerFieldAdapter PERCENT_COMPLETE = new IntegerFieldAdapter(Tasks.PERCENT_COMPLETE); + + /** + * Adapter for the status of a task. + */ + IntegerFieldAdapter STATUS = new IntegerFieldAdapter(Tasks.STATUS); + + /** + * Adapter for the priority value of a task. + */ + IntegerFieldAdapter PRIORITY = new IntegerFieldAdapter(Tasks.PRIORITY); + + /** + * Adapter for the classification value of a task. + */ + IntegerFieldAdapter CLASSIFICATION = new IntegerFieldAdapter(Tasks.CLASSIFICATION); + + /** + * Adapter for the list name of a task. + */ + StringFieldAdapter LIST_NAME = new StringFieldAdapter(Tasks.LIST_NAME); + + /** + * Adapter for the account name of a task. + */ + StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(Tasks.ACCOUNT_NAME); + + /** + * Adapter for the account type of a task. + */ + StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(Tasks.ACCOUNT_TYPE); + + /** + * Adapter for the title of a task. + */ + StringFieldAdapter TITLE = new StringFieldAdapter(Tasks.TITLE); + + /** + * Adapter for the location of a task. + */ + StringFieldAdapter LOCATION = new StringFieldAdapter(Tasks.LOCATION); + + /** + * Adapter for the description of a task. + */ + StringFieldAdapter DESCRIPTION = new StringFieldAdapter(Tasks.DESCRIPTION); + + /** + * Adapter for the start date of a task. + */ + DateTimeFieldAdapter DTSTART = new DateTimeFieldAdapter(Tasks.DTSTART, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the original date of a task. + */ + DateTimeFieldAdapter ORIGINAL_INSTANCE_TIME = new DateTimeFieldAdapter(Tasks.ORIGINAL_INSTANCE_TIME, Tasks.TZ, + Tasks.ORIGINAL_INSTANCE_ALLDAY); + + /** + * Adapter for the raw start date timestamp of a task. + */ + LongFieldAdapter DTSTART_RAW = new LongFieldAdapter(Tasks.DTSTART); + + /** + * Adapter for the due date of a task. + */ + DateTimeFieldAdapter DUE = new DateTimeFieldAdapter(Tasks.DUE, Tasks.TZ, Tasks.IS_ALLDAY); + + /** + * Adapter for the raw due date timestamp of a task. + */ + LongFieldAdapter DUE_RAW = new LongFieldAdapter(Tasks.DUE); + + /** + * Adapter for the start date of a task. + */ + DurationFieldAdapter DURATION = new DurationFieldAdapter(Tasks.DURATION); + + /** + * Adapter for the dirty flag of a task. + */ + BooleanFieldAdapter _DIRTY = new BooleanFieldAdapter(Tasks._DIRTY); + + /** + * Adapter for the deleted flag of a task. + */ + BooleanFieldAdapter _DELETED = new BooleanFieldAdapter(Tasks._DELETED); + + /** + * Adapter for the completed date of a task. + */ + DateTimeFieldAdapter COMPLETED = new DateTimeFieldAdapter(Tasks.COMPLETED, null, null); + + /** + * Adapter for the created date of a task. + */ + DateTimeFieldAdapter CREATED = new DateTimeFieldAdapter(Tasks.CREATED, null, null); + + /** + * Adapter for the last modified date of a task. + */ + DateTimeFieldAdapter LAST_MODIFIED = new DateTimeFieldAdapter(Tasks.LAST_MODIFIED, null, null); + + /** + * Adapter for the URL of a task. + */ + UrlFieldAdapter URL = new UrlFieldAdapter(TaskContract.Tasks.URL); + + /** + * Adapter for the UID of a task. + */ + StringFieldAdapter _UID = new StringFieldAdapter(TaskContract.Tasks._UID); + + /** + * Adapter for the raw time zone of a task. + */ + StringFieldAdapter TIMEZONE_RAW = new StringFieldAdapter(TaskContract.Tasks.TZ); + + /** + * Adapter for the Color of the task. + */ + IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskContract.Tasks.LIST_COLOR); + + /** + * Adapter for the access level of the task list. + */ + IntegerFieldAdapter LIST_ACCESS_LEVEL = new IntegerFieldAdapter(TaskContract.Tasks.LIST_ACCESS_LEVEL); + + /** + * Adapter for the visibility setting of the task list. + */ + BooleanFieldAdapter LIST_VISIBLE = new BooleanFieldAdapter(TaskContract.Tasks.VISIBLE); + + /** + * Adpater for the ID of the task. + */ + IntegerFieldAdapter TASK_ID = new IntegerFieldAdapter(TaskContract.Tasks._ID); + + /** + * Adapter for the IS_CLOSED flag of a task. + */ + BooleanFieldAdapter IS_CLOSED = new BooleanFieldAdapter(TaskContract.Tasks.IS_CLOSED); + + /** + * Adapter for the IS_NEW flag of a task. + */ + BooleanFieldAdapter IS_NEW = new BooleanFieldAdapter(TaskContract.Tasks.IS_NEW); + + /** + * Adapter for the PINNED flag of a task. + */ + BooleanFieldAdapter PINNED = new BooleanFieldAdapter(TaskContract.Tasks.PINNED); + + /** + * Adapter for the HAS_ALARMS flag of a task. + */ + BooleanFieldAdapter HAS_ALARMS = new BooleanFieldAdapter(TaskContract.Tasks.HAS_ALARMS); + + /** + * Adapter for the HAS_PROPERTIES flag of a task. + */ + BooleanFieldAdapter HAS_PROPERTIES = new BooleanFieldAdapter(TaskContract.Tasks.HAS_PROPERTIES); + + /** + * Adapter for the RRULE of a task. + */ + RRuleFieldAdapter RRULE = new RRuleFieldAdapter(TaskContract.Tasks.RRULE); + + /** + * Adapter for the RDATE of a task. + */ + DateTimeIterableFieldAdapter RDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.RDATE, + TaskContract.Tasks.TZ); + + /** + * Adapter for the EXDATE of a task. + */ + DateTimeIterableFieldAdapter EXDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.EXDATE, + TaskContract.Tasks.TZ); + + /** + * Adapter for the SYNC1 field of a task. + */ + BinaryFieldAdapter SYNC1 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC1); + + /** + * Adapter for the SYNC2 field of a task. + */ + BinaryFieldAdapter SYNC2 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC2); + + /** + * Adapter for the SYNC3 field of a task. + */ + BinaryFieldAdapter SYNC3 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC3); + + /** + * Adapter for the SYNC4 field of a task. + */ + BinaryFieldAdapter SYNC4 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC4); + + /** + * Adapter for the SYNC5 field of a task. + */ + BinaryFieldAdapter SYNC5 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC5); + + /** + * Adapter for the SYNC6 field of a task. + */ + BinaryFieldAdapter SYNC6 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC6); + + /** + * Adapter for the SYNC7 field of a task. + */ + BinaryFieldAdapter SYNC7 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC7); + + /** + * Adapter for the SYNC8 field of a task. + */ + BinaryFieldAdapter SYNC8 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC8); + + /** + * Adapter for the SYNC_VERSION field of a task. + */ + BinaryFieldAdapter SYNC_VERSION = new BinaryFieldAdapter(TaskContract.Tasks.SYNC_VERSION); + + /** + * Adapter for the SYNC_ID field of a task. + */ + StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskContract.Tasks._SYNC_ID); + + /** + * Adapter for the due date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter(Instances.INSTANCE_DUE, Tasks.TZ, + Tasks.IS_ALLDAY); + + /** + * Adapter for the start date of a task instance. + */ + DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter(Instances.INSTANCE_START, Tasks.TZ, + Tasks.IS_ALLDAY); + + /** + * Returns whether the adapted task is recurring. + * + * @return true if the task is recurring, false otherwise. + */ + boolean isRecurring(); + + /** + * Returns whether any value that's relevant for recurrence has been modified thought this adapter. This returns true if any of + * {@link TaskContract.TaskColumns#DTSTART}, {@link TaskContract.TaskColumns#DUE},{@link TaskContract.TaskColumns#DURATION}, + * {@link TaskContract.TaskColumns#RRULE}, {@link TaskContract.TaskColumns#RDATE} or {@link TaskContract.TaskColumns#EXDATE} has been modified. + * + * @return true if the recurrence set has changed, false otherwise. + */ + boolean recurrenceUpdated(); + + /*** + * Creates a {@link TaskAdapter} for a new task initialized with the values of this task (except for _ID). + * + * @return A new task having the same values. + */ + @Override + TaskAdapter duplicate(); +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java new file mode 100644 index 0000000..0d6c24d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a binary value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class BinaryFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link BinaryFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public BinaryFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public byte[] getFrom(ContentValues values) + { + return values.getAsByteArray(mFieldName); + } + + + @Override + public byte[] getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getBlob(columnIdx); + } + + + @Override + public void setIn(ContentValues values, byte[] value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java new file mode 100644 index 0000000..ef7e948 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Boolean} value from a {@link Cursor} or {@link ContentValues}. + *

+ * Implementation detail: + *

+ * The values are loaded and stored as 0 (for false) and 1 (for true). + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class BooleanFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link BooleanFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public BooleanFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Boolean getFrom(ContentValues values) + { + Integer value = values.getAsInteger(mFieldName); + + return value != null && value > 0; + } + + + @Override + public Boolean getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return !cursor.isNull(columnIdx) && cursor.getInt(columnIdx) > 0; + } + + + @Override + public void setIn(ContentValues values, Boolean value) + { + values.put(mFieldName, value ? 1 : 0); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java new file mode 100644 index 0000000..5c9159b --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java @@ -0,0 +1,243 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * Knows how to load and store {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. + *

+ * {@link DateTime} values are stored as three separate values: + *

    + *
  • a timestamp in milliseconds since the epoch
  • + *
  • a time zone
  • + *
  • an allday flag
  • + *
+ *

+ * This adapter combines those three fields to a {@link DateTime} value. If the time zone field is null the time zone is always set to UTC. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DateTimeFieldAdapter extends SimpleFieldAdapter +{ + private final String mTimestampField; + private final String mTzField; + private final String mAllDayField; + private final boolean mAllDayDefault; + + + /** + * Constructor for a new {@link DateTimeFieldAdapter}. + * + * @param timestampField + * The name of the field that holds the time stamp in milliseconds. + * @param tzField + * The name of the field that holds the time zone (as Olson ID). If the field name is null the time is always set to UTC. + * @param alldayField + * The name of the field that indicated that this time is a date not a date-time. If this fieldName is null all loaded values are + * non-allday. + */ + public DateTimeFieldAdapter(String timestampField, String tzField, String alldayField) + { + if (timestampField == null) + { + throw new IllegalArgumentException("timestampField must not be null"); + } + mTimestampField = timestampField; + mTzField = tzField; + mAllDayField = alldayField; + mAllDayDefault = false; + } + + + @Override + String fieldName() + { + return mTimestampField; + } + + + @Override + public DateTime getFrom(ContentValues values) + { + Long timestamp = values.getAsLong(mTimestampField); + if (timestamp == null) + { + // if the time stamp is null we return null + return null; + } + String timezone = mTzField == null ? null : values.getAsString(mTzField); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // cache mAlldayField locally + String allDayField = mAllDayField; + + // set the allday flag appropriately + Integer allDayInt = allDayField == null ? null : values.getAsInteger(allDayField); + + if ((allDayInt != null && allDayInt != 0) || (allDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + + return value; + } + + + @Override + public DateTime getFrom(Cursor cursor) + { + int tsIdx = cursor.getColumnIndex(mTimestampField); + int tzIdx = mTzField == null ? -1 : cursor.getColumnIndex(mTzField); + int adIdx = mAllDayField == null ? -1 : cursor.getColumnIndex(mAllDayField); + + if (tsIdx < 0 || (mTzField != null && tzIdx < 0) || (mAllDayField != null && adIdx < 0)) + { + throw new IllegalArgumentException("At least one column is missing in cursor."); + } + + if (cursor.isNull(tsIdx)) + { + // if the time stamp is null we return null + return null; + } + + Long timestamp = cursor.getLong(tsIdx); + + String timezone = mTzField == null ? null : cursor.getString(tzIdx); + DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); + + // set the allday flag appropriately + Integer allDayInt = adIdx < 0 ? null : cursor.getInt(adIdx); + + if ((allDayInt != null && allDayInt != 0) || (mAllDayField == null && mAllDayDefault)) + { + value = value.toAllDay(); + } + return value; + } + + + @Override + public DateTime getFrom(Cursor cursor, ContentValues values) + { + int tsIdx; + int tzIdx; + int adIdx; + long timestamp; + String timeZoneId = null; + Integer allDay = 0; + + if (values != null && values.containsKey(mTimestampField)) + { + if (values.getAsLong(mTimestampField) == null) + { + // if the time stamp is null we return null + return null; + } + timestamp = values.getAsLong(mTimestampField); + } + else if (cursor != null && (tsIdx = cursor.getColumnIndex(mTimestampField)) >= 0) + { + if (cursor.isNull(tsIdx)) + { + // if the time stamp is null we return null + return null; + } + timestamp = cursor.getLong(tsIdx); + } + else + { + throw new IllegalArgumentException("Missing timestamp column."); + } + + if (mTzField != null) + { + if (values != null && values.containsKey(mTzField)) + { + timeZoneId = values.getAsString(mTzField); + } + else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTzField)) >= 0) + { + timeZoneId = cursor.getString(tzIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + if (mAllDayField != null) + { + if (values != null && values.containsKey(mAllDayField)) + { + allDay = values.getAsInteger(mAllDayField); + } + else if (cursor != null && (adIdx = cursor.getColumnIndex(mAllDayField)) >= 0) + { + allDay = cursor.getInt(adIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + DateTime value = new DateTime(timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId), timestamp); + + if (allDay != 0) + { + value = value.toAllDay(); + } + return value; + } + + + @Override + public void setIn(ContentValues values, DateTime value) + { + if (value != null) + { + // just store all three parts separately + values.put(mTimestampField, value.getTimestamp()); + + if (mTzField != null) + { + TimeZone timezone = value.getTimeZone(); + values.put(mTzField, timezone == null ? null : timezone.getID()); + } + if (mAllDayField != null) + { + values.put(mAllDayField, value.isAllDay() ? 1 : 0); + } + } + else + { + // write timestamp only, other fields may still use allday and timezone + values.put(mTimestampField, (Long) null); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java new file mode 100644 index 0000000..3f1ebbc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java @@ -0,0 +1,198 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; +import android.text.TextUtils; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.iterables.Split; +import org.dmfs.iterables.decorators.DelegatingIterable; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * Knows how to load and store {@link Iterable}s of {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DateTimeIterableFieldAdapter extends SimpleFieldAdapter, EntityType> +{ + private final String mDateTimeListFieldName; + private final String mTimeZoneFieldName; + + + /** + * Constructor for a new {@link DateTimeIterableFieldAdapter}. + * + * @param datetimeListFieldName + * The name of the field that holds the {@link DateTime} list. + * @param timezoneFieldName + * The name of the field that holds the time zone name. + */ + public DateTimeIterableFieldAdapter(String datetimeListFieldName, String timezoneFieldName) + { + if (datetimeListFieldName == null) + { + throw new IllegalArgumentException("datetimeListFieldName must not be null"); + } + mDateTimeListFieldName = datetimeListFieldName; + mTimeZoneFieldName = timezoneFieldName; + } + + + @Override + String fieldName() + { + return mDateTimeListFieldName; + } + + + @Override + public Iterable getFrom(ContentValues values) + { + String datetimeList = values.getAsString(mDateTimeListFieldName); + if (datetimeList == null) + { + // no list, return an empty Iterable + return EmptyIterable.instance(); + } + + // create a new TimeZone for the given time zone string + String timezoneString = mTimeZoneFieldName == null ? null : values.getAsString(mTimeZoneFieldName); + TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public Iterable getFrom(Cursor cursor) + { + int tdLIdx = cursor.getColumnIndex(mDateTimeListFieldName); + int tzIdx = mTimeZoneFieldName == null ? -1 : cursor.getColumnIndex(mTimeZoneFieldName); + + if (tdLIdx < 0 || (mTimeZoneFieldName != null && tzIdx < 0)) + { + throw new IllegalArgumentException("At least one column is missing in cursor."); + } + + if (cursor.isNull(tdLIdx)) + { + // if the time stamp list is null we return an empty Iterable + return EmptyIterable.instance(); + } + + String datetimeList = cursor.getString(tdLIdx); + + // create a new TimeZone for the given time zone string + String timezoneString = mTimeZoneFieldName == null ? null : cursor.getString(tzIdx); + TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public Iterable getFrom(Cursor cursor, ContentValues values) + { + int tsIdx; + int tzIdx; + String datetimeList; + String timeZoneId = null; + + if (values != null && values.containsKey(mDateTimeListFieldName)) + { + if (values.getAsString(mDateTimeListFieldName) == null) + { + // the date times are null, so we return null + return EmptyIterable.instance(); + } + datetimeList = values.getAsString(mDateTimeListFieldName); + } + else if (cursor != null && (tsIdx = cursor.getColumnIndex(mDateTimeListFieldName)) >= 0) + { + if (cursor.isNull(tsIdx)) + { + // the date times are null, so we return an empty Iterable. + return EmptyIterable.instance(); + } + datetimeList = cursor.getString(tsIdx); + } + else + { + throw new IllegalArgumentException("Missing date time list column."); + } + + if (mTimeZoneFieldName != null) + { + if (values != null && values.containsKey(mTimeZoneFieldName)) + { + timeZoneId = values.getAsString(mTimeZoneFieldName); + } + else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTimeZoneFieldName)) >= 0) + { + timeZoneId = cursor.getString(tzIdx); + } + else + { + throw new IllegalArgumentException("Missing timezone column."); + } + } + + // create a new TimeZone for the given time zone string + TimeZone timeZone = timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId); + + return new DateTimeList(timeZone, datetimeList); + } + + + @Override + public void setIn(ContentValues values, Iterable value) + { + if (value != null) + { + String stringValue = TextUtils.join(",", new Mapped<>(dt -> dt.isFloating() ? dt : dt.shiftTimeZone(DateTime.UTC), value)); + values.put(mDateTimeListFieldName, stringValue.isEmpty() ? null : stringValue); + } + else + { + values.put(mDateTimeListFieldName, (String) null); + } + } + + + private final class DateTimeList extends DelegatingIterable + { + + public DateTimeList(TimeZone timeZone, String dateTimeList) + { + super(new Mapped<>( + datetime -> !datetime.isFloating() && timeZone != null ? datetime.shiftTimeZone(timeZone) : datetime, + new Mapped( + charSequence -> DateTime.parse(timeZone, charSequence.toString()), + new Split(dateTimeList, ',')))); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java new file mode 100644 index 0000000..5a5f8eb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java @@ -0,0 +1,106 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.Duration; + + +/** + * Knows how to load and store {@link Duration} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class DurationFieldAdapter extends SimpleFieldAdapter +{ + + private final String mFieldName; + + + /** + * Constructor for a new {@link DurationFieldAdapter}. + * + * @param urlField + * The field name that holds the {@link Duration}. + */ + public DurationFieldAdapter(String urlField) + { + if (urlField == null) + { + throw new IllegalArgumentException("urlField must not be null"); + } + mFieldName = urlField; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Duration getFrom(ContentValues values) + { + String rawValue = values.getAsString(mFieldName); + if (rawValue == null) + { + return null; + } + + return Duration.parse(rawValue); + } + + + @Override + public Duration getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + + if (cursor.isNull(columnIdx)) + { + return null; + } + + return Duration.parse(cursor.getString(columnIdx)); + } + + + @Override + public void setIn(ContentValues values, Duration value) + { + if (value != null) + { + values.put(mFieldName, value.toString()); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java new file mode 100644 index 0000000..e9fe287 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java @@ -0,0 +1,148 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a specific field from or to {@link ContentValues} or from {@link Cursor}s. + * + * @param + * The type of the value this adapter stores. + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public interface FieldAdapter +{ + + /** + * Check if a value is present and non-null in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to check. + * + * @return + */ + boolean existsIn(ContentValues values); + + /** + * Check if a value is present (may be null) in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to check. + * + * @return + */ + boolean isSetIn(ContentValues values); + + /** + * Get the value from the given {@link ContentValues} + * + * @param values + * The {@link ContentValues} that contain the value to return. + * + * @return The value. + */ + FieldType getFrom(ContentValues values); + + /** + * Check if a value is present and non-null in the given {@link Cursor}. + * + * @param cursor + * The {@link Cursor} that contains the value to check. + * + * @return + */ + boolean existsIn(Cursor cursor); + + /** + * Get the value from the given {@link Cursor} + * + * @param cursor + * The {@link Cursor} that contain the value to return. + * + * @return The value. + */ + FieldType getFrom(Cursor cursor); + + /** + * Check if a value is present and non-null in the given {@link Cursor} or {@link ContentValues}. + * + * @param cursor + * The {@link Cursor} that contains the value to check. + * @param values + * The {@link ContentValues} that contains the value to check. + * + * @return + */ + boolean existsIn(Cursor cursor, ContentValues values); + + /** + * Get the value from the given {@link Cursor} or {@link ContentValues}, with the {@link ContentValues} taking precedence over the cursor values. + * + * @param cursor + * The {@link Cursor} that contains the value to return. + * @param values + * The {@link ContentValues} that contains the value to return. + * + * @return The value. + */ + FieldType getFrom(Cursor cursor, ContentValues values); + + /** + * Set a value in the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} to store the new value in. + * @param value + * The new value to store. + */ + void setIn(ContentValues values, FieldType value); + + /** + * Remove a value from the given {@link ContentValues}. + * + * @param values + * The {@link ContentValues} from which to remove the value. + */ + void removeFrom(ContentValues values); + + /** + * Copy the value from a {@link Cursor} to the given {@link ContentValues}. + * + * @param source + * The {@link Cursor} that contains the value to copy. + * @param dest + * The {@link ContentValues} to receive the value. + */ + void copyValue(Cursor source, ContentValues dest); + + /** + * Copy the value from {@link ContentValues} to another {@link ContentValues} object. + * + * @param source + * The {@link ContentValues} that contains the value to copy. + * @param dest + * The {@link ContentValues} to receive the value. + */ + void copyValue(ContentValues source, ContentValues dest); + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java new file mode 100644 index 0000000..28b8a01 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Float} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class FloatFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link FloatFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public FloatFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Float getFrom(ContentValues values) + { + return values.getAsFloat(mFieldName); + } + + + @Override + public Float getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getFloat(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Float value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java new file mode 100644 index 0000000..933c5e8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store an {@link Integer} from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class IntegerFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link IntegerFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public IntegerFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Integer getFrom(ContentValues values) + { + // return the value as Integer + return values.getAsInteger(mFieldName); + } + + + @Override + public Integer getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getInt(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Integer value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java new file mode 100644 index 0000000..517ca23 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link Long} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class LongFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link LongFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public LongFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public Long getFrom(ContentValues values) + { + return values.getAsLong(mFieldName); + } + + + @Override + public Long getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.isNull(columnIdx) ? null : cursor.getLong(columnIdx); + } + + + @Override + public void setIn(ContentValues values, Long value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java new file mode 100644 index 0000000..201b075 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java @@ -0,0 +1,122 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; + + +/** + * Knows how to load and store a {@link RecurrenceRule} from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class RRuleFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link RRuleFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public RRuleFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public RecurrenceRule getFrom(ContentValues values) + { + String rrule = values.getAsString(mFieldName); + if (rrule == null) + { + return null; + } + try + { + return new RecurrenceRule(rrule); + } + catch (InvalidRecurrenceRuleException e) + { + throw new IllegalArgumentException("can not parse RRULE '" + rrule + "'", e); + } + } + + + @Override + public RecurrenceRule getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + if (cursor.isNull(columnIdx)) + { + return null; + } + + try + { + return new RecurrenceRule(cursor.getString(columnIdx)); + } + catch (InvalidRecurrenceRuleException e) + { + throw new IllegalArgumentException("can not parse RRULE '" + cursor.getString(columnIdx) + "'", e); + } + } + + + @Override + public void setIn(ContentValues values, RecurrenceRule value) + { + if (value != null) + { + values.put(mFieldName, value.toString()); + } + else + { + values.putNull(mFieldName); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java new file mode 100644 index 0000000..2752ba9 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java @@ -0,0 +1,100 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * An abstract {@link FieldAdapter} that implements a couple of methods as used by most simple FieldAdapters. + * + * @param + * The Type of the field this adapter handles. + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public abstract class SimpleFieldAdapter implements FieldAdapter +{ + + /** + * Returns the sole field name of this adapter. + * + * @return + */ + abstract String fieldName(); + + + @Override + public boolean existsIn(ContentValues values) + { + return values.get(fieldName()) != null; + } + + + @Override + public boolean isSetIn(ContentValues values) + { + return values.containsKey(fieldName()); + } + + + @Override + public boolean existsIn(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(fieldName()); + return columnIdx >= 0 && !cursor.isNull(columnIdx); + } + + + @Override + public FieldType getFrom(Cursor cursor, ContentValues values) + { + return values.containsKey(fieldName()) ? getFrom(values) : getFrom(cursor); + } + + + @Override + public boolean existsIn(Cursor cursor, ContentValues values) + { + return existsIn(values) || existsIn(cursor); + } + + + @Override + public void removeFrom(ContentValues values) + { + values.remove(fieldName()); + } + + + @Override + public void copyValue(Cursor cursor, ContentValues values) + { + setIn(values, getFrom(cursor)); + } + + + @Override + public void copyValue(ContentValues oldValues, ContentValues newValues) + { + setIn(newValues, getFrom(oldValues)); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java new file mode 100644 index 0000000..4c5311a --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + + +/** + * Knows how to load and store a {@link String} value from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class StringFieldAdapter extends SimpleFieldAdapter +{ + + /** + * The field name this adapter uses to store the values. + */ + private final String mFieldName; + + + /** + * Constructor for a new {@link StringFieldAdapter}. + * + * @param fieldName + * The name of the field to use when loading or storing the value. + */ + public StringFieldAdapter(String fieldName) + { + if (fieldName == null) + { + throw new IllegalArgumentException("fieldName must not be null"); + } + mFieldName = fieldName; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public String getFrom(ContentValues values) + { + // return the value as String + return values.getAsString(mFieldName); + } + + + @Override + public String getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + return cursor.getString(columnIdx); + } + + + @Override + public void setIn(ContentValues values, String value) + { + if (value != null) + { + values.put(mFieldName, value); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java new file mode 100644 index 0000000..53496b8 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; +import android.database.Cursor; + +import java.net.URI; +import java.net.URL; + + +/** + * Knows how to load and store {@link URL} values from a {@link Cursor} or {@link ContentValues}. + * + * @param + * The type of the entity the field belongs to. + * + * @author Marten Gajda + */ +public final class UrlFieldAdapter extends SimpleFieldAdapter +{ + + private final String mFieldName; + + + /** + * Constructor for a new {@link UrlFieldAdapter}. + * + * @param urlField + * The field name that holds the URL. + */ + public UrlFieldAdapter(String urlField) + { + if (urlField == null) + { + throw new IllegalArgumentException("urlField must not be null"); + } + mFieldName = urlField; + } + + + @Override + String fieldName() + { + return mFieldName; + } + + + @Override + public URI getFrom(ContentValues values) + { + return values.get(mFieldName) == null ? null : URI.create(values.getAsString(mFieldName)); + } + + + @Override + public URI getFrom(Cursor cursor) + { + int columnIdx = cursor.getColumnIndex(mFieldName); + if (columnIdx < 0) + { + throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); + } + + return cursor.isNull(columnIdx) ? null : URI.create(cursor.getString(columnIdx)); + } + + + @Override + public void setIn(ContentValues values, URI value) + { + if (value != null) + { + values.put(mFieldName, value.toASCIIString()); + } + else + { + values.putNull(mFieldName); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java new file mode 100644 index 0000000..8ae6323 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * @author Marten Gajda + */ +public interface EntityProcessor> +{ + T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + + T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + + void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java new file mode 100644 index 0000000..87f7379 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * @author Marten Gajda + */ +public final class Logging> implements EntityProcessor +{ + public static final String TAG = "Logging EntityProcessor"; + private final EntityProcessor mDelegate; + + + public Logging(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before insert"); + T result = mDelegate.insert(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after insert on " + entityAdapter.id()); + return result; + } + + + @Override + public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before update of " + entityAdapter.id()); + T result = mDelegate.update(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after update of " + entityAdapter.id()); + return result; + } + + + @Override + public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + Log.d(TAG, "before delete of " + entityAdapter.id()); + mDelegate.delete(db, entityAdapter, isSyncAdapter); + Log.d(TAG, "after delete of " + entityAdapter.id()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java new file mode 100644 index 0000000..d86f026 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java @@ -0,0 +1,50 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.model.EntityAdapter; + + +/** + * A simple No-Op {@link EntityProcessor}. + * + * @author Marten Gajda + */ +public final class NoOpProcessor> implements EntityProcessor +{ + @Override + public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + return entityAdapter; + } + + + @Override + public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + return entityAdapter; + } + + + @Override + public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) + { + // do nothing + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java new file mode 100644 index 0000000..8c98fd0 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java @@ -0,0 +1,337 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.predicate.composite.AnyOf; +import org.dmfs.jems.predicate.composite.Not; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Timestamps; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceList; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.HashSet; +import java.util.TimeZone; + +import static java.util.Arrays.asList; + + +/** + * An instance {@link EntityProcessor} detaches completed instances at the start of a recurring task. + * + * @author Marten Gajda + */ +public final class Detaching implements EntityProcessor +{ + + private final EntityProcessor mDelegate; + private final EntityProcessor mTaskDelegate; + + + public Detaching(EntityProcessor delegate, EntityProcessor taskDelegate) + { + mDelegate = delegate; + mTaskDelegate = taskDelegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate for now + // if we ever support inserting instances, we'll have to make sure that inserting a completed instance results in a detached task + return mDelegate.insert(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach the given instance if all of the following conditions are met + *

+ * - The instance is a recurrence instance (INSTANCE_ORIGINAL_TIME != null) + * - and the task has been closed (IS_CLOSED != 0) + * - and the instance is the first non-closed instance (DISTANCE_FROM_CURRENT==0). + *

+ */ + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + if (entityAdapter.valueOf(InstanceAdapter.DISTANCE_FROM_CURRENT) != 0 // not the first open task + + // not closed, note we can't use IS_CLOSED at this point because its not updated yet + || (!new HashSet<>(asList(TaskContract.Tasks.STATUS_COMPLETED, TaskContract.Tasks.STATUS_CANCELLED)).contains( + entityAdapter.valueOf(new IntegerFieldAdapter<>(TaskContract.Tasks.STATUS)))) + + // not recurring + || entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME) == null) + { + // not a detachable instance + return mDelegate.update(db, entityAdapter, isSyncAdapter); + } + // update instance accordingly and detach it + return detachAll(db, mDelegate.update(db, entityAdapter, isSyncAdapter)); + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach all closed instances preceding the given one. + *

+ * TODO: this method needs some refactoring + */ + private InstanceAdapter detachAll(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + // keep some values for later + long masterId = new FirstPresent<>( + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.ORIGINAL_INSTANCE_ID))), + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.TASK_ID)))).value(); + DateTime instanceOriginalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); + + // detach instances which are completed + try (Cursor instances = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, + null, + String.format("%s < 0 and %s == ?", TaskContract.Instances.DISTANCE_FROM_CURRENT, TaskContract.Instances.ORIGINAL_INSTANCE_ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + while (instances.moveToNext()) + { + detachSingle(db, new CursorContentValuesInstanceAdapter(instances, new ContentValues())); + } + } + + // move the master to the first incomplete task + try (Cursor task = db.query(TaskDatabaseHelper.Tables.TASKS_VIEW, + null, + String.format("%s == ?", TaskContract.Tasks._ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + if (task.moveToFirst()) + { + TaskAdapter masterTask = new CursorContentValuesTaskAdapter(task, new ContentValues()); + DateTime oldStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // assume we have no instances left + boolean noInstances = true; + + // update RRULE, if existent + RecurrenceRule rule = masterTask.valueOf(TaskAdapter.RRULE); + int count = 0; + if (rule != null) + { + RecurrenceSet ruleSet = new RecurrenceSet(); + ruleSet.addInstances(new RecurrenceRuleAdapter(rule)); + if (rule.getCount() == null) + { + // rule has no count limit, allowing us to exclude exdates + ruleSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + } + RecurrenceSetIterator ruleIterator = ruleSet.iterator( + oldStart.getTimeZone(), + oldStart.getTimestamp()); + + // move DTSTART to next RRULE instance which is > instanceOriginalTime + // reduce COUNT by the number of skipped instances, if present + while (count < 1000 && ruleIterator.hasNext()) + { + DateTime inst = new DateTime(oldStart.getTimeZone(), ruleIterator.next()); + if (instanceOriginalTime.before(inst)) + { + updateStart(masterTask, inst); + noInstances = false; // just found another instance + break; + } + count += 1; + } + + if (noInstances) + { + // remove the RRULE but keep a mask for the old start + masterTask.set(TaskAdapter.EXDATE, + new Joined<>(new SingletonIterable<>(oldStart), new Sieved<>(new Not<>(oldStart::equals), masterTask.valueOf(TaskAdapter.EXDATE)))); + masterTask.set(TaskAdapter.RRULE, null); + } + else + { + // adjust COUNT if present + if (rule.getCount() != null) + { + rule.setCount(rule.getCount() - count); + masterTask.set(TaskAdapter.RRULE, rule); + } + } + } + + DateTime newStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // update RDATE and EXDATE + masterTask.set(TaskAdapter.RDATE, new Sieved<>(instanceOriginalTime::before, masterTask.valueOf(TaskAdapter.RDATE))); + masterTask.set(TaskAdapter.EXDATE, + new Sieved<>(new AnyOf<>(instanceOriginalTime::before, newStart::equals), masterTask.valueOf(TaskAdapter.EXDATE))); + + // First check if we still have any RDATE instances left + // TODO: 6 lines for something we should be able to express in one simple expression, we need to straighten lib-recur!! + RecurrenceSet rdateSet = new RecurrenceSet(); + rdateSet.addInstances(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.RDATE)).value())); + rdateSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + RecurrenceSetIterator iterator = rdateSet.iterator(DateTime.UTC, Long.MIN_VALUE); + iterator.fastForward(Long.MIN_VALUE + 1); // skip bogus start + noInstances &= !iterator.hasNext(); + + if (noInstances) + { + // no more instances left, remove the master + mTaskDelegate.delete(db, masterTask, false); + } + else + { + if (masterTask.valueOf(TaskAdapter.RRULE) == null) + { + // we don't have any RRULE, allowing us to adjust DTSTART/DUE to the first RDATE + DateTime start = new DateTime(iterator.next()); + if (masterTask.valueOf(TaskAdapter.IS_ALLDAY)) + { + start = start.toAllDay(); + } + else if (masterTask.valueOf(TaskAdapter.TIMEZONE_RAW) != null) + { + start = start.shiftTimeZone(TimeZone.getTimeZone(masterTask.valueOf(TaskAdapter.TIMEZONE_RAW))); + } + updateStart(masterTask, start); + } + + // we still have instances, update the database + mTaskDelegate.update(db, masterTask, false); + } + } + } + + return entityAdapter; + } + + + private void updateStart(TaskAdapter task, DateTime newStart) + { + // this new instance becomes the new start (or due if we don't have a start) + if (task.valueOf(TaskAdapter.DTSTART) != null) + { + DateTime oldStart = task.valueOf(TaskAdapter.DTSTART); + task.set(TaskAdapter.DTSTART, newStart); + if (task.valueOf(TaskAdapter.DUE) != null) + { + long duration = task.valueOf(TaskAdapter.DUE).getTimestamp() - oldStart.getTimestamp(); + task.set(TaskAdapter.DUE, + newStart.addDuration( + new Duration(1, (int) (duration / (3600 * 24 * 1000)), (int) (duration % (3600 * 24 * 1000)) / 1000))); + } + } + else + { + task.set(TaskAdapter.DUE, newStart); + } + + } + + + /** + * Detach the given instance. + *

+ * - clone the override into a new deleted task (set _DELETED == 1) + * - detach the original override by removing the ORIGINAL_INSTANCE_ID, ORIGINAL_INSTANCE_SYNC_ID, ORIGINAL_INSTANCE_START and ORIGINAL_INSTANCE_ALLDAY + * (i.e. all columns which relate this to the original) + * - wipe _SYNC_ID, _UID and all sync columns (make this an unsynced task) + */ + private void detachSingle(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + TaskAdapter original = entityAdapter.taskAdapter(); + TaskAdapter cloneAdapter = original.duplicate(); + + // first prepare the original to resemble the same instance but as a new, detached task + original.set(TaskAdapter.SYNC_ID, null); + original.set(TaskAdapter.SYNC_VERSION, null); + original.set(TaskAdapter.SYNC1, null); + original.set(TaskAdapter.SYNC2, null); + original.set(TaskAdapter.SYNC3, null); + original.set(TaskAdapter.SYNC4, null); + original.set(TaskAdapter.SYNC5, null); + original.set(TaskAdapter.SYNC6, null); + original.set(TaskAdapter.SYNC7, null); + original.set(TaskAdapter.SYNC8, null); + original.set(TaskAdapter._UID, null); + original.set(TaskAdapter._DIRTY, true); + original.set(TaskAdapter.ORIGINAL_INSTANCE_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, null); + original.unset(TaskAdapter.COMPLETED); + original.commit(db); + + // wipe INSTANCE_ORIGINAL_TIME from instances entry + ContentValues noOriginalTime = new ContentValues(); + noOriginalTime.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + db.update(TaskDatabaseHelper.Tables.INSTANCES, noOriginalTime, "_ID = ?", new String[] { String.valueOf(entityAdapter.id()) }); + + // reset the clone to be a deleted instance + cloneAdapter.set(TaskAdapter._DELETED, true); + // remove joined field values + cloneAdapter.unset(TaskAdapter.LIST_ACCESS_LEVEL); + cloneAdapter.unset(TaskAdapter.LIST_COLOR); + cloneAdapter.unset(TaskAdapter.LIST_NAME); + cloneAdapter.unset(TaskAdapter.LIST_OWNER); + cloneAdapter.unset(TaskAdapter.LIST_VISIBLE); + cloneAdapter.unset(TaskAdapter.ACCOUNT_NAME); + cloneAdapter.unset(TaskAdapter.ACCOUNT_TYPE); + cloneAdapter.commit(db); + + // note, we don't have to create an instance for the clone because it's deleted + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java new file mode 100644 index 0000000..f02ed1d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java @@ -0,0 +1,284 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Filtered; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.iterators.filters.NoneOf; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.handler.PropertyHandler; +import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; +import org.dmfs.provider.tasks.model.ContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An instance {@link EntityProcessor} which delegates to the appropriate task {@link EntityProcessor}. + * + * @author Marten Gajda + */ +public final class TaskValueDelegate implements EntityProcessor +{ + private final static Iterable> SPECIAL_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.SYNC1, + TaskAdapter.SYNC2, + TaskAdapter.SYNC3, + TaskAdapter.SYNC4, + TaskAdapter.SYNC5, + TaskAdapter.SYNC6, + TaskAdapter.SYNC7, + TaskAdapter.SYNC8, + TaskAdapter.SYNC_ID, + TaskAdapter.SYNC_VERSION, + // unset any list and read-only fields + TaskAdapter.VERSION, + TaskAdapter.ACCOUNT_NAME, + TaskAdapter.ACCOUNT_TYPE, + TaskAdapter.LIST_VISIBLE, + TaskAdapter.LIST_COLOR, + TaskAdapter.LIST_NAME, + TaskAdapter.LIST_ACCESS_LEVEL, + TaskAdapter.LIST_OWNER, + TaskAdapter._DELETED, + TaskAdapter._DIRTY, + TaskAdapter.IS_NEW, + TaskAdapter.IS_CLOSED, + TaskAdapter.HAS_PROPERTIES, + TaskAdapter.HAS_ALARMS, + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, /* this will be resolved automatically */ + // also unset any recurrence fields + TaskAdapter.RRULE, + TaskAdapter.RDATE, + TaskAdapter.EXDATE, + TaskAdapter.CREATED, + TaskAdapter.LAST_MODIFIED + ); + + private final EntityProcessor mDelegate; + + + public TaskValueDelegate(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + Long masterTaskId = null; + if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // this is going to be an override to an existing task - make sure we add an RDATE first + masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + DateTime originalTime = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME); + // get the master and add an rdate + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); + if (masterTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + throw new IllegalArgumentException("Can't add an instance to an override instance"); + } + DateTime masterDate = new Backed(new FirstPresent<>(new Seq<>( + new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DUE)))), () -> null).value(); + if (!masterTaskAdapter.isRecurring() && masterDate != null) + { + // master is not recurring yet, also add its start as an RDATE + appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, masterDate); + } + // TODO: should we throw if the new master has no DTSTART? + appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, originalTime); + mDelegate.update(db, masterTaskAdapter, false); + + } + else + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "No task with _ID %d found", masterTaskId)); + } + } + } + + // move on with inserting the instance + TaskAdapter taskResult = mDelegate.insert(db, entityAdapter.taskAdapter(), false); + + if (masterTaskId != null) + { + // we just cloned the master task into a new instance, we need to copy the properties as well + copyProperties(db, masterTaskId, taskResult.id()); + } + + try (Cursor c = db.query(TaskDatabaseHelper.Tables.INSTANCES, new String[] { TaskContract.Instances._ID }, + TaskContract.Instances.TASK_ID + "=" + taskResult.id(), null, null, null, null)) + { + // the cursor should contain exactly one row after this operation + c.moveToFirst(); + return new ContentValuesInstanceAdapter(c.getLong(0), new ContentValues()); + } + } + + + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // if this is the master of a recurring task, we create a new instance or update an existing one for this override, otherwise we just delegate + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + if (taskAdapter.isRecurring()) + { + // clone the task to create an unsynced override + InstanceAdapter newInstanceAdapter = entityAdapter.duplicate(); + TaskAdapter override = newInstanceAdapter.taskAdapter(); + override.set(TaskAdapter.ORIGINAL_INSTANCE_ID, entityAdapter.valueOf(InstanceAdapter.TASK_ID)); + override.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); + // unset all fields which have special meaning + for (FieldAdapter specialFieldAdapter : SPECIAL_FIELD_ADAPTERS) + { + override.unset(specialFieldAdapter); + } + + // make sure we update DTSTART and DUE to match the instance values (unless they are set explicitly) + if (!taskAdapter.isUpdated(TaskAdapter.DTSTART)) + { + // set DTSTART to the instance start + override.set(TaskAdapter.DTSTART, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_START)); + } + if (!taskAdapter.isUpdated(TaskAdapter.DUE) && !taskAdapter.isUpdated(TaskAdapter.DURATION)) + { + // set DUE to the effective instance DUE and wipe any duration + override.set(TaskAdapter.DUE, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_DUE)); + override.set(TaskAdapter.DURATION, null); + } + // copy original instance allday flag + override.set(TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, taskAdapter.valueOf(TaskAdapter.IS_ALLDAY)); + + TaskAdapter newTask = mDelegate.insert(db, override, false); + + copyProperties(db, taskAdapter.id(), newTask.id()); + } + else + { + // this is a non-recurring task or it's already an override, just delegate the update + mDelegate.update(db, taskAdapter, false); + } + return entityAdapter; + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // deleted instances are converted to deleted tasks (for non-recurring tasks) or exdates (for recurring tasks). + TaskAdapter taskAdapter = entityAdapter.taskAdapter(); + + if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + /* this is an override - we have to: + * - mark it deleted + * - add an exclusion to the master task + * + * TODO: if this instance was added by an RDATE, just remove the RDATE + * TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate + * TODO: if this is the last instance of a finite task, consider just setting a new recurrence end + */ + long masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + DateTime originalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); + + // delete the override + mDelegate.delete(db, taskAdapter, false); + + // get the master and add an exdate + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); + appendDate(masterTaskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, originalTime); + mDelegate.update(db, masterTaskAdapter, false); + } + } + } + else if (taskAdapter.isRecurring()) + { + // TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate + // TODO: if this is the last instance of a finite task, consider just setting a new recurrence end + appendDate(taskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); + mDelegate.update(db, taskAdapter, false); + } + else + { + // task is non-recurring, delete it as a non-sync-adapter (effectively setting the _deleted flag) + mDelegate.delete(db, taskAdapter, false); + } + } + + + private void appendDate(TaskAdapter taskAdapter, FieldAdapter, TaskAdapter> addfieldAdapter, FieldAdapter, TaskAdapter> removefieldAdapter, DateTime dateTime) + { + taskAdapter.set(addfieldAdapter, new Joined<>(new Filtered<>(taskAdapter.valueOf(addfieldAdapter), new NoneOf<>(dateTime)), new Seq<>(dateTime))); + taskAdapter.set(removefieldAdapter, new Filtered<>(taskAdapter.valueOf(removefieldAdapter), new NoneOf<>(dateTime))); + } + + + /** + * Copy the properties from the give original task to the new task. + * + * @param db + * The {@link SQLiteDatabase} + * @param originalId + * The ID of the task of which to copy the properties + * @param newId + * The ID of the task to copy the properties to. + */ + private void copyProperties(SQLiteDatabase db, long originalId, long newId) + { + // for each property of the original task + try (Cursor c = db.query(TaskDatabaseHelper.Tables.PROPERTIES, null /* all */, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Properties.TASK_ID, originalId), null, null, null, null)) + { + // load the property and insert it for the new task + ContentValues values = new ContentValues(c.getColumnCount()); + while (c.moveToNext()) + { + values.clear(); + DatabaseUtils.cursorRowToContentValues(c, values); + PropertyHandler ph = PropertyHandlerFactory.get(values.getAsString(TaskContract.Properties.MIMETYPE)); + ph.insert(db, newId, ph.cloneForNewTask(newId, values), false); + } + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java new file mode 100644 index 0000000..238e650 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java @@ -0,0 +1,186 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.First; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An {@link EntityProcessor} which validates the instance data. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private final static Iterable> INSTANCE_FIELD_ADAPTERS = new Seq<>( + InstanceAdapter._ID, + InstanceAdapter.INSTANCE_START, + InstanceAdapter.INSTANCE_START_SORTING, + InstanceAdapter.INSTANCE_DUE, + InstanceAdapter.INSTANCE_DUE_SORTING, + InstanceAdapter.INSTANCE_ORIGINAL_TIME, + InstanceAdapter.DISTANCE_FROM_CURRENT, + InstanceAdapter.TASK_ID); + + private final static Iterable> RECURRENCE_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.RRULE, + TaskAdapter.RDATE, + TaskAdapter.EXDATE); + + private static final Iterable> ORIGINAL_INSTANCE_FIELD_ADAPTERS = new Seq<>( + TaskAdapter.ORIGINAL_INSTANCE_ID, + TaskAdapter.ORIGINAL_INSTANCE_TIME, + TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID); + + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + validateValues(entityAdapter); + validateInstanceIsNew(db, entityAdapter); + + return mDelegate.insert(db, entityAdapter, false); + } + + + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + validateValues(entityAdapter); + validateOriginalInstanceValues(entityAdapter); + return mDelegate.update(db, entityAdapter, false); + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + validateIsSyncAdapter(isSyncAdapter); + mDelegate.delete(db, entityAdapter, false); + } + + + private void validateIsSyncAdapter(boolean isSyncAdapter) + { + if (isSyncAdapter) + { + throw new UnsupportedOperationException("Sync adapters are not expected to write to the instances table."); + } + } + + + private void validateInstanceIsNew(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + Optional instanceId = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)); + Optional instanceTime = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)); + + // check if ORIGINAL_INSTANCE_ID and ORIGINAL_INSTANCE_TIME are both present/absent at the same time + if (instanceId.isPresent() != instanceTime.isPresent()) + { + throw new IllegalArgumentException(String.format("%s and %s must either be both absent or both present", + TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks.ORIGINAL_INSTANCE_TIME)); + } + + if (instanceId.isPresent()) + { + String timeStampString = Long.toString(instanceTime.value().getTimestamp()); + // Make sure there is no instance at the given time already + try (Cursor c = db.query( + TaskDatabaseHelper.Tables.INSTANCE_VIEW, + new String[] { TaskContract.Instances._ID }, + // find any instance which refers to the given original ID and has the same instance time + // for recurring tasks this matches the INSTANCE_ORIGINAL_TIME, for non-recurring tasks this matches start or due (whichever is present). + String.format("(%1$s == ? or %2$s == ?) and (%3$s == ? or %3$s is null and %4$s == ? or %3$s is null and %4$s is null and %5$s == ?) ", + TaskContract.Instances.TASK_ID, + TaskContract.Instances.ORIGINAL_INSTANCE_ID, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME, + TaskContract.Instances.INSTANCE_START, + TaskContract.Instances.INSTANCE_DUE), + new String[] { + instanceId.value().toString(), + instanceId.value().toString(), + timeStampString, + timeStampString, + timeStampString }, + null, + null, + null)) + { + if (c.getCount() > 0) + { + throw new IllegalArgumentException(String.format(Locale.ENGLISH, "Instance %s of task %d already exists", + entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME).toString(), instanceId.value())); + } + } + } + } + + + private void validateValues(InstanceAdapter instanceAdapter) + { + // actually, no instance value can be changed, the instance table only allows for updating task values + if (new First<>(new Sieved<>(instanceAdapter::isUpdated, INSTANCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("Instance columns are read-only."); + } + + TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); + // By definition, single instances don't have a recurrence set on their own, hence changes to the recurrence fields are not allowed. + if (new First<>(new Sieved<>(taskAdapter::isUpdated, RECURRENCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("Recurrence values can not be modified through the instances table."); + } + } + + + private void validateOriginalInstanceValues(InstanceAdapter instanceAdapter) + { + TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); + // Updates of ORIGINAL_INSTANCE_* fields are not allowed + if (new First<>(new Sieved<>(taskAdapter::isUpdated, ORIGINAL_INSTANCE_FIELD_ADAPTERS)).isPresent()) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_* fields can not be updated through the instances table."); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java new file mode 100644 index 0000000..0e33369 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.lists; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that performs the actual operations on task lists. + * + * @author Marten Gajda + */ +public final class ListCommitProcessor implements EntityProcessor +{ + + @Override + public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + list.commit(db); + return list; + } + + + @Override + public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + list.commit(db); + return list; + } + + + @Override + public void delete(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + db.delete(TaskDatabaseHelper.Tables.LISTS, TaskContract.TaskLists._ID + "=" + list.id(), null); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java new file mode 100644 index 0000000..8b27215 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java @@ -0,0 +1,137 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.lists; + +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import org.dmfs.provider.tasks.model.ListAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; + + +/** + * A processor to validate the values of a task list. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + throw new UnsupportedOperationException("Caller must be a sync adapter to create task lists"); + } + + if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_NAME))) + { + throw new IllegalArgumentException("ACCOUNT_NAME is required on INSERT"); + } + + if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_TYPE))) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is required on INSERT"); + } + + verifyCommon(list, isSyncAdapter); + return mDelegate.insert(db, list, isSyncAdapter); + } + + + @Override + public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) + { + if (list.isUpdated(ListAdapter.ACCOUNT_NAME)) + { + throw new IllegalArgumentException("ACCOUNT_NAME is write-once"); + } + + if (list.isUpdated(ListAdapter.ACCOUNT_TYPE)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE is write-once"); + } + + verifyCommon(list, isSyncAdapter); + return mDelegate.update(db, list, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, ListAdapter entityAdapter, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + throw new UnsupportedOperationException("Caller must be a sync adapter to delete task lists"); + } + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Performs tests that are common to insert an update operations. + * + * @param list + * The {@link ListAdapter} to verify. + * @param isSyncAdapter + * true if the caller is a sync adapter, false otherwise. + */ + private void verifyCommon(ListAdapter list, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (list.isUpdated(ListAdapter._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (isSyncAdapter) + { + // sync adapters may do all the stuff below + return; + } + + if (list.isUpdated(ListAdapter.LIST_COLOR)) + { + throw new IllegalArgumentException("Only sync adapters can change the LIST_COLOR."); + } + if (list.isUpdated(ListAdapter.LIST_NAME)) + { + throw new IllegalArgumentException("Only sync adapters can change the LIST_NAME."); + } + if (list.isUpdated(ListAdapter.SYNC_ID)) + { + throw new IllegalArgumentException("Only sync adapters can change the _SYNC_ID."); + } + if (list.isUpdated(ListAdapter.SYNC_VERSION)) + { + throw new IllegalArgumentException("Only sync adapters can change SYNC_VERSION."); + } + if (list.isUpdated(ListAdapter.OWNER)) + { + throw new IllegalArgumentException("Only sync adapters can change the list OWNER."); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java new file mode 100644 index 0000000..65bbe9a --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java @@ -0,0 +1,210 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor to adjust some task values automatically. + *

+ * Other then recurrence exceptions no relations are handled by this code. Relation specific changes go to {@link Relating}. + * + * @author Marten Gajda + */ +public final class AutoCompleting implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + private static final String[] TASK_ID_PROJECTION = { TaskContract.Tasks._ID }; + private static final String[] TASK_SYNC_ID_PROJECTION = { TaskContract.Tasks._SYNC_ID }; + + private static final String SYNC_ID_SELECTION = TaskContract.Tasks._SYNC_ID + "=?"; + private static final String TASK_ID_SELECTION = TaskContract.Tasks._ID + "=?"; + + + public AutoCompleting(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + updateFields(db, task, isSyncAdapter); + + if (!isSyncAdapter) + { + // set created date for tasks created on the device + task.set(TaskAdapter.CREATED, DateTime.now()); + } + + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + + if (isSyncAdapter && result.isRecurring()) + { + // task is recurring, update ORIGINAL_INSTANCE_ID of all exceptions that may already exists + ContentValues values = new ContentValues(1); + TaskAdapter.ORIGINAL_INSTANCE_ID.setIn(values, result.id()); + db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + "=? and " + + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " is null", new String[] { result.valueOf(TaskAdapter.SYNC_ID) }); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + updateFields(db, task, isSyncAdapter); + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + + if (isSyncAdapter && result.isRecurring() && result.isUpdated(TaskAdapter.SYNC_ID)) + { + // task is recurring, update ORIGINAL_INSTANCE_SYNC_ID of all exceptions that may already exists + ContentValues values = new ContentValues(1); + TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID.setIn(values, result.valueOf(TaskAdapter.SYNC_ID)); + db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + result.id(), null); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + private void updateFields(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + if (!isSyncAdapter) + { + task.set(TaskAdapter._DIRTY, true); + task.set(TaskAdapter.LAST_MODIFIED, DateTime.now()); + + // set proper STATUS if task has been completed + if (task.valueOf(TaskAdapter.COMPLETED) != null && !task.isUpdated(TaskAdapter.STATUS)) + { + task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); + } + } + + if (task.isUpdated(TaskAdapter.PRIORITY)) + { + Integer priority = task.valueOf(TaskAdapter.PRIORITY); + if (priority != null && priority == 0) + { + // replace priority 0 by null, it's the default and we need that for proper sorting + task.set(TaskAdapter.PRIORITY, null); + } + } + + // Find corresponding ORIGINAL_INSTANCE_ID + if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID)) + { + String[] syncId = { task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) }; + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_ID_PROJECTION, SYNC_ID_SELECTION, syncId, null, null, null)) + { + if (cursor.moveToNext()) + { + Long originalId = cursor.getLong(0); + task.set(TaskAdapter.ORIGINAL_INSTANCE_ID, originalId); + } + } + } + else if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) // Find corresponding ORIGINAL_INSTANCE_SYNC_ID + { + String[] id = { Long.toString(task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)) }; + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_SYNC_ID_PROJECTION, TASK_ID_SELECTION, id, null, null, null)) + { + if (cursor.moveToNext()) + { + String originalSyncId = cursor.getString(0); + task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, originalSyncId); + } + } + } + + // check that PERCENT_COMPLETE is an Integer between 0 and 100 if supplied also update status and completed accordingly + if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) + { + Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); + + if (!isSyncAdapter && percent != null && percent == 100) + { + if (!task.isUpdated(TaskAdapter.STATUS)) + { + task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); + } + + if (!task.isUpdated(TaskAdapter.COMPLETED)) + { + task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); + } + } + else if (!isSyncAdapter && percent != null) + { + if (!task.isUpdated(TaskAdapter.COMPLETED)) + { + task.set(TaskAdapter.COMPLETED, null); + } + } + } + + // validate STATUS and set IS_NEW and IS_CLOSED accordingly + if (task.isUpdated(TaskAdapter.STATUS) || task.id() < 0 /* this is true when the task is new */) + { + Integer status = task.valueOf(TaskAdapter.STATUS); + if (status == null) + { + status = TaskContract.Tasks.STATUS_DEFAULT; + task.set(TaskAdapter.STATUS, status); + } + + task.set(TaskAdapter.IS_NEW, status == TaskContract.Tasks.STATUS_NEEDS_ACTION); + task.set(TaskAdapter.IS_CLOSED, status == TaskContract.Tasks.STATUS_COMPLETED || status == TaskContract.Tasks.STATUS_CANCELLED); + + /* + * Update PERCENT_COMPLETE and COMPLETED (if not given). Sync adapters should know what they're doing, so don't update anything if caller is a sync + * adapter. + */ + if (status == TaskContract.Tasks.STATUS_COMPLETED && !isSyncAdapter) + { + task.set(TaskAdapter.PERCENT_COMPLETE, 100); + if (!task.isUpdated(TaskAdapter.COMPLETED) || task.valueOf(TaskAdapter.COMPLETED) == null) + { + task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); + } + } + else if (!isSyncAdapter) + { + task.set(TaskAdapter.COMPLETED, null); + } + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java new file mode 100644 index 0000000..1891ff3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java @@ -0,0 +1,397 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.function.elementary.DiffMap; +import org.dmfs.jems.iterable.composite.Diff; +import org.dmfs.jems.iterable.decorators.Mapped; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.pair.Pair; +import org.dmfs.jems.pair.elementary.RightSidedPair; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.InstanceValuesIterable; +import org.dmfs.provider.tasks.utils.Limited; +import org.dmfs.provider.tasks.utils.OverrideValuesFunction; +import org.dmfs.provider.tasks.utils.Range; +import org.dmfs.provider.tasks.utils.RowIterator; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + +import static org.dmfs.provider.tasks.model.TaskAdapter.IS_CLOSED; + + +/** + * A processor that creates or updates the instance values of a task. + * + * @author Marten Gajda + */ +public final class Instantiating implements EntityProcessor +{ + /** + * Projection we use to read the overrides of a task + */ + private final static String[] OVERRIDE_PROJECTION = { + TaskContract.Tasks._ID, + TaskContract.Tasks.DTSTART, + TaskContract.Tasks.DUE, + TaskContract.Tasks.DURATION, + TaskContract.Tasks.TZ, + TaskContract.Tasks.IS_ALLDAY, + TaskContract.Tasks.IS_CLOSED, + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME, + TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY }; + + /** + * This is a field adapter for a pseudo column to indicate that the instances may need an update, even if no relevant value has changed. This is useful to + * force an update of the sorting values when the local timezone has been changed. + *

+ * TODO: get rid of it + */ + private final static BooleanFieldAdapter UPDATE_REQUESTED = new BooleanFieldAdapter( + "org.dmfs.tasks.TaskInstanceProcessor.UPDATE_REQUESTED"); + + // for now we only expand the next upcoming instance + private final static int UPCOMING_INSTANCE_COUNT_LIMIT = 1; + + + /** + * Add a pseudo column to the given {@link ContentValues} to request an instances update, even if no time value has changed. + * + * @param values + * The {@link ContentValues} to add the pseudo column to. + */ + public static void addUpdateRequest(ContentValues values) + { + UPDATE_REQUESTED.setIn(values, true); + } + + + private final EntityProcessor mDelegate; + + + public Instantiating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // an override was created, insert a single task + updateOverrideInstance(db, result, result.id()); + } + else + { + // update the recurring instances, there may already be overrides, so we use the update method + updateMasterInstances(db, result, result.id()); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + // TODO: get rid if this mechanism + boolean updateRequested = task.isUpdated(UPDATE_REQUESTED) ? task.valueOf(UPDATE_REQUESTED) : false; + task.unset(UPDATE_REQUESTED); + + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + + if (!result.isUpdated(TaskAdapter.DTSTART) && !result.isUpdated(TaskAdapter.DUE) && !result.isUpdated(TaskAdapter.DURATION) + && !result.isUpdated(TaskAdapter.STATUS) && !result.isUpdated(TaskAdapter.RDATE) && !result.isUpdated(TaskAdapter.RRULE) && !result.isUpdated( + TaskAdapter.EXDATE) && !result.isUpdated(IS_CLOSED) && !updateRequested) + { + // date values didn't change and update not requested -> no need to update the instances table + return result; + } + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) == null) + { + updateMasterInstances(db, result, result.id()); + } + else + { + updateOverrideInstance(db, result, result.id()); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + // Note: there is a database trigger which cleans the instances table automatically when a task is deleted + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Update the instance of an override. + *

+ * TODO: take instance overrides into account + * + * @param db + * an {@link SQLiteDatabase}. + * @param taskAdapter + * the {@link TaskAdapter} of the task to insert. + * @param id + * the row id of the new task. + */ + private void updateOverrideInstance(SQLiteDatabase db, TaskAdapter taskAdapter, long id) + { + long origId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + int count = 0; + if (!taskAdapter.isUpdated(IS_CLOSED)) + { + // task status was not updated, we can take the shortcut and only update any existing instance values + for (Single values : new InstanceValuesIterable(id, taskAdapter)) + { + if (count++ > 1) + { + throw new RuntimeException("more than one instance returned for task instance which was supposed to have exactly one"); + } + ContentValues contentValues = values.value(); + // we don't know the current distance, but it for sure hasn't changed either, so just make sure we don't change it + contentValues.remove(TaskContract.Instances.DISTANCE_FROM_CURRENT); + // TASK_ID hasn't changed either + contentValues.remove(TaskContract.Instances.TASK_ID); + + db.update(TaskDatabaseHelper.Tables.INSTANCES, + contentValues, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances.TASK_ID, id), + null); + } + if (count == 0) + { + throw new RuntimeException("no instance returned for task which was supposed to have exactly one"); + } + } + else + { + // task status was updated, this might affect other instances, update them all + // ensure the distance from current is set properly for all sibling instances + try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, + String.format(Locale.ENGLISH, "(%s = %d)", TaskContract.Tasks._ID, origId), null, null, null, null)) + { + if (c.moveToFirst()) + { + TaskAdapter ta = new CursorContentValuesTaskAdapter(c, new ContentValues()); + updateMasterInstances(db, ta, ta.id()); + } + } + } + } + + + /** + * Updates the instances of an existing task + * + * @param db + * An {@link SQLiteDatabase}. + * @param taskAdapter + * the {@link TaskAdapter} of the task to update + * @param id + * the row id of the new task + */ + private void updateMasterInstances(SQLiteDatabase db, TaskAdapter taskAdapter, long id) + { + try (Cursor existingInstances = db.query( + TaskDatabaseHelper.Tables.INSTANCE_VIEW, + new String[] { + TaskContract.Instances._ID, + TaskContract.InstanceColumns.INSTANCE_ORIGINAL_TIME, + TaskContract.InstanceColumns.INSTANCE_START, + TaskContract.InstanceColumns.INSTANCE_START_SORTING, + TaskContract.InstanceColumns.INSTANCE_DUE, + TaskContract.InstanceColumns.INSTANCE_DUE_SORTING, + TaskContract.InstanceColumns.INSTANCE_DURATION, + TaskContract.InstanceColumns.TASK_ID, + TaskContract.InstanceColumns.DISTANCE_FROM_CURRENT, + TaskContract.Instances.IS_CLOSED }, + String.format(Locale.ENGLISH, "%s = ? or %s = ?", TaskContract.Instances.TASK_ID, TaskContract.Instances.ORIGINAL_INSTANCE_ID), + new String[] { Long.toString(id), Long.toString(id) }, + null, + null, + TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + Cursor overrides = db.query( + TaskDatabaseHelper.Tables.TASKS, + OVERRIDE_PROJECTION, + String.format("%s = ? AND %s != 1", TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks._DELETED), + new String[] { Long.toString(id) }, + null, + null, + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME);) + { + + /* + * The goal of the code below is to update existing instances in place (as opposed to delete and recreate all instances). We do this for two reasons: + * 1) efficiency, in most cases existing instances don't change, deleting and recreating them would be overly expensive + * 2) stable row ids, deleting and recreating instances would change their id and void any existing URIs to them + */ + final int idIdx = existingInstances.getColumnIndex(TaskContract.Instances._ID); + final int startIdx = existingInstances.getColumnIndex(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + final int taskIdIdx = existingInstances.getColumnIndex(TaskContract.Instances.TASK_ID); + final int isClosedIdx = existingInstances.getColumnIndex(TaskContract.Instances.IS_CLOSED); + final int distanceIdx = existingInstances.getColumnIndex(TaskContract.Instances.DISTANCE_FROM_CURRENT); + + // get an Iterator of all expected instances + // for very long or even infinite series we need to stop iterating at some point. + + Iterable, Optional>> diff = new Diff<>( + new Mapped<>(Single::value, new Limited<>(10000 /* hard limit for infinite rules*/, + new Mapped<>( + new DiffMap<>( + (original, override) -> override, // we have both, a regular instance and an override -> take the override + original -> original, + override -> override // we only have an override :-o, not really valid but tolerated + ), + new Diff<>( + new InstanceValuesIterable(id, taskAdapter), + new Mapped<>( + cursor -> + new OverrideValuesFunction() + .value(new CursorContentValuesTaskAdapter(cursor, new ContentValues())), + () -> new RowIterator(overrides)), + (left, right) -> { + Long leftLong = left.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + Long rightLong = right.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + // null is always smaller + if (leftLong == null) + { + return rightLong == null ? 0 : -1; + } + if (rightLong == null) + { + return 1; + } + + long ldiff = leftLong - rightLong; + return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); + })))), + new Range(existingInstances.getCount()), + (newInstanceValues, cursorRow) -> + { + existingInstances.moveToPosition(cursorRow); + long ldiff = new Backed<>(new NullSafe<>(newInstanceValues.getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME)), 0L).value() + - existingInstances.getLong(startIdx); + return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); + }); + + int distance = -1; + // sync the instances table with the new instances + for (Pair, Optional> next : diff) + { + if (distance >= UPCOMING_INSTANCE_COUNT_LIMIT - 1) + { + // we already expanded enough instances + if (!next.right().isPresent()) + { + // if no further instances exist, stop here + Long original = next.left().value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + if (original != null && existingInstances.moveToLast() && existingInstances.getLong(startIdx) < original) + { + break; + } + + // we may have to delete a few future instances + continue; + } + next = new RightSidedPair<>(next.right()); + } + + if (!next.left().isPresent()) + { + // there is no new instance for this old one, remove it + existingInstances.moveToPosition(next.right().value()); + db.delete(TaskDatabaseHelper.Tables.INSTANCES, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), null); + } + else if (!next.right().isPresent()) + { + // there is no old instance for this new one, add it + ContentValues values = next.left().value(); + if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) + { + distance += 1; + } + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + db.insert(TaskDatabaseHelper.Tables.INSTANCES, "", values); + } + else // both sides are present + { + // update this instance + existingInstances.moveToPosition(next.right().value()); + ContentValues values = next.left().value(); + if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) + { + // the distance needs to be updated + distance += 1; + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + } + + ContentValues updates = updatedOnly(values, existingInstances); + if (updates.size() > 0) + { + db.update(TaskDatabaseHelper.Tables.INSTANCES, + updates, + String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), + null); + } + } + } + } + } + + + private static ContentValues updatedOnly(ContentValues newValues, Cursor oldValues) + { + ContentValues result = new ContentValues(newValues); + for (String key : newValues.keySet()) + { + int columnIdx = oldValues.getColumnIndex(key); + if (columnIdx < 0) + { + throw new RuntimeException("Missing column " + key + " in Cursor "); + } + if (oldValues.isNull(columnIdx) && newValues.get(key) == null) + { + result.remove(key); + } + else if (!oldValues.isNull(columnIdx) && newValues.get(key) != null && oldValues.getLong(columnIdx) == newValues.getAsLong(key)) + { + result.remove(key); + } + } + return result; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java new file mode 100644 index 0000000..ea9d9cb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java @@ -0,0 +1,205 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * This processor makes sure that changing the list a task belongs is properly handled by sync adapters. This is achieved by emulating an atomic copy & delete + * operation. + *

+ * TODO: at present we only move recurrence exceptions based on the original row id. We should consider to move exceptions based on the original SYNC_ID as well + * to support moving exception sets of tasks without known master instance. + * + * @author Marten Gajda + */ +public final class Moving implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Moving(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + return mDelegate.insert(db, task, isSyncAdapter); + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + if (isSyncAdapter) + { + // sync-adapters have to implement the move logic themselves + return mDelegate.update(db, task, isSyncAdapter); + } + + if (!task.isUpdated(TaskAdapter.LIST_ID)) + { + // list has not been changed + return mDelegate.update(db, task, isSyncAdapter); + } + + long oldList = task.oldValueOf(TaskAdapter.LIST_ID); + long newList = task.valueOf(TaskAdapter.LIST_ID); + + if (oldList == newList) + { + // list has not been changed + return mDelegate.update(db, task, isSyncAdapter); + } + + Long newMasterId; + Long deletedMasterId = null; + + if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null) + { + // this is an exception, move the master first + newMasterId = task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); + if (newMasterId != null) + { + // find the master task + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks._ID + "=" + newMasterId, null, null, null, null); + try + { + if (c.moveToFirst()) + { + // move the master task + deletedMasterId = moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, null, true); + } + + } + finally + { + c.close(); + } + } + + // now move this exception, make sure we link the deleted exception to the deleted master + moveTask(db, task, oldList, newList, deletedMasterId, false); + } + else + { + newMasterId = task.id(); + // move the task to the new list + deletedMasterId = moveTask(db, task, oldList, newList, null, false); + } + + if (task.isRecurring() || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) + { + // This task is recurring and may have exceptions or it's an exception itself. Move all (other) exceptions to the new list. + Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + newMasterId + " and " + + TaskContract.Tasks._ID + "!=" + task.id(), null, null, null, null); + try + { + while (c.moveToNext()) + { + moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, deletedMasterId, true); + } + } + finally + { + c.close(); + } + } + + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + } + + + private Long moveTask(SQLiteDatabase db, TaskAdapter task, long oldList, long newList, Long deletedOriginalId, boolean commitTask) + { + /* + * The task has been moved to a different list. Sync adapters are not expected to support this (especially since the new list may belong to a completely + * different account or even account-type), so we emulate a copy & delete operation. + * + * All sync adapter fields of the task are cleared, so it looks like a new task. In addition we create a new deleted task in the old list having the old + * sync adapter field values. This means that the _ID field of the "deleted" task will not equal the _ID field f the original task. Sync adapters should + * handle that correctly. + */ + + Long result = null; + + // create a deleted task for the old one, unless the task has not been synced yet (which is always true for tasks in the local account) + if (task.valueOf(TaskAdapter.SYNC_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null + || task.valueOf(TaskAdapter.SYNC_VERSION) != null) + { + TaskAdapter deletedTask = task.duplicate(); + deletedTask.set(TaskAdapter.LIST_ID, oldList); + deletedTask.set(TaskAdapter.ORIGINAL_INSTANCE_ID, deletedOriginalId); + deletedTask.set(TaskAdapter._DELETED, true); + + // make sure we unset any values that do not exist in the tasks table + deletedTask.unset(TaskAdapter.LIST_COLOR); + deletedTask.unset(TaskAdapter.LIST_NAME); + deletedTask.unset(TaskAdapter.ACCOUNT_NAME); + deletedTask.unset(TaskAdapter.ACCOUNT_TYPE); + deletedTask.unset(TaskAdapter.LIST_OWNER); + deletedTask.unset(TaskAdapter.LIST_ACCESS_LEVEL); + deletedTask.unset(TaskAdapter.LIST_VISIBLE); + + // create the deleted task + deletedTask.commit(db); + + result = deletedTask.id(); + } + + // clear all sync fields to convert the existing task to a new task + task.set(TaskAdapter.LIST_ID, newList); + task.set(TaskAdapter._DIRTY, true); + task.set(TaskAdapter.SYNC1, null); + task.set(TaskAdapter.SYNC2, null); + task.set(TaskAdapter.SYNC3, null); + task.set(TaskAdapter.SYNC4, null); + task.set(TaskAdapter.SYNC5, null); + task.set(TaskAdapter.SYNC6, null); + task.set(TaskAdapter.SYNC7, null); + task.set(TaskAdapter.SYNC8, null); + task.set(TaskAdapter.SYNC_ID, null); + task.set(TaskAdapter.SYNC_VERSION, null); + task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); + if (commitTask) + { + task.commit(db); + } + + return result; + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java new file mode 100644 index 0000000..15e82b4 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java @@ -0,0 +1,77 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.Locale; + + +/** + * An {@link EntityProcessor} which updates the {@link TaskContract.Tasks#ORIGINAL_INSTANCE_ID} of any overrides when a master is inserted which has the + * matching {@link TaskContract.Tasks#ORIGINAL_INSTANCE_SYNC_ID}. + * + * @author Marten Gajda + */ +public final class Originating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Originating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + String syncId = result.valueOf(TaskAdapter.SYNC_ID); + if (syncId != null) + { + // A master task with a syncId has been inserted. + // Update original ID of any existing overrides. + ContentValues values = new ContentValues(1); + values.put(TaskContract.Tasks.ORIGINAL_INSTANCE_ID, result.id()); + db.update(TaskDatabaseHelper.Tables.TASKS, values, String.format(Locale.ENGLISH, "%s = ?", TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID), + new String[] { syncId }); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java new file mode 100644 index 0000000..d641779 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java @@ -0,0 +1,150 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that updates relations for new tasks. + *

+ * In general there is no guarantee that a related task is already in the database when a task is + * inserted. In such a case we can not set the {@link TaskContract.Property.Relation#RELATED_ID} value. This processor updates the {@link + * TaskContract.Property.Relation#RELATED_ID} when a task is inserted. + *

+ * It also updates {@link TaskContract.Property.Relation#RELATED_UID} when a tasks + * is synced the first time and a UID has been set. + *

+ * + * @author Marten Gajda + */ +public final class Relating implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Relating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + // A new task has been inserted by the sync adapter. Update all relations that point to this task. + + if (!isSyncAdapter) + { + // the task was created on the device, so it doesn't have a UID + return result; + } + + String uid = result.valueOf(TaskAdapter._UID); + + if (uid != null) + { + ContentValues v = new ContentValues(1); + v.put(TaskContract.Property.Relation.RELATED_ID, result.id()); + + int updates = db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, + TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_UID + "=?", new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, uid }); + + if (updates > 0) + { + // there were other relations pointing towards this task, update PARENT_IDs if necessary + ContentValues parentIdValues = new ContentValues(1); + parentIdValues.put(TaskContract.Tasks.PARENT_ID, result.id()); + // iterate over all tasks which refer to this as their parent and update their PARENT_ID + try (Cursor c = db.query( + TaskDatabaseHelper.Tables.PROPERTIES, new String[] { TaskContract.Property.Relation.TASK_ID }, + String.format("%s = ? and %s = ? and %s = ?", + TaskContract.Property.Relation.MIMETYPE, + TaskContract.Property.Relation.RELATED_ID, + TaskContract.Property.Relation.RELATED_TYPE), + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + String.valueOf(result.id()), + String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT) }, + null, null, null)) + { + while (c.moveToNext()) + { + db.update(TaskDatabaseHelper.Tables.TASKS, parentIdValues, TaskContract.Tasks._ID + " = ?", new String[] { c.getString(0) }); + } + } + // TODO, way also may have to do this for all the siblings of these tasks. + } + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + // A task has been updated and may have received a UID by the sync adapter. Update all by-id references to this task. + // in this case we don't need to update any PARENT_ID because it should already be set. + + if (!isSyncAdapter) + { + // only sync adapters may assign a UID + return result; + } + + String uid = result.valueOf(TaskAdapter._UID); + + if (uid != null) + { + ContentValues v = new ContentValues(1); + v.put(TaskContract.Property.Relation.RELATED_UID, uid); + + db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, + TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, Long.toString(result.id()) }); + } + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + mDelegate.delete(db, task, isSyncAdapter); + + if (!isSyncAdapter) + { + // remove once the deletion is final, which is when the sync adapter removes it + return; + } + + db.delete(TaskDatabaseHelper.Tables.PROPERTIES, TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + Long.toString(task.id()) }); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java new file mode 100644 index 0000000..a08f9b7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java @@ -0,0 +1,119 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * An {@link EntityProcessor} which updates a task's parent-child relations when its {@link TaskContract.Tasks#PARENT_ID} is updated. + * + * @author Marten Gajda + */ +public final class Reparenting implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Reparenting(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, entityAdapter, isSyncAdapter); + if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) + { + linkParent(db, result); + } + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) + { + unlinkParent(db, entityAdapter); + TaskAdapter result = mDelegate.update(db, entityAdapter, isSyncAdapter); + linkParent(db, entityAdapter); + return result; + } + else + { + return mDelegate.update(db, entityAdapter, isSyncAdapter); + } + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + unlinkParent(db, entityAdapter); + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + private void unlinkParent(SQLiteDatabase db, TaskAdapter taskAdapter) + { + if (taskAdapter.oldValueOf(TaskAdapter.PARENT_ID) != null) + { + // delete any parent, child or sibling relation with this task + db.delete(TaskDatabaseHelper.Tables.PROPERTIES, + String.format("%s = ? AND (%s = ? and %s in (?, ?) or %s = ? and %s in (?, ?))", + TaskContract.Property.Relation.MIMETYPE, + TaskContract.Property.Relation.TASK_ID, + TaskContract.Property.Relation.RELATED_TYPE, + TaskContract.Property.Relation.RELATED_ID, + TaskContract.Property.Relation.RELATED_TYPE), + new String[] { + TaskContract.Property.Relation.CONTENT_ITEM_TYPE, + String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), + String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), + String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT), + String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), + String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), + String.valueOf(TaskContract.Property.Relation.RELTYPE_CHILD) }); + } + } + + + private void linkParent(SQLiteDatabase db, TaskAdapter taskAdapter) + { + if (taskAdapter.valueOf(TaskAdapter.PARENT_ID) != null) + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE); + values.put(TaskContract.Property.Relation.TASK_ID, taskAdapter.id()); + values.put(TaskContract.Property.Relation.RELATED_TYPE, TaskContract.Property.Relation.RELTYPE_PARENT); + values.put(TaskContract.Property.Relation.RELATED_ID, taskAdapter.valueOf(TaskAdapter.PARENT_ID)); + values.put(TaskContract.Property.Relation.RELATED_UID, taskAdapter.valueOf(TaskAdapter._UID)); + db.insert(TaskDatabaseHelper.Tables.PROPERTIES, "", values); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java new file mode 100644 index 0000000..5b62b8e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.FTSDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Profiled; + + +/** + * An {@link EntityProcessor} to update the fast text search table when inserting or updating a task. + * + * @author Marten Gajda + */ +public final class Searchable implements EntityProcessor +{ + private final EntityProcessor mDelegate; + + + public Searchable(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); + new Profiled("InsertFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); + return result; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); + new Profiled("UpdateFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); + return result; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + new Profiled("DeleteFTS").run(() -> mDelegate.delete(db, entityAdapter, isSyncAdapter)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java new file mode 100644 index 0000000..677dd61 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that performs the actual operations on tasks. + * + * @author Marten Gajda + */ +public final class TaskCommitProcessor implements EntityProcessor +{ + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + task.commit(db); + return task; + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + task.commit(db); + return task; + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + String accountType = task.valueOf(TaskAdapter.ACCOUNT_TYPE); + + if (isSyncAdapter || TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType)) + { + // this is a local task or it's removed by a sync adapter, in either case we delete it right away + db.delete(TaskDatabaseHelper.Tables.TASKS, TaskContract.TaskColumns._ID + "=" + task.id(), null); + } + else + { + // just set the deleted flag otherwise + task.set(TaskAdapter._DELETED, true); + task.commit(db); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java new file mode 100644 index 0000000..a28a97e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java @@ -0,0 +1,278 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.rfc5545.Duration; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A processor that validates the values of a task. + * + * @author Marten Gajda + */ +public final class Validating implements EntityProcessor +{ + private static final String[] TASKLIST_ID_PROJECTION = { TaskContract.TaskLists._ID }; + private static final String TASKLISTS_ID_SELECTION = TaskContract.TaskLists._ID + "="; + + private final EntityProcessor mDelegate; + + + public Validating(EntityProcessor delegate) + { + mDelegate = delegate; + } + + + @Override + public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + verifyCommon(task, isSyncAdapter); + + // LIST_ID must be present and refer to an existing TaskList row id + Long listId = task.valueOf(TaskAdapter.LIST_ID); + if (listId == null) + { + throw new IllegalArgumentException("LIST_ID is required on INSERT"); + } + + // TODO: get rid of this query and use a cache instead + // TODO: ensure that the list is writable unless the caller is a sync adapter + Cursor cursor = db.query(TaskDatabaseHelper.Tables.LISTS, TASKLIST_ID_PROJECTION, TASKLISTS_ID_SELECTION + listId, null, null, null, null); + try + { + if (cursor == null || cursor.getCount() != 1) + { + throw new IllegalArgumentException("LIST_ID must refer to an existing TaskList"); + } + } + finally + { + if (cursor != null) + { + cursor.close(); + } + } + return mDelegate.insert(db, task, isSyncAdapter); + } + + + @Override + public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) + { + verifyCommon(task, isSyncAdapter); + + // only sync adapters can modify original sync id and original instance id of an existing task + if (!isSyncAdapter && (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID) || task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID))) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID can be modified by sync adapters only"); + } + + // only sync adapters are allowed to change the UID of existing tasks + if (!isSyncAdapter && task.isUpdated(TaskAdapter._UID)) + { + throw new IllegalArgumentException("modification of _UID is not allowed to non-sync adapters"); + } + + return mDelegate.update(db, task, isSyncAdapter); + } + + + @Override + public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) + { + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Performs tests that are common to insert an update operations. + * + * @param task + * The {@link TaskAdapter} to verify. + * @param isSyncAdapter + * true if the caller is a sync adapter, false otherwise. + */ + private void verifyCommon(TaskAdapter task, boolean isSyncAdapter) + { + // row id can not be changed or set manually + if (task.isUpdated(TaskAdapter._ID)) + { + throw new IllegalArgumentException("_ID can not be set manually"); + } + + if (task.isUpdated(TaskAdapter.VERSION)) + { + throw new IllegalArgumentException("VERSION can not be set manually"); + } + + // account name can not be set on a tasks + if (task.isUpdated(TaskAdapter.ACCOUNT_NAME)) + { + throw new IllegalArgumentException("ACCOUNT_NAME can not be set on a tasks"); + } + + // account type can not be set on a tasks + if (task.isUpdated(TaskAdapter.ACCOUNT_TYPE)) + { + throw new IllegalArgumentException("ACCOUNT_TYPE can not be set on a tasks"); + } + + // list color is read only for tasks + if (task.isUpdated(TaskAdapter.LIST_COLOR)) + { + throw new IllegalArgumentException("LIST_COLOR can not be set on a tasks"); + } + + // no one can undelete a task! + if (task.isUpdated(TaskAdapter._DELETED)) + { + throw new IllegalArgumentException("modification of _DELETE is not allowed"); + } + + // only sync adapters are allowed to remove the dirty flag + if (!isSyncAdapter && task.isUpdated(TaskAdapter._DIRTY)) + { + throw new IllegalArgumentException("modification of _DIRTY is not allowed"); + } + + // only sync adapters are allowed to set creation time + if (!isSyncAdapter && task.isUpdated(TaskAdapter.CREATED)) + { + throw new IllegalArgumentException("modification of CREATED is not allowed"); + } + + // IS_NEW is set automatically + if (task.isUpdated(TaskAdapter.IS_NEW)) + { + throw new IllegalArgumentException("modification of IS_NEW is not allowed"); + } + + // IS_CLOSED is set automatically + if (task.isUpdated(TaskAdapter.IS_CLOSED)) + { + throw new IllegalArgumentException("modification of IS_CLOSED is not allowed"); + } + + // HAS_PROPERTIES is set automatically + if (task.isUpdated(TaskAdapter.HAS_PROPERTIES)) + { + throw new IllegalArgumentException("modification of HAS_PROPERTIES is not allowed"); + } + + // HAS_ALARMS is set automatically + if (task.isUpdated(TaskAdapter.HAS_ALARMS)) + { + throw new IllegalArgumentException("modification of HAS_ALARMS is not allowed"); + } + + // only sync adapters are allowed to set modification time + if (!isSyncAdapter && task.isUpdated(TaskAdapter.LAST_MODIFIED)) + { + throw new IllegalArgumentException("modification of MODIFICATION_TIME is not allowed"); + } + + if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) && task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) + { + throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID must not be specified at the same time"); + } + + // check that CLASSIFICATION is an Integer between 0 and 2 if given + if (task.isUpdated(TaskAdapter.CLASSIFICATION)) + { + Integer classification = task.valueOf(TaskAdapter.CLASSIFICATION); + if (classification != null && (classification < 0 || classification > 2)) + { + throw new IllegalArgumentException("CLASSIFICATION must be an integer between 0 and 2"); + } + } + + // check that PRIORITY is an Integer between 0 and 9 if given + if (task.isUpdated(TaskAdapter.PRIORITY)) + { + Integer priority = task.valueOf(TaskAdapter.PRIORITY); + if (priority != null && (priority < 0 || priority > 9)) + { + throw new IllegalArgumentException("PRIORITY must be an integer between 0 and 9"); + } + } + + // check that PERCENT_COMPLETE is an Integer between 0 and 100 + if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) + { + Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); + if (percent != null && (percent < 0 || percent > 100)) + { + throw new IllegalArgumentException("PERCENT_COMPLETE must be null or an integer between 0 and 100"); + } + } + + // validate STATUS + if (task.isUpdated(TaskAdapter.STATUS)) + { + Integer status = task.valueOf(TaskAdapter.STATUS); + if (status != null && (status < TaskContract.Tasks.STATUS_NEEDS_ACTION || status > TaskContract.Tasks.STATUS_CANCELLED)) + { + throw new IllegalArgumentException("invalid STATUS: " + status); + } + } + + // ensure that DUE and DURATION are set properly if DTSTART is given + Long dtStart = task.valueOf(TaskAdapter.DTSTART_RAW); + Long due = task.valueOf(TaskAdapter.DUE_RAW); + Duration duration = task.valueOf(TaskAdapter.DURATION); + + if (dtStart != null) + { + if (due != null && duration != null) + { + throw new IllegalArgumentException("Only one of DUE or DURATION must be supplied."); + } + else if (due != null) + { + if (due < dtStart) + { + throw new IllegalArgumentException("DUE must not be < DTSTART"); + } + } + else if (duration != null) + { + if (duration.getSign() == -1) + { + throw new IllegalArgumentException("DURATION must not be negative"); + } + } + } + else if (duration != null) + { + throw new IllegalArgumentException("DURATION must not be supplied without DTSTART"); + } + + // if one of DTSTART or DUE is given, TZ must not be null unless it's an all-day task + if ((dtStart != null || due != null) && !task.valueOf(TaskAdapter.IS_ALLDAY) && task.valueOf(TaskAdapter.TIMEZONE_RAW) == null) + { + throw new IllegalArgumentException("TIMEZONE must be supplied if one of DTSTART or DUE is not null and not all-day"); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java new file mode 100644 index 0000000..c9e606c --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.provider.tasks.utils.Zipped; +import org.dmfs.rfc5545.DateTime; + +import java.util.TimeZone; + + +/** + * A {@link Single} of date and time {@link ContentValues} of an instance. + * + * @author Marten Gajda + */ +public final class Dated extends DelegatingSingle +{ + + public Dated(Optional dateTime, String timeStampColumn, String sortingColumn, Single delegate) + { + super(new Zipped<>( + dateTime, + delegate, + (dateTime1, values) -> + { + // add timestamp and sorting + values.put(timeStampColumn, dateTime1.getTimestamp()); + values.put(sortingColumn, dateTime1.isAllDay() ? dateTime1.getInstance() : dateTime1.shiftTimeZone(TimeZone.getDefault()).getInstance()); + return values; + })); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java new file mode 100644 index 0000000..fafc5ca --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link Single} of the instance distance {@link ContentValues} of an instance. + * + * @author Marten Gajda + */ +public final class Distant extends DelegatingSingle +{ + + public Distant(int distance, Single delegate) + { + super(() -> + { + ContentValues values = delegate.value(); + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); + return values; + }); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java new file mode 100644 index 0000000..d39005f --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to a {@link Single} of {@link ContentValues} adding due data. + * + * @author Marten Gajda + */ +public final class DueDated extends DelegatingSingle +{ + public DueDated(Optional due, Single delegate) + { + super(new Dated(due, TaskContract.Instances.INSTANCE_DUE, TaskContract.Instances.INSTANCE_DUE_SORTING, delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java new file mode 100644 index 0000000..0552a87 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_DURATION} field based on the + * already populated {@link TaskContract.Instances#INSTANCE_START} and {@link TaskContract.Instances#INSTANCE_DUE} fields. + * + * @author Marten Gajda + */ +public final class Enduring implements Single +{ + private final Single mDelegate; + + + public Enduring(Single delegate) + { + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + // just store the difference between due and start, if both are present, otherwise store null + values.put(TaskContract.Instances.INSTANCE_DURATION, + new Backed( + new Zipped<>( + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), + new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)), + (start, due) -> due - start), + () -> null).value()); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java new file mode 100644 index 0000000..793ffa1 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.procedure.composite.ForEach; +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_ORIGINAL_TIME} field based on + * the given {@link Optional} original start. + * + * @author Marten Gajda + */ +public final class Overridden implements Single +{ + private final Optional mOriginalTime; + private final Single mDelegate; + + + public Overridden(DateTime originalTime, ContentValues delegate) + { + this(new Present<>(originalTime), () -> delegate); + } + + + public Overridden(Optional originalTime, Single delegate) + { + mOriginalTime = originalTime; + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + new ForEach<>(new Mapped<>(DateTime::getTimestamp, mOriginalTime)).process(time -> values.put(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, time)); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java new file mode 100644 index 0000000..5ecb320 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.decorators.DelegatingSingle; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to a {@link Single} of {@link ContentValues} adding start data. + * + * @author Marten Gajda + */ +public final class StartDated extends DelegatingSingle +{ + public StartDated(Optional start, Single delegate) + { + super(new Dated(start, TaskContract.Instances.INSTANCE_START, TaskContract.Instances.INSTANCE_START_SORTING, delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java new file mode 100644 index 0000000..65be8e9 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A decorator to {@link Single}s of {@link ContentValues} adding a {@link TaskContract.Instances#TASK_ID} to the data. + * + * @author Marten Gajda + */ +public final class TaskRelated implements Single +{ + private final long mTaskId; + private final Single mDelegate; + + + public TaskRelated(TaskAdapter taskAdapter, Single delegate) + { + this(taskAdapter.id(), delegate); + } + + + public TaskRelated(long taskId, Single delegate) + { + mTaskId = taskId; + mDelegate = delegate; + } + + + @Override + public ContentValues value() + { + ContentValues values = mDelegate.value(); + values.put(TaskContract.Instances.TASK_ID, mTaskId); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java new file mode 100644 index 0000000..b46d3cc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.single.Single; +import org.dmfs.tasks.contract.TaskContract; + + +/** + * A {@link Single} of instance data {@link ContentValues}. It initializes most columns with {@code null} values, except for {@link + * TaskContract.Instances#TASK_ID} which is left out and {@link TaskContract.Instances#DISTANCE_FROM_CURRENT} which is initialized with {@code 0} as well. + * + * @author Marten Gajda + */ +public final class VanillaInstanceData implements Single +{ + @Override + public ContentValues value() + { + ContentValues values = new ContentValues(10); + values.putNull(TaskContract.Instances.INSTANCE_START); + values.putNull(TaskContract.Instances.INSTANCE_START_SORTING); + values.putNull(TaskContract.Instances.INSTANCE_DUE); + values.putNull(TaskContract.Instances.INSTANCE_DUE_SORTING); + values.putNull(TaskContract.Instances.INSTANCE_DURATION); + values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, 0); + values.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + return values; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java new file mode 100644 index 0000000..3f37b2e --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.jems.predicate.Predicate; + +import java.util.Arrays; + + +/** + * A {@link Predicate} which determines whether all values of a ContentValues object are present in a {@link Cursor}. + * + * @author Marten Gajda + */ +public final class ContainsValues implements Predicate +{ + private final ContentValues mValues; + + + public ContainsValues(ContentValues values) + { + mValues = values; + } + + + @Override + public boolean satisfiedBy(Cursor testedInstance) + { + for (String key : mValues.keySet()) + { + int columnIdx = testedInstance.getColumnIndex(key); + if (columnIdx < 0) + { + return false; + } + + if (testedInstance.getType(columnIdx) == Cursor.FIELD_TYPE_BLOB) + { + if (!Arrays.equals(mValues.getAsByteArray(key), testedInstance.getBlob(columnIdx))) + { + return false; + } + } + else + { + String stringValue = mValues.getAsString(key); + if (stringValue != null && !stringValue.equals(testedInstance.getString(columnIdx)) || stringValue == null && !testedInstance.isNull(columnIdx)) + { + return false; + } + } + } + return true; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java new file mode 100644 index 0000000..5ee653d --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java @@ -0,0 +1,120 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.iterators.SingletonIterator; +import org.dmfs.jems.iterable.elementary.Seq; +import org.dmfs.jems.iterator.decorators.Mapped; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; +import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; +import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; + +import java.util.Iterator; + + +/** + * An {@link Iterable} of {@link Single} {@link ContentValues} of the instances of a task. + * + * @author Marten Gajda + */ +// TODO: replace Single with Generator +public final class InstanceValuesIterable implements Iterable> +{ + private final long mId; + private final TaskAdapter mTaskAdapter; + + + public InstanceValuesIterable(long id, TaskAdapter taskAdapter) + { + mId = id; + mTaskAdapter = taskAdapter; + } + + + @Override + public Iterator> iterator() + { + Optional start = new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)); + // effective due is either the actual due, start + duration or absent + Optional effectiveDue = new FirstPresent<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DUE)), + new Zipped<>(start, new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); + + Single baseData = new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(mId, new VanillaInstanceData()))))); + + if (!mTaskAdapter.isRecurring()) + { + return new SingletonIterator<>( + // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME + new org.dmfs.provider.tasks.utils.Zipped<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), + baseData, + (DateTime time, ContentValues data) -> new Overridden(time, data).value())); + } + + if (start.isPresent()) + { + Optional effectiveDuration = new FirstPresent<>( + new Seq<>( + new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), + new Zipped<>(start, effectiveDue, + (dtStart, due) -> new Duration(1, 0, (int) ((due.getTimestamp() - dtStart.getTimestamp()) / 1000))))); + + return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Overridden(new Present<>(dateTime), + new Enduring( + new DueDated(new Zipped<>(new Present<>(dateTime), effectiveDuration, this::addDuration), + new StartDated(new Present<>(dateTime), + new TaskRelated(mId, new VanillaInstanceData())))))), + new TaskInstanceIterable(mTaskAdapter).iterator()); + } + + // special treatment for recurring tasks without a DTSTART: + return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Overridden(new Present<>(dateTime), + new DueDated(new Present<>(dateTime), new TaskRelated(mId, new VanillaInstanceData())))), + new TaskInstanceIterable(mTaskAdapter).iterator()); + + } + + + private DateTime addDuration(DateTime dt, Duration dur) + { + if (dt.isAllDay() && dur.getSecondsOfDay() != 0) + { + dur = new Duration(1, dur.getWeeks() * 7 + dur.getDays() + dur.getSecondsOfDay() / (3600 * 24), 0); + } + return dt.addDuration(dur); + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java new file mode 100644 index 0000000..43833a1 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import java.util.Iterator; + + +/** + * An {@link Iterable} which limits the number of elements. + *

+ * TODO: move to jems + * + * @author Marten Gajda + * @deprecated + */ +@Deprecated +public final class Limited implements Iterable +{ + private final int mCount; + private final Iterable mDelegate; + + + public Limited(int count, Iterable delegate) + { + mCount = count; + mDelegate = delegate; + } + + + @Override + public Iterator iterator() + { + return new LimitedIterator<>(mCount, mDelegate.iterator()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java new file mode 100644 index 0000000..88bba90 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterators.AbstractBaseIterator; + +import java.util.Iterator; +import java.util.NoSuchElementException; + + +/** + * An {@link Iterator} which limits the number elements. + * TODO: move to jems + * + * @author Marten Gajda + * @deprecated + */ +@Deprecated +public final class LimitedIterator extends AbstractBaseIterator +{ + private int mCount; + private final Iterator mDelegate; + + + public LimitedIterator(int count, Iterator delegate) + { + mCount = count; + mDelegate = delegate; + } + + + @Override + public boolean hasNext() + { + return mCount > 0 && mDelegate.hasNext(); + } + + + @Override + public T next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No more elements to iterate"); + } + mCount--; + return mDelegate.next(); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java new file mode 100644 index 0000000..b8984b6 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.jems.function.Function; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.composite.Zipped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.Single; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; +import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; +import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; +import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; +import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; +import org.dmfs.rfc5545.DateTime; + + +/** + * An {@link Iterable} of {@link Single} {@link ContentValues} of the overrides of a task. + * + * @author Marten Gajda + */ +public final class OverrideValuesFunction implements Function> +{ + + @Override + public Single value(TaskAdapter taskAdapter) + { + Optional start = new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DTSTART)); + // effective due is either the actual due, start + duration or absent + Optional effectiveDue = new FirstPresent<>( + new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DUE)), + new Zipped<>(start, new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); + + Single baseData = new Distant(taskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, + new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(taskAdapter, new VanillaInstanceData()))))); + + // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME + return new org.dmfs.provider.tasks.utils.Zipped<>( + new NullSafe<>(taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), + baseData, + (DateTime time, ContentValues data) -> new Overridden(time, data).value()); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java new file mode 100644 index 0000000..a30f3fc --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.util.Log; + +import org.dmfs.jems.fragile.Fragile; +import org.dmfs.jems.single.Single; + +import java.util.Locale; + + +/** + * A simple class to measure the execution time of a given piece of code. + * + * @author Marten Gajda + */ +public final class Profiled +{ + private final String mSubject; + + + public Profiled(String subject) + { + mSubject = subject; + } + + + public void run(Runnable runnable) + { + long start = System.currentTimeMillis(); + runnable.run(); + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + + + public V run(Single runnable) + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + + + public V run(Fragile runnable) throws E + { + + long start = System.currentTimeMillis(); + try + { + return runnable.value(); + } + finally + { + Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); + } + } + +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java new file mode 100644 index 0000000..731e424 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.iterator.generators.IntSequenceGenerator; + +import java.util.Iterator; + + +/** + * An {@link Iterable} which iterates a range of numbers. + *

+ * TODO: implement in jems + * + * @author Marten Gajda + */ +@Deprecated +public final class Range implements Iterable +{ + private final int mStart; + private final int mEnd; + + + public Range(int end) + { + this(0, end); + } + + + public Range(int start, int end) + { + mStart = start; + mEnd = end; + } + + + @Override + public Iterator iterator() + { + return new LimitedIterator<>(mEnd - mStart, new IntSequenceGenerator(mStart)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java new file mode 100644 index 0000000..e5b5fab --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java @@ -0,0 +1,49 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.Context; + +import org.dmfs.iterators.elementary.Seq; + +import java.util.Iterator; + + +/** + * An {@link Iterable} of a string array resource. + * + * @author Marten Gajda + */ +public final class ResourceArray implements Iterable +{ + private final Context mContext; + private final int mResource; + + + public ResourceArray(Context context, int resource) + { + mContext = context; + mResource = resource; + } + + + @Override + public Iterator iterator() + { + return new Seq<>(mContext.getResources().getStringArray(mResource)); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java new file mode 100644 index 0000000..e49ac38 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.database.Cursor; + +import org.dmfs.iterators.AbstractBaseIterator; + +import java.util.NoSuchElementException; + + +/** + * @author Marten Gajda + */ +public final class RowIterator extends AbstractBaseIterator +{ + private final Cursor mCursor; + + + public RowIterator(Cursor cursor) + { + mCursor = cursor; + } + + + @Override + public boolean hasNext() + { + return mCursor.getCount() > 0 && !mCursor.isClosed() && !mCursor.isLast(); + } + + + @Override + public Cursor next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No other rows to iterate."); + } + mCursor.moveToNext(); + return mCursor; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java new file mode 100644 index 0000000..d96ac73 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.jems.function.Function; + +import java.util.LinkedList; +import java.util.List; + + +/** + * A {@link Function} which returns all column names of a specific table on a given database. + * + * @author Marten Gajda + */ +public final class TableColumns implements Function> +{ + private final String mTableName; + + + public TableColumns(String tableName) + { + mTableName = tableName; + } + + + @Override + public Iterable value(SQLiteDatabase db) + { + try (Cursor cursor = db.rawQuery(String.format("PRAGMA table_info(%s)", DatabaseUtils.sqlEscapeString(mTableName)), null)) + { + int nameIdx = cursor.getColumnIndexOrThrow("name"); + + List result = new LinkedList<>(); + while (cursor.moveToNext()) + { + result.add(cursor.getString(nameIdx)); + } + + return result; + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java new file mode 100644 index 0000000..b3620b3 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceList; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; + +import java.util.Iterator; +import java.util.TimeZone; + + +/** + * An {@link Iterable} of all the instances of a task. + * + * @author Marten Gajda + */ +public final class TaskInstanceIterable implements Iterable +{ + private final TaskAdapter mTaskAdapter; + + + public TaskInstanceIterable(TaskAdapter taskAdapter) + { + mTaskAdapter = taskAdapter; + } + + + @Override + public Iterator iterator() + { + DateTime dtstart = new Backed(new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)), () -> mTaskAdapter.valueOf(TaskAdapter.DUE)).value(); + + RecurrenceSet set = new RecurrenceSet(); + RecurrenceRule rule = mTaskAdapter.valueOf(TaskAdapter.RRULE); + if (rule != null) + { + if (rule.getUntil() != null && dtstart.isFloating() != rule.getUntil().isFloating()) + { + // rule UNTIL date mismatches start. This is merely a workaround for existing users. In future we should make sure + // such tasks don't exist + if (dtstart.isFloating()) + { + // make until floating too by making it floating in the current time zone + rule.setUntil(rule.getUntil().shiftTimeZone(TimeZone.getDefault()).swapTimeZone(null)); + } + else + { + // anchor UNTIL in the current time zone + rule.setUntil(new DateTime(null, rule.getUntil().getTimestamp()).swapTimeZone(TimeZone.getDefault())); + } + } + set.addInstances(new RecurrenceRuleAdapter(rule)); + } + + set.addInstances(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.RDATE)).value())); + set.addExceptions(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.EXDATE)).value())); + + return new TaskInstanceIterator(dtstart, set); + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java new file mode 100644 index 0000000..5b74cfb --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.iterators.AbstractBaseIterator; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.TimeZone; + + +/** + * An {@link Iterator} of instances as returned by a {@link RecurrenceSetIterator}. + *

+ * TODO: this should go to lib-recur + * + * @author Marten Gajda + */ +public final class TaskInstanceIterator extends AbstractBaseIterator +{ + private final DateTime mStart; + private final RecurrenceSetIterator mSetIterator; + private final String mTimezone; + + + TaskInstanceIterator(DateTime start, RecurrenceSet set) + { + this(start, set.iterator(start.getTimeZone(), start.getTimestamp()), + new Backed<>(new Mapped<>(TimeZone::getID, new NullSafe<>(start.getTimeZone())), () -> null).value()); + } + + + TaskInstanceIterator(DateTime start, RecurrenceSetIterator setIterator, String timezone) + { + mStart = start; + mSetIterator = setIterator; + mTimezone = timezone; + } + + + @Override + public boolean hasNext() + { + return mSetIterator.hasNext(); + } + + + @Override + public DateTime next() + { + if (!hasNext()) + { + throw new NoSuchElementException("No more elements to iterate"); + } + DateTime result = new DateTime(mStart.getTimeZone(), mSetIterator.next()); + return mStart.isAllDay() ? result.toAllDay() : mTimezone == null ? result.swapTimeZone(null) : result; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java new file mode 100644 index 0000000..84f0949 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java @@ -0,0 +1,55 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; + + +/** + * A {@link Single} of an array of timestamp values of a given {@link Iterable} of {@link DateTime}s. + * + * @author Marten Gajda + */ +public final class Timestamps implements Single +{ + private final Iterable mDateTimes; + + + public Timestamps(Iterable dateTimes) + { + mDateTimes = dateTimes; + } + + + @Override + public long[] value() + { + int count = 0; + for (DateTime ignored : mDateTimes) + { + count += 1; + } + long[] timeStamps = new long[count]; + int i = 0; + for (DateTime dt : mDateTimes) + { + timeStamps[i++] = dt.getTimestamp(); + } + return timeStamps; + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java new file mode 100644 index 0000000..7ae6b44 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.adapters.SinglePresent; +import org.dmfs.jems.procedure.Procedure; +import org.dmfs.jems.single.Single; + + +/** + * Experiemental Procedure which calls another procedure with a given value. + *

+ * TODO move to jems if this works out well + * + * @author Marten Gajda + */ +@Deprecated +public final class With implements Procedure> +{ + private final Optional mValue; + + + public With(T value) + { + this(() -> value); + } + + + public With(Single value) + { + this(new SinglePresent<>(value)); + } + + + public With(Optional value) + { + mValue = value; + } + + + @Override + public void process(Procedure delegate) + { + if (mValue.isPresent()) + { + delegate.process(mValue.value()); + } + } +} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java new file mode 100644 index 0000000..80df3f7 --- /dev/null +++ b/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.Optional; +import org.dmfs.jems.optional.decorators.Mapped; +import org.dmfs.jems.single.Single; +import org.dmfs.jems.single.combined.Backed; +import org.dmfs.jems.single.decorators.DelegatingSingle; + + +/** + * Experimental {@link Single} which applies a {@link BiFunction} based on the presence of an {@link Optional}. + *

+ * TODO: maybe a more appropriate name? + *

+ * TODO: move to jems + * + * @author Marten Gajda + */ +@Deprecated +public final class Zipped extends DelegatingSingle +{ + public Zipped(Optional optionalValue, Single delegate, BiFunction function) + { + super(new Backed(new Mapped<>(from -> function.value(from, delegate.value()), optionalValue), delegate)); + } +} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java b/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java new file mode 100644 index 0000000..3ce329d --- /dev/null +++ b/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java @@ -0,0 +1,1728 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.contract; + +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.SyncStateContract; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + + +/** + * Task contract. This class defines the interface to the task provider. + *

+ * TODO: Add missing javadoc. + *

+ *

+ * TODO: Specify extended properties + *

+ *

+ * TODO: Add CONTENT_URI for the attachment store. + *

+ *

+ * TODO: Also, we could use some refactoring... + *

+ * + * @author Marten Gajda + * @author Tobias Reinsch + */ +public final class TaskContract +{ + + private static Map sUriFactories = new HashMap(4); + + /** + * URI parameter to signal that the caller is a sync adapter. + */ + public static final String CALLER_IS_SYNCADAPTER = "caller_is_syncadapter"; + + /** + * URI parameter to signal the request of the extended properties of a task. + */ + public static final String LOAD_PROPERTIES = "load_properties"; + + /** + * URI parameter to submit the account name of the account we operate on. + */ + public static final String ACCOUNT_NAME = "account_name"; + + /** + * URI parameter to submit the account type of the account we operate on. + */ + public static final String ACCOUNT_TYPE = "account_type"; + + /** + * Account name for local, unsynced task lists. + */ + public static final String LOCAL_ACCOUNT_NAME = "Local"; + + /** + * Account type for local, unsynced task lists. + */ + public static final String LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"; + + /** + * Broadcast action that's sent when the task database has been initialized, either because the app was launched for the first time or because the app was + * launched after the user cleared the app data. + *

+ * The intent data represents the authority of the provider, the MIME type will be {@link #MIMETYPE_AUTHORITY}. + */ + public static final String ACTION_DATABASE_INITIALIZED = "org.dmfs.tasks.DATABASE_INITIALIZED"; + + /** + * A MIME type of an authority. Authorities itself don't seem to have a MIME type in Android, so we just use our own. + */ + public static final String MIMETYPE_AUTHORITY = ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.org.dmfs.authority.mimetype"; + + /** + * The action of the broadcast that's send when a task becomes due. The intent data will be a {@link Uri} of the task that became due. + */ + public static final String ACTION_BROADCAST_TASK_DUE = "org.dmfs.android.tasks.TASK_DUE"; + + /** + * The action of the broadcast that's send when a task starts. The intent data will be a {@link Uri} of the task that has started. + */ + public static final String ACTION_BROADCAST_TASK_STARTING = "org.dmfs.android.tasks.TASK_START"; + + /** + * A Long extra that contains a timestamp of the event that's triggered. So this is either the timestamp of the start or due date of the task. + */ + public final static String EXTRA_TASK_TIMESTAMP = "org.dmfs.provider.tasks.extra.TIMESTAMP"; + + /** + * A Boolean extra to indicate that the event that was triggered is an all-day date. + */ + public final static String EXTRA_TASK_ALLDAY = "org.dmfs.provider.tasks.extra.ALLDAY"; + + /** + * A String extra containing the timezone id of the task. + */ + public final static String EXTRA_TASK_TIMEZONE = "org.dmfs.provider.tasks.extra.TIMEZONE"; + + /** + * A String extra containing the title of the task. + */ + public final static String EXTRA_TASK_TITLE = "org.dmfs.provider.tasks.extra.TITLE"; + + /** + * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of {@link Uri}s that have been modified. This always + * goes along with an {@link #EXTRA_OPERATIONS} which contains a code for the operation executed on a Uri at the same index. + */ + public final static String EXTRA_OPERATIONS_URIS = "org.dmfs.tasks.OPERATIONS_URIS"; + + /** + * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of provider operation codes. The following codes are + * used: + *

    + *
  • 0 - for inserts
  • + *
  • 1 - for updates
  • + *
  • 2 - for deletes
  • + *
+ */ + public final static String EXTRA_OPERATIONS = "org.dmfs.tasks.OPERATIONS"; + + + /** + * Private constructor to prevent instantiation. + */ + private TaskContract() + { + } + + + /** + * A table provided for sync adapters to use for storing private sync state data. + *

+ * Only sync adapters are allowed to access this table and they may access their own rows only. + *

+ * Note that only one row per account will be stored. Updating or inserting a sync state for a specific account will override any previous sync state for + * this account. + */ + public static class SyncState implements SyncStateContract.Columns, BaseColumns + { + public final static String CONTENT_URI_PATH = "syncstate"; + + + /** + * Get the sync state content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Get the base content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(); + } + + + /** + * A set of columns for synchronization purposes. These columns exist in {@link Tasks} and in {@link TaskLists} but have different meanings. Only sync + * adapters are allowed to change these values. + * + * @author Marten Gajda + */ + public interface CommonSyncColumns + { + + /** + * A unique Sync ID as set by the sync adapter. + *

+ * Value: String + *

+ */ + String _SYNC_ID = "_sync_id"; + + /** + * Sync version as set by the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC_VERSION = "sync_version"; + + /** + * Indicates that a task or a task list has been changed. + *

+ * Value: Integer + *

+ */ + String _DIRTY = "_dirty"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC1 = "sync1"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC2 = "sync2"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC3 = "sync3"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC4 = "sync4"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC5 = "sync5"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC6 = "sync6"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC7 = "sync7"; + + /** + * A general purpose column for the sync adapter. + *

+ * Value: String + *

+ */ + String SYNC8 = "sync8"; + + } + + + /** + * Additional sync columns for task lists. + * + * @author Marten Gajda + */ + public interface TaskListSyncColumns + { + + /** + * The name of the account this list belongs to. This field is write-once. + *

+ * Value: String + *

+ */ + String ACCOUNT_NAME = "account_name"; + + /** + * The type of the account this list belongs to. This field is write-once. + *

+ * Value: String + *

+ */ + String ACCOUNT_TYPE = "account_type"; + } + + + /** + * Additional sync columns for tasks. + * + * @author Marten Gajda + */ + public interface TaskSyncColumns + { + /** + * The UID of a task. This is field can be changed by a sync adapter only. + *

+ * Value: String + *

+ */ + String _UID = "_uid"; + + /** + * Deleted flag of a task. This is set to 1 by the content provider when a task app deletes a task. The sync adapter has to remove the task + * again to finish the removal. This value is read-only. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String _DELETED = "_deleted"; + } + + + /** + * Data columns of task lists. + * + * @author Marten Gajda + */ + public interface TaskListColumns + { + + /** + * List ID. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String _ID = "_id"; + + /** + * The name of the task list. + *

+ * Value: String + *

+ */ + String LIST_NAME = "list_name"; + + /** + * The color of this list as integer (0xaarrggbb). Only the sync adapter can change this. + *

+ * Value: Integer + *

+ */ + String LIST_COLOR = "list_color"; + + /** + * The access level a user has on this list. This value is not used yet, sync adapters should set it to 0. + *

+ * Value: Integer + *

+ */ + String ACCESS_LEVEL = "list_access_level"; + + /** + * Indicates that a task list is set to be visible. + *

+ * Value: Integer (0 or 1) + *

+ */ + String VISIBLE = "visible"; + + /** + * Indicates that a task list is set to be synced. + *

+ * Value: Integer (0 or 1) + *

+ */ + String SYNC_ENABLED = "sync_enabled"; + + /** + * The email address of the list owner. + *

+ * Value: String + *

+ */ + String OWNER = "list_owner"; + + } + + + /** + * The task list table holds one entry for each task list. + * + * @author Marten Gajda + */ + public static final class TaskLists implements TaskListColumns, TaskListSyncColumns, CommonSyncColumns + { + public static final String CONTENT_URI_PATH = "tasklists"; + + /** + * The default sort order. + */ + public static final String DEFAULT_SORT_ORDER = ACCOUNT_NAME + ", " + LIST_NAME; + + /** + * An array of columns only a sync adapter is allowed to change. + */ + public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { + ACCESS_LEVEL, _DIRTY, OWNER, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, + _SYNC_ID, SYNC_VERSION, }; + + + /** + * Get the task list content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Task data columns. Defines all the values a task can have at most once. + * + * @author Marten Gajda + */ + public interface TaskColumns extends BaseColumns + { + + /** + * The row id of a task. This value is read-only + *

+ * Value: Integer + *

+ */ + String _ID = "_id"; + + /** + * The local version number of this task. The only guarantee about the value is, it's incremented whenever the task changes (this includes any + * changes applied by sync adapters). + *

+ * Note, there is no guarantee about how much it's incremented other than by at least 1. + *

+ * Value: Integer + *

+ * read-only + */ + String VERSION = "version"; + + /** + * The id of the list this task belongs to. This value is write-once and must not be null. + *

+ * Value: Integer + *

+ */ + String LIST_ID = "list_id"; + + /** + * The title of the task. + *

+ * Value: String + *

+ */ + String TITLE = "title"; + + /** + * The location of the task. + *

+ * Value: String + *

+ */ + String LOCATION = "location"; + + /** + * A geographic location related to the task. The should be a string in the format "longitude,latitude". + *

+ * Value: String + *

+ */ + String GEO = "geo"; + + /** + * The description of a task. + *

+ * Value: String + *

+ */ + String DESCRIPTION = "description"; + + /** + * The URL iCalendar field for this task. Must be a valid URI if not null- + *

+ * Value: String + *

+ */ + String URL = "url"; + + /** + * The email address of the organizer if any, {@code null} otherwise. + *

+ * Value: String + *

+ */ + String ORGANIZER = "organizer"; + + /** + * The priority of a task. This is an Integer between zero and 9. Zero means there is no priority set. 1 is the highest priority and 9 the lowest. + *

+ * Value: Integer + *

+ */ + String PRIORITY = "priority"; + + /** + * The default value of {@link #PRIORITY}. + */ + int PRIORITY_DEFAULT = 0; + + /** + * The classification of a task. This value must be either null or one of {@link #CLASSIFICATION_PUBLIC}, {@link #CLASSIFICATION_PRIVATE}, + * {@link #CLASSIFICATION_CONFIDENTIAL}. + *

+ * Value: Integer + *

+ */ + String CLASSIFICATION = "class"; + + /** + * Classification value for public tasks. + */ + int CLASSIFICATION_PUBLIC = 0; + + /** + * Classification value for private tasks. + */ + int CLASSIFICATION_PRIVATE = 1; + + /** + * Classification value for confidential tasks. + */ + int CLASSIFICATION_CONFIDENTIAL = 2; + + /** + * Default value of {@link #CLASSIFICATION}. + */ + Integer CLASSIFICATION_DEFAULT = null; + + /** + * Date of completion of this task in milliseconds since the epoch or {@code null} if this task has not been completed yet. + *

+ * Value: Long + *

+ */ + String COMPLETED = "completed"; + + /** + * Indicates that the date of completion is an all-day date. + *

+ * Value: Integer + *

+ */ + String COMPLETED_IS_ALLDAY = "completed_is_allday"; + + /** + * A number between 0 and 100 that indicates the progress of the task or null. + *

+ * Value: Integer (0-100) + *

+ */ + String PERCENT_COMPLETE = "percent_complete"; + + /** + * The status of this task. One of {@link #STATUS_NEEDS_ACTION},{@link #STATUS_IN_PROCESS}, {@link #STATUS_COMPLETED}, {@link #STATUS_CANCELLED}. + *

+ * Value: Integer + *

+ */ + String STATUS = "status"; + + /** + * A specific status indicating that nothing has been done yet. + */ + int STATUS_NEEDS_ACTION = 0; + + /** + * A specific status indicating that some work has been done. + */ + int STATUS_IN_PROCESS = 1; + + /** + * A specific status indicating that the task is completed. + */ + int STATUS_COMPLETED = 2; + + /** + * A specific status indicating that the task has been cancelled. + */ + int STATUS_CANCELLED = 3; + + /** + * The default status is "needs action". + */ + int STATUS_DEFAULT = STATUS_NEEDS_ACTION; + + /** + * A flag that indicates a task is new (i.e. not work has been done yet). This flag is read-only. Its value is 1 when + * {@link #STATUS} equals {@link #STATUS_NEEDS_ACTION} and 0 otherwise. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String IS_NEW = "is_new"; + + /** + * A flag that indicates a task is closed (no more work has to be done). This flag is read-only. Its value is 1 when + * {@link #STATUS} equals {@link #STATUS_COMPLETED} or {@link #STATUS_CANCELLED} and 0 otherwise. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String IS_CLOSED = "is_closed"; + + /** + * An individual color for this task in the format 0xaarrggbb or {@code null} to use {@link TaskListColumns#LIST_COLOR} instead. + *

+ * Value: Integer + *

+ */ + String TASK_COLOR = "task_color"; + + /** + * When this task starts in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String DTSTART = "dtstart"; + + /** + * Boolean: flag that indicates that this is an all-day task. + */ + String IS_ALLDAY = "is_allday"; + + /** + * When this task has been created in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String CREATED = "created"; + + /** + * When this task had been modified the last time in milliseconds since the epoch. + *

+ * Value: Long + *

+ */ + String LAST_MODIFIED = "last_modified"; + + /** + * String: An Olson Id of the time zone of this task. If this value is null, it's automatically replaced by the local time zone. + */ + String TZ = "tz"; + + /** + * When this task is due in milliseconds since the epoch. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task + * has no due date). + *

+ * Value: Long + *

+ */ + String DUE = "due"; + + /** + * The duration of this task. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task has no due date). Setting a + * {@link #DURATION} is not allowed when {@link #DTSTART} is null. The Value must be a duration string as in RFC 5545 Section 3.3.6. + *

+ * Value: String + *

+ */ + String DURATION = "duration"; + + /** + * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 + * and RFC 5545 Section 3.3.5) that contains dates of instances of e recurring task. + * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String RDATE = "rdate"; + + /** + * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 + * and RFC 5545 Section 3.3.5) that contains dates of exceptions of a recurring task. + * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String EXDATE = "exdate"; + + /** + * A recurrence rule as specified in RFC 5545 Section 3.3.10. + *

+ * This value must be {@code null} for exception instances. + *

+ * Value: String + *

+ */ + String RRULE = "rrule"; + + /** + * The _sync_id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or + * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. + *

+ * Value: String + *

+ */ + String ORIGINAL_INSTANCE_SYNC_ID = "original_instance_sync_id"; + + /** + * The row id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or + * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. + *

+ * Value: Long + *

+ */ + String ORIGINAL_INSTANCE_ID = "original_instance_id"; + + /** + * The time in milliseconds since the Epoch of the original instance that is overridden by this instance or null if this task is not a + * recurring instance. + *

+ * Value: Long + *

+ */ + String ORIGINAL_INSTANCE_TIME = "original_instance_time"; + + /** + * A flag indicating that the original instance was an all-day task. + *

+ * Value: Integer + *

+ */ + String ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"; + + /** + * The row id of the parent task. null if the task has no parent task. + *

+ * Note, when writing this value the task {@link Property.Relation} properties are updated accordingly. Any parent or child relations which + * make this a child of another task are deleted and a new {@link Property.Relation#RELTYPE_PARENT} relation pointing to the new parent is created. + * Be aware that Siblings will be split, i.e. they are not moved to the new parent. Currently this might cause siblings to become orphans if they + * don't have a parent-child relationship. This behavior may change in future version. + *

+ * + *

+ * Value: Long + *

+ */ + String PARENT_ID = "parent_id"; + + /** + * The sorting of this task under it's parent task. + *

+ * Value: String + *

+ */ + String SORTING = "sorting"; + + /** + * Indicates how many alarms a task has. 0 means the task has no alarms. This field is read only as it's set automatically. + *

+ * Value: Integer + *

+ * Read-only + */ + String HAS_ALARMS = "has_alarms"; + + /** + * Indicates that this task has extended properties like attachments, alarms or relations. This field is read only as it's set automatically. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String HAS_PROPERTIES = "has_properties"; + + /** + * Indicates that this task has been pinned to the notification area. This flag is moved to the exception when an exception for the first instance of a + * recurring task is created. That means, if you edit a pinned recurring task, the pinned flag is moved to the exception and cleared from the master + * task. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String PINNED = "pinned"; + } + + + /** + * Columns that are valid in a search query. + * + * @author Marten Gajda + */ + public interface TaskSearchColumns + { + /** + * The score of a task in a search result. It's an indicator for the relevance of the task. Value is in (0, 1.0] where 0 would be "no relevance" at all + * (though the result doesn't contain such tasks). + *

+ * Value: Float + *

+ */ + String SCORE = "score"; + } + + + /** + * The task table stores the data of all tasks. + * + * @author Marten Gajda + */ + public static final class Tasks implements TaskColumns, CommonSyncColumns, TaskSyncColumns, TaskSearchColumns + { + /** + * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; + + /** + * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; + + /** + * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_NAME = TaskLists.LIST_NAME; + /** + * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. To change the color of an individual task use {@code TASK_COLOR} instead. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_COLOR = TaskLists.LIST_COLOR; + + /** + * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_OWNER = TaskLists.OWNER; + + /** + * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; + + /** + * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String VISIBLE = "visible"; + + public static final String CONTENT_URI_PATH = "tasks"; + + public static final String SEARCH_URI_PATH = "tasks_search"; + + public static final String SEARCH_QUERY_PARAMETER = "q"; + + public static final String DEFAULT_SORT_ORDER = DUE; + + public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { + _DIRTY, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, _SYNC_ID, + SYNC_VERSION, }; + + + /** + * Get the tasks content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + + public static Uri getSearchUri(String authority, String query) + { + Uri.Builder builder = getUriFactory(authority).getUri(SEARCH_URI_PATH).buildUpon(); + builder.appendQueryParameter(SEARCH_QUERY_PARAMETER, Uri.encode(query)); + return builder.build(); + } + } + + + /** + * Columns of a task instance. + * + * @author Yannic Ahrens + * @author Marten Gajda + */ + public interface InstanceColumns + { + /** + * _ID of task this instance belongs to. + *

+ * Value: Long + *

+ */ + String TASK_ID = "task_id"; + + /** + * The start date of an instance in milliseconds since the epoch or null if the instance has no start date. At present this is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_START = "instance_start"; + + /** + * The due date of an instance in milliseconds since the epoch or null if the instance has no due date. At present this is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_DUE = "instance_due"; + + /** + * This column should be used in an order clause to sort instances by start date. The only guarantee about the values in this column is the sort order. + * Don't make any other assumptions about the value. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String INSTANCE_START_SORTING = "instance_start_sorting"; + + /** + * This column should be used in an order clause to sort instances by due date. The only guarantee about the values in this column is the sort order. + * Don't make any other assumptions about the value. + *

+ * Value: Long + *

+ *

+ * read-only + *

+ */ + String INSTANCE_DUE_SORTING = "instance_due_sorting"; + + /** + * The duration of an instance in milliseconds or null if the instance has only one of start or due date or none of both. At present this + * is read only. + *

+ * Value: Long + *

+ */ + String INSTANCE_DURATION = "instance_duration"; + + /** + * The start of the original instance as specified in the master task. For non-recurring task instances this is {@code null}. + *

+ * For recurring tasks, these are the timestamps which have been derived from the recurrence rule or dates, except those specified as exdates. + */ + String INSTANCE_ORIGINAL_TIME = "instance_original_time"; + + /** + * The distance of the instance from the current one. For closed instances this is always {@code -1}, for the current instance this is {@code 0}. For + * the instance after the current one this is {@code 1}, for the instance after that one it's {@code 2}, etc.. + *

+ * Value: Integer + *

+ * read-only + */ + String DISTANCE_FROM_CURRENT = "distance_from_current"; + } + + + /** + * A table containing one entry per task instance. This table is writable in order to allow modification of single instances of a task. Write operations to + * this table will be converted into operations on overrides and forwarded to the task table. + *

+ * Note: The {@link #DTSTART}, {@link #DUE} values of instances of recurring tasks represent the actual instance values, i.e. they are different for each + * instance ({@link #DURATION} is always {@code null}). + *

+ * Also, none of the instances are recurring themselves, so {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} are always {@code null}. + *

+ * TODO: Insert all instances of recurring tasks. + *

+ * The following operations are supported: + *

+ *

Insert

+ *

+ * Note, the data of an insert must not contain the fields {@link #RRULE}, {@link #RDATE} or {@link #EXDATE}. If the new instance belongs to an existing + * task the data must contain the fields {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME}. Also note, this table supports writing {@link + * #DURATION} (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} + * {@link #DUE} date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * If there already is an instance (with or without override) for the given {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME} an exception + * is thrown. + *

+ *
ORIGINAL_INSTANCE_ID valueResult
absent or emptyA new non-recurring task is created with the given + * values.
a valid {@link Tasks} row {@code _ID}An {@link #RDATE} for the given {@link #ORIGINAL_INSTANCE_TIME} time is added to + * the given master task, any {@link #EXDATE} for this time is removed. The task is inserted as an override to the given master. No fields are inherited + * though. {@link #ORIGINAL_INSTANCE_ALLDAY} will be set to {@link #IS_ALLDAY} of the master. + *

+ * Note, if the given master is non-recurring, this operation will turn it into a recurring task.

invalid {@link Tasks} row {@code + * _ID}An exception is thrown.
+ *

+ *

Update

+ *

+ * Note, the data of an update must not contain any fields related to recurrence ({@link #RRULE}, {@link #RDATE}, {@link #EXDATE}, {@link + * #ORIGINAL_INSTANCE_ID}, {@link #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY}). Also note, this table supports writing {@link #DURATION} + * (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} {@link #DUE} + * date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. + *

+ * + *
Target task typeResult
Recurring master taskA new override is created with the given data.

Note, + * any fields which are not provided are inherited from the master, except for {@link #DTSTART} and {@link #DUE} which will be inherited from the instance + * and {@link #DURATION}, {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} which are set to {@code null}. {@link #ORIGINAL_INSTANCE_ID}, {@link + * #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY} will be set accordingly.

Single instance taskThe task is + * updated with the given values.
Recurrence override with existing masterThe task is updated with the given values.
Recurrence override without existing masterThe task is updated with the given values.
+ *

+ *

Delete

+ *

+ * + * + *
Target task typeResult
Recurring master taskAn {@link #EXDATE} for this instance is added, any {@link + * #RDATE} for this instance is removed. The instance row is removed.

TODO: mark the task deleted if the remaining recurrence set is empty

Single instance taskThe {@link Tasks#_DELETED} flag of the task is set.
Recurrence override with existing + * masterThe {@link Tasks#_DELETED} flag of the override is set, an {@link #EXDATE} for this instance is added to the master, any {@link #RDATE} + * for this instance is removed from the master. TODO: mark the master deleted if the remaining recurrence set of the master is empty
Recurrence override without existing masterThe {@link Tasks#_DELETED} flag of the task is set.
+ * + * @author Yannic Ahrens + * @author Marten Gajda + */ + public static final class Instances implements TaskColumns, InstanceColumns + { + + /** + * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; + + /** + * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; + + /** + * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_NAME = TaskLists.LIST_NAME; + /** + * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value + * here. To change the color of an individual task use {@code TASK_COLOR} instead. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_COLOR = TaskLists.LIST_COLOR; + + /** + * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: String + *

+ *

+ * read-only + *

+ */ + public static final String LIST_OWNER = TaskLists.OWNER; + + /** + * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; + + /** + * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + public static final String VISIBLE = "visible"; + + /** + * Flag indicating that ths is an instance of a recurring task. + *

+ * Value: Integer + *

+ * read-only + */ + public static final String IS_RECURRING = "is_recurring"; + + public static final String CONTENT_URI_PATH = "instances"; + + public static final String DEFAULT_SORT_ORDER = INSTANCE_DUE_SORTING; + + + /** + * Get the instances content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + /** + * Available values in Categories. + *

+ * Categories are per account. It's up to the front-end to ensure consistency of category colors across accounts. + * + * @author Marten Gajda + */ + public interface CategoriesColumns + { + + String _ID = "_id"; + + String ACCOUNT_NAME = "account_name"; + + String ACCOUNT_TYPE = "account_type"; + + String NAME = "name"; + + String COLOR = "color"; + } + + + public static final class Categories implements CategoriesColumns + { + + public static final String CONTENT_URI_PATH = "categories"; + + public static final String DEFAULT_SORT_ORDER = NAME; + + + /** + * Get the categories content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface AlarmsColumns + { + String ALARM_ID = "alarm_id"; + + String LAST_TRIGGER = "last_trigger"; + + String NEXT_TRIGGER = "next_trigger"; + } + + + public static final class Alarms implements AlarmsColumns + { + + public static final String CONTENT_URI_PATH = "alarms"; + + + /** + * Get the alarms content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface PropertySyncColumns + { + String SYNC1 = "prop_sync1"; + + String SYNC2 = "prop_sync2"; + + String SYNC3 = "prop_sync3"; + + String SYNC4 = "prop_sync4"; + + String SYNC5 = "prop_sync5"; + + String SYNC6 = "prop_sync6"; + + String SYNC7 = "prop_sync7"; + + String SYNC8 = "prop_sync8"; + } + + + public interface PropertyColumns + { + + String PROPERTY_ID = "property_id"; + + String TASK_ID = "task_id"; + + String MIMETYPE = "mimetype"; + + String VERSION = "prop_version"; + + String DATA0 = "data0"; + + String DATA1 = "data1"; + + String DATA2 = "data2"; + + String DATA3 = "data3"; + + String DATA4 = "data4"; + + String DATA5 = "data5"; + + String DATA6 = "data6"; + + String DATA7 = "data7"; + + String DATA8 = "data8"; + + String DATA9 = "data9"; + + String DATA10 = "data10"; + + String DATA11 = "data11"; + + String DATA12 = "data12"; + + String DATA13 = "data13"; + + String DATA14 = "data14"; + + String DATA15 = "data15"; + } + + + public static final class Properties implements PropertySyncColumns, PropertyColumns + { + + public static final String CONTENT_URI_PATH = "properties"; + + public static final String DEFAULT_SORT_ORDER = DATA0; + + + /** + * Get the properties content {@link Uri} using the given authority. + * + * @param authority + * The authority. + * + * @return A {@link Uri}. + */ + public static Uri getContentUri(String authority) + { + return getUriFactory(authority).getUri(CONTENT_URI_PATH); + } + + } + + + public interface Property + { + /** + * Attached documents. + *

+ * Note: Attachments are write-once. To change an attachment you'll have to remove and re-add it. + *

+ * + * @author Marten Gajda + */ + interface Attachment extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attachment"; + + /** + * URL of the attachment. This is the link that points to the attached resource. + *

+ * Value: String + *

+ */ + String URL = DATA1; + + /** + * The display name of the attachment, if any. + *

+ * Value: String + *

+ */ + String DISPLAY_NAME = DATA2; + + /** + * Content-type of the attachment. + *

+ * Value: String + *

+ */ + String FORMAT = DATA3; + + /** + * File size of the attachment or -1 if unknown. + *

+ * Value: Long + *

+ */ + String SIZE = DATA4; + + /** + * A content {@link Uri} that can be used to retrieve the attachment. Sync adapters can set this field if they know how to download the attachment + * without going through the browser. + *

+ * Value: String + *

+ */ + String CONTENT_URI = DATA5; + + } + + + interface Attendee extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attendee"; + + /** + * Name of the contact, if known. + *

+ * Value: String + *

+ */ + String NAME = DATA0; + + /** + * Email address of the contact. + *

+ * Value: String + *

+ */ + String EMAIL = DATA1; + + String ROLE = DATA2; + + String STATUS = DATA3; + + String RSVP = DATA4; + } + + + /** + * Categories are immutable. For creation is either the category id or name necessary + */ + interface Category extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/category"; + + /** + * Row id of the category. + *

+ * Value: Long + *

+ */ + String CATEGORY_ID = DATA0; + + /** + * The name of the category + *

+ * Value: String + *

+ */ + String CATEGORY_NAME = DATA1; + + /** + * The decimal coded color of the category + *

+ * Value: Integer + *

+ *

+ * read-only + *

+ */ + String CATEGORY_COLOR = DATA2; + } + + + interface Comment extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/comment"; + + /** + * Comment text. + *

+ * Value: String + *

+ */ + String COMMENT = DATA0; + + /** + * Language code of the comment as defined in RFC5646 or null. + *

+ * Value: String + *

+ */ + String LANGUAGE = DATA1; + } + + + interface Contact extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/contact"; + + String NAME = DATA0; + + String LANGUAGE = DATA1; + } + + + /** + * Relations of a task. + *

+ * When writing a relation, exactly one of {@link #RELATED_ID} or {@link #RELATED_UID} must be present. The missing value and {@link + * #RELATED_CONTENT_URI} will be populated automatically if possible. + *

+ * {@link Tasks#PARENT_ID} is updated automatically if possible. + */ + interface Relation extends PropertyColumns + { + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/relation"; + + /** + * The row id of the related task. May be -1 if the property doesn't refer to a task in this database or if it doesn't refer to a task + * at all. + *

+ * Value: long + *

+ */ + String RELATED_ID = DATA1; + + /** + * The relation type. This must be one of the {@code RELTYPE_*} values. + *

+ * Value: int + *

+ */ + String RELATED_TYPE = DATA2; + + /** + * The UID of the related object. + *

+ * Value: String + *

+ */ + String RELATED_UID = DATA3; + + /** + * The content Uri of a related object in another Android content provider, if found. + *

+ * Value: String (URI) + *

+ *

+ * This field is read-only. + *

+ */ + String RELATED_CONTENT_URI = DATA5; + + /** + * The related object is the parent of the object owning this relation. + */ + int RELTYPE_PARENT = 0; + + /** + * The related object is the child of the object owning this relation. + */ + int RELTYPE_CHILD = 1; + + /** + * The related object is a sibling of the object owning this relation. + */ + int RELTYPE_SIBLING = 2; + + } + + + interface Alarm extends PropertyColumns + { + + int ALARM_TYPE_NOTHING = 0; + + int ALARM_TYPE_MESSAGE = 1; + + int ALARM_TYPE_EMAIL = 2; + + int ALARM_TYPE_SMS = 3; + + int ALARM_TYPE_SOUND = 4; + + int ALARM_REFERENCE_DUE_DATE = 1; + + int ALARM_REFERENCE_START_DATE = 2; + + /** + * The mime-type of this property. + */ + String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/alarm"; + + /** + * Number of minutes from the reference date when the alarm goes off. If the value is < 0 the alarm will go off after the reference date. + *

+ * Value: Integer + *

+ */ + String MINUTES_BEFORE = DATA0; + + /** + * The reference date for the alarm. Either {@link #ALARM_REFERENCE_DUE_DATE} or {@link #ALARM_REFERENCE_START_DATE}. + *

+ * Value: Integer + *

+ */ + String REFERENCE = DATA1; + + /** + * A message that appears with the alarm. + *

+ * Value: String + *

+ */ + String MESSAGE = DATA2; + + /** + * The type of the alarm. Use the provided alarm types {@link #ALARM_TYPE_MESSAGE}, {@link #ALARM_TYPE_SOUND}, {@link #ALARM_TYPE_NOTHING}, + * {@link #ALARM_TYPE_EMAIL} and {@link #ALARM_TYPE_SMS}. + *

+ * Value: Integer + *

+ */ + String ALARM_TYPE = DATA3; + } + + } + + + private static synchronized UriFactory getUriFactory(String authority) + { + UriFactory uriFactory = sUriFactories.get(authority); + if (uriFactory == null) + { + uriFactory = new UriFactory(authority); + uriFactory.addUri(SyncState.CONTENT_URI_PATH); + uriFactory.addUri(TaskLists.CONTENT_URI_PATH); + uriFactory.addUri(Tasks.CONTENT_URI_PATH); + uriFactory.addUri(Tasks.SEARCH_URI_PATH); + uriFactory.addUri(Instances.CONTENT_URI_PATH); + uriFactory.addUri(Categories.CONTENT_URI_PATH); + uriFactory.addUri(Alarms.CONTENT_URI_PATH); + uriFactory.addUri(Properties.CONTENT_URI_PATH); + sUriFactories.put(authority, uriFactory); + + } + return uriFactory; + } + +} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java b/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java new file mode 100644 index 0000000..0092aca --- /dev/null +++ b/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.tasks.contract; + +import android.net.Uri; + +import java.util.HashMap; +import java.util.Map; + + +/** + * TODO + */ +public final class UriFactory +{ + private final String mAuthority; + private final Map mUriMap = new HashMap(16); + + + UriFactory(String authority) + { + mAuthority = authority; + mUriMap.put(null, Uri.parse("content://" + authority)); + } + + + void addUri(String path) + { + mUriMap.put(path, Uri.parse("content://" + mAuthority + "/" + path)); + } + + + Uri getUri() + { + return mUriMap.get(null); + } + + + Uri getUri(String path) + { + return mUriMap.get(path); + } +} diff --git a/provider/src/main/res/drawable/ic_24_agendula_tasks.xml b/provider/src/main/res/drawable/ic_24_agendula_tasks.xml new file mode 100644 index 0000000..a5420b4 --- /dev/null +++ b/provider/src/main/res/drawable/ic_24_agendula_tasks.xml @@ -0,0 +1,4 @@ + + + diff --git a/provider/src/main/res/values-cs/strings.xml b/provider/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..9b043e6 --- /dev/null +++ b/provider/src/main/res/values-cs/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + přečíst úkoly + Číst úkoly a seznamy úkolů + zapsat úkoly + Vytvořit, změnit a smazat úkoly a seznamy úkolů + Vytvořit, změnit a smazat úkoly a seznamy úkolů + + diff --git a/provider/src/main/res/values-de/strings.xml b/provider/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..2f16cc7 --- /dev/null +++ b/provider/src/main/res/values-de/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Aufgaben lesen + Aufgaben lesen + Aufgaben schreiben + Aufgaben und Aufgabenlisten erstellen, bearbeiten und löschen + Aufgaben lesen und verwalten + + diff --git a/provider/src/main/res/values-es/strings.xml b/provider/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..c2808fb --- /dev/null +++ b/provider/src/main/res/values-es/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + leer tareas + Leer tareas y listas de tareas + escribir tareas + Crear, modificar y borrar tareas y listas de tareas + Crear, modificar y borrar tareas y listas de tareas + + diff --git a/provider/src/main/res/values-fr/strings.xml b/provider/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..165e07d --- /dev/null +++ b/provider/src/main/res/values-fr/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Lire tâches + Autoriser une application à lire les tâches de la listes de tâches + Écrire tâches + Autoriser une application à écrire des tâches dans la listes de tâches + Autoriser une application à écrire des tâches dans la listes de tâches + + diff --git a/provider/src/main/res/values-hu/strings.xml b/provider/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..5f73108 --- /dev/null +++ b/provider/src/main/res/values-hu/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + feladatok olvasása + Feladatok és feladatlisták olvasása + feladatok írása + Feladatok és feladatlisták létrehozása, módosítása és törlése + Feladatok és feladatlisták létrehozása, módosítása és törlése + + diff --git a/provider/src/main/res/values-it/strings.xml b/provider/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..c321b6f --- /dev/null +++ b/provider/src/main/res/values-it/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + lettura attività + lettura attività ed elenchi di attività + scrittura attività + creazione, modifica e eliminazione di attività ed elenchi di attività + accesso e modifica delle attività + + diff --git a/provider/src/main/res/values-ja/strings.xml b/provider/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..5a6f8b7 --- /dev/null +++ b/provider/src/main/res/values-ja/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + タスクを読む + タスクとタスクリストを読み込みます。 + タスクを書く + タスクとタスクリストを作成、変更、削除します。 + タスクとタスクリストを作成、変更、削除します。 + + diff --git a/provider/src/main/res/values-nl/strings.xml b/provider/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..cd41d16 --- /dev/null +++ b/provider/src/main/res/values-nl/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + leestaken + Staat een app toe om taken in je takenlijst te lezen + schrijftaken + Staat een app toe om taken in je takenlijst aan te maken + Staat een app tpe om taken in je takenlijst aan te maken + + diff --git a/provider/src/main/res/values-pl/strings.xml b/provider/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..41f57c1 --- /dev/null +++ b/provider/src/main/res/values-pl/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + Czytaj zadania + Pozwala aplikacji na czytanie zadań z Twojej listy zadań + Zapisuj zadania + Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań + Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań + + diff --git a/provider/src/main/res/values-pt-rBR/strings.xml b/provider/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..c93660c --- /dev/null +++ b/provider/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + ler tarefas + Permite que um aplicativo leia as tarefas na sua lista de tarefas + escrever tarefas + Permite que um aplicativo escreva tarefas na sua lista de tarefas + Permite que um aplicativo escreva tarefas na sua lista de tarefas + + \ No newline at end of file diff --git a/provider/src/main/res/values-pt-rPT/strings.xml b/provider/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..73ea9e8 --- /dev/null +++ b/provider/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + ler tarefas + Ler tarefas e listas de tarefas + escrever tarefas + Criar, modificar e eliminar tarefas e listas de tarefas + Criar, modificar e eliminar tarefas e listas de tarefas + + diff --git a/provider/src/main/res/values-ru/strings.xml b/provider/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..4b8d77f --- /dev/null +++ b/provider/src/main/res/values-ru/strings.xml @@ -0,0 +1,16 @@ + + + + + + + + + + чтение задач + Разрешить приложению читать задачи из Ваших списков задач + запись задач + Разрешить приложению сохранять задачи в Ваших списках задач + Разрешить приложению сохранять задачи в Ваших списках задач + + diff --git a/provider/src/main/res/values-sr/strings.xml b/provider/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..3562f79 --- /dev/null +++ b/provider/src/main/res/values-sr/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + читање задатака + Дозвољава апликацији да чита задатке са ваше листе задатака + упис задатака + Дозвољава апликацији да уноси задатке у вашу листу задатака + Дозвољава апликацији да уноси задатке у вашу листу задатака + + diff --git a/provider/src/main/res/values-uk/strings.xml b/provider/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..1cdb330 --- /dev/null +++ b/provider/src/main/res/values-uk/strings.xml @@ -0,0 +1,14 @@ + + + + + + + + читання завдань + Дозволити додатку читати ваші завдання з ваших переліків завдань + запис завдань + Дозволити додатку зберігати завдання до ваших переліків завдань + Дозволити додатку зберігати завдання до ваших переліків завдань + + diff --git a/provider/src/main/res/values/agendula_defaults.xml b/provider/src/main/res/values/agendula_defaults.xml new file mode 100644 index 0000000..816a10b --- /dev/null +++ b/provider/src/main/res/values/agendula_defaults.xml @@ -0,0 +1,16 @@ + + + + + de.jeanlucmakiola.agendula.tasks + + diff --git a/provider/src/main/res/values/agendula_provider_changed_receivers.xml b/provider/src/main/res/values/agendula_provider_changed_receivers.xml new file mode 100644 index 0000000..a4ac2ab --- /dev/null +++ b/provider/src/main/res/values/agendula_provider_changed_receivers.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/provider/src/main/res/values/strings.xml b/provider/src/main/res/values/strings.xml new file mode 100644 index 0000000..a65c33b --- /dev/null +++ b/provider/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + + Agendula tasks + + + + read tasks + read tasks and task lists + write tasks + create, modify and delete tasks and task lists + access and manage tasks + + diff --git a/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java b/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java new file mode 100644 index 0000000..04c332b --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Jean-Luc Makiola + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import org.dmfs.tasks.contract.TaskContract; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.controller.ContentProviderController; +import org.robolectric.RuntimeEnvironment; + +import de.jeanlucmakiola.agendula.provider.R; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; + + +/** + * Agendula's own test, not upstream's. + *

+ * Local-only mode is the default Agendula ships, and it rests on a claim worth pinning down: that the provider works with no account on the device at + * all. That is open question 3 in {@code docs/STORAGE-AND-SYNC.md}, and it is not obvious — we dropped {@code GET_ACCOUNTS} from a provider whose list + * cleanup was written assuming it, so the failure this guards against is the provider quietly deleting the user's task lists. See change 1 in + * {@code provider/PROVENANCE.md}. + *

+ * Robolectric, so this is not a substitute for running it on a device — but it does hold the invariant against future edits to the cleanup path. + * + * @author Jean-Luc Makiola + */ +@RunWith(RobolectricTestRunner.class) +public class ProviderAccountCleanupTest +{ + private ContentProviderController mController; + private ContentResolver mResolver; + private String mAuthority; + + + @Before + public void setUp() + { + // Robolectric ships no aarch64 SQLite in either backend: the native runtime refuses outright and the LEGACY sqlite4java shadow throws + // "Architecture 'aarch64' is not supported". Every other test in this module is architecture-independent; this is the only one that opens a + // database. Skipping beats failing on an ARM64 dev machine — but note that means these assertions are only actually exercised on x86_64, which + // CI is. If this ever starts skipping in CI, the invariant has stopped being checked at all. + assumeFalse( + "Robolectric has no SQLite backend for aarch64 — this class only runs on x86_64", + System.getProperty("os.arch", "").contains("aarch64")); + + Context context = RuntimeEnvironment.getApplication(); + Utils.clearOwnAccountTypesCache(); + mAuthority = context.getString(R.string.agendula_tasks_authority); + mController = Robolectric.buildContentProvider(TaskProvider.class).create(mAuthority); + mResolver = context.getContentResolver(); + } + + + @After + public void tearDown() + { + Utils.clearOwnAccountTypesCache(); + // Null when setUp bailed out on the architecture assumption above. + if (mController != null) + { + mController.shutdown(); + } + } + + + /** + * The authority is ours, not dmfs's. Guards against a resource merge or a careless resync quietly handing our provider back to {@code org.dmfs.tasks}, + * which would make Agendula and OpenTasks mutually uninstallable. + */ + @Test + public void authorityIsOurs() + { + assertEquals("de.jeanlucmakiola.agendula.tasks", mAuthority); + } + + + /** + * The whole of Local mode: create a local list and a task in it with no account anywhere on the device, and read both back. + */ + @Test + public void localListAndTaskSurviveWithNoAccount() + { + Uri listUri = insertLocalList("Groceries"); + assertNotNull(listUri); + + long listId = Long.parseLong(listUri.getLastPathSegment()); + ContentValues task = new ContentValues(); + task.put(Tasks.LIST_ID, listId); + task.put(Tasks.TITLE, "Oat milk"); + Uri taskUri = mResolver.insert(Tasks.getContentUri(mAuthority), task); + assertNotNull(taskUri); + + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + assertEquals(1, countRows(Tasks.getContentUri(mAuthority))); + } + + + /** + * The bug this exists for. An account-update sweep reporting zero accounts — exactly what we see without {@code GET_ACCOUNTS} — must not take a synced + * list with it. Upstream would delete this list. + */ + @Test + public void cleanUpDoesNotPruneListsOfAccountTypesWeDoNotAuthenticate() + { + insertSyncedList("Shared shopping", "someone@example.org", "bitfire.at.davdroid"); + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + + // No authenticator in this package, so nothing is prunable and no account is visible. + Utils.cleanUpLists( + RuntimeEnvironment.getApplication(), + mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), + new Account[0], + mAuthority); + + assertEquals( + "a list whose account type we cannot enumerate must never be pruned", + 1, + countRows(TaskLists.getContentUri(mAuthority))); + } + + + /** + * Local lists are exempt from cleanup regardless — upstream's rule, restated here because Local mode depends on it and it is easy to lose in a resync. + */ + @Test + public void cleanUpNeverPrunesLocalLists() + { + insertLocalList("Groceries"); + + Utils.cleanUpLists( + RuntimeEnvironment.getApplication(), + mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), + new Account[0], + mAuthority); + + assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); + } + + + /** + * With no authenticator of our own, the prunable set is empty — which is what makes the two tests above hold by construction rather than by luck. + */ + @Test + public void weAuthenticateNoAccountTypesYet() + { + assertTrue(Utils.ownAccountTypes(RuntimeEnvironment.getApplication()).isEmpty()); + } + + + private Uri insertLocalList(String name) + { + return insertSyncedList(name, TaskContract.LOCAL_ACCOUNT_NAME, TaskContract.LOCAL_ACCOUNT_TYPE); + } + + + private Uri insertSyncedList(String name, String accountName, String accountType) + { + ContentValues values = new ContentValues(); + values.put(TaskLists.LIST_NAME, name); + values.put(TaskLists.VISIBLE, 1); + values.put(TaskLists.SYNC_ENABLED, 1); + return mResolver.insert(asSyncAdapter(TaskLists.getContentUri(mAuthority), accountName, accountType), values); + } + + + private static Uri asSyncAdapter(Uri uri, String accountName, String accountType) + { + return uri.buildUpon() + .appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(TaskContract.ACCOUNT_NAME, accountName) + .appendQueryParameter(TaskContract.ACCOUNT_TYPE, accountType) + .build(); + } + + + private int countRows(Uri uri) + { + try (Cursor cursor = mResolver.query(uri, null, null, null, null)) + { + assertNotNull(cursor); + return cursor.getCount(); + } + } +} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java b/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java new file mode 100644 index 0000000..29ff809 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java @@ -0,0 +1,223 @@ +/* + * Copyright 2018 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.model.adapters; + +import android.content.ContentValues; + +import org.dmfs.iterables.EmptyIterable; +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DateTimeIterableFieldAdapterTest +{ + @Test + public void testFieldName() + { + assertThat(new DateTimeIterableFieldAdapter<>("x", "y").fieldName(), is("x")); + } + + + @Test + public void testGetFromCVAllDay1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"))); + } + + + @Test + public void testGetFromCVAllDay2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109,20180110"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"), DateTime.parse("20180110"))); + } + + + @Test + public void testGetFromCVFloating1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000"); + values.putNull("y"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"))); + } + + + @Test + public void testGetFromCVFloating2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000,20180110T140000"); + values.putNull("y"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"), DateTime.parse("20180110T140000"))); + } + + + @Test + public void testGetFromCVAbsolute1() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000Z"); + values.put("y", "Europe/Berlin"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"))); + } + + + @Test + public void testGetFromCVAbsolute2() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + values.put("x", "20180109T140000Z,20180110T140000Z"); + values.put("y", "Europe/Berlin"); + assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); + } + + + @Test + public void testSetInNull() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, null); + assertThat(values.getAsString("x"), nullValue()); + } + + + @Test + public void testSetInEmpty() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, EmptyIterable.instance()); + assertThat(values.getAsString("x"), nullValue()); + } + + + @Test + public void testSetInSingleAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"))); + assertThat(values.getAsString("x"), is("20180109")); + } + + + @Test + public void testSetInSingleFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000")); + } + + + @Test + public void testSetInSingleAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z")); + } + + + @Test + public void testSetInDoubleAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"))); + assertThat(values.getAsString("x"), is("20180109,20180110")); + } + + + @Test + public void testSetInDoubleFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000")); + } + + + @Test + public void testSetInDoubleAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z")); + } + + + @Test + public void testSetInMultiAllDay() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"), DateTime.parse("20180111"))); + assertThat(values.getAsString("x"), is("20180109,20180110,20180111")); + } + + + @Test + public void testSetInMultiFloating() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"), DateTime.parse("20180111T150000"))); + assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000,20180111T150000")); + } + + + @Test + public void testSetInMultiAbsolute() + { + ContentValues values = new ContentValues(); + FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); + adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"), + DateTime.parse("Europe/Berlin", "20180111T150000"))); + assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z,20180111T140000Z")); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java new file mode 100644 index 0000000..4d60f5b --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DatedTest +{ + + @Test + public void testAbsent() + { + ContentValues instanceData = new Dated(absent(), "ts", "sorting", ContentValues::new).value(); + // this shouldn't really add any values and go by the "defaults" + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testPresent() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new Dated(new Present<>(start), "ts", "sorting", ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong("ts", start.getTimestamp())); + assertThat(instanceData, new ContentValuesWithLong("sorting", start.getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java new file mode 100644 index 0000000..c8be962 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DistantTest +{ + + @Test + public void test() + { + ContentValues instanceData = new Distant(100, ContentValues::new).value(); + assertThat(instanceData.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(100)); + assertThat(instanceData.size(), is(1)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java new file mode 100644 index 0000000..25840e9 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.TimeZone; + +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class DueDatedTest +{ + + @Test + public void testNone() + { + ContentValues instanceData = new DueDated(absent(), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, nullValue(Long.class))); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, nullValue(Long.class))); + // this doesn't actually add anything, the ContentValues are expected to contain null values. + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testStartEurope() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testStartAmerica() + { + DateTime start = DateTime.parse("America/New_York", "20171208T125500"); + + ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java new file mode 100644 index 0000000..4fbd8f5 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class EnduringTest +{ + @Test + public void testNoValue() + { + assertThat(new Enduring(ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(ContentValues::new).value().size(), is(1)); + } + + + @Test + public void testStartValue() + { + ContentValues values = new ContentValues(1); + values.put(TaskContract.Instances.INSTANCE_START, 10); + assertThat(new Enduring(() -> new ContentValues(values)), + hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); + } + + + @Test + public void testDueValue() + { + ContentValues values = new ContentValues(1); + values.put(TaskContract.Instances.INSTANCE_DUE, 10); + assertThat(new Enduring(() -> new ContentValues(values)), + hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); + } + + + @Test + public void testStartDueValue() + { + ContentValues values = new ContentValues(2); + values.put(TaskContract.Instances.INSTANCE_START, 1); + values.put(TaskContract.Instances.INSTANCE_DUE, 10); + assertThat(new Enduring(() -> new ContentValues(values)), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, 9))); + assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(3)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java new file mode 100644 index 0000000..e56d8a4 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.optional.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class OverriddenTest +{ + @Test + public void testAbsent() + { + ContentValues instanceData = new Overridden(absent(), ContentValues::new).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testAbsentWithStart() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testAbsentWithDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testAbsentWithStartAndDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testPresent() + { + + ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), ContentValues::new).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); + assertThat(instanceData.size(), is(1)); + } + + + @Test + public void testPresentWithStartAndDue() + { + ContentValues values = new ContentValues(); + values.put(TaskContract.Instances.INSTANCE_START, 10); + values.put(TaskContract.Instances.INSTANCE_DUE, 20); + + ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), () -> new ContentValues(values)).value(); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); + assertThat(instanceData.size(), is(3)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java new file mode 100644 index 0000000..85676d1 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.TimeZone; + +import static org.dmfs.optional.Absent.absent; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class StartDatedTest +{ + + @Test + public void testNone() + { + ContentValues instanceData = new StartDated(absent(), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, nullValue(Long.class))); + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, nullValue(Long.class))); + // this doesn't actually add anything, the ContentValues are expected to contain null values. + assertThat(instanceData.size(), is(0)); + } + + + @Test + public void testStartEurope() + { + DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); + + ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } + + + @Test + public void testStartAmerica() + { + DateTime start = DateTime.parse("America/New_York", "20171208T125500"); + + ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); + + assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); + assertThat(instanceData, + new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); + assertThat(instanceData.size(), is(2)); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java new file mode 100644 index 0000000..760ca34 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.provider.tasks.utils.ContentValuesWithLong; +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TaskRelatedTest +{ + @Test + public void testValue() + { + assertThat(new TaskRelated(123, ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.TASK_ID, 123))); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java new file mode 100644 index 0000000..f78f8bf --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.tasks.instancedata; + +import android.content.ContentValues; + +import org.dmfs.tasks.contract.TaskContract; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class VanillaInstanceDataTest +{ + @Test + public void testValue() + { + ContentValues values = new VanillaInstanceData().value(); + assertThat(values.get(TaskContract.Instances.INSTANCE_START), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_START_SORTING), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DUE), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DUE_SORTING), nullValue()); + assertThat(values.get(TaskContract.Instances.INSTANCE_DURATION), nullValue()); + assertThat(values.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(0)); + assertThat(values.get(TaskContract.Instances.INSTANCE_ORIGINAL_TIME), nullValue()); + assertThat(values.size(), is(7)); + } + +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java new file mode 100644 index 0000000..750f477 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; +import android.database.MatrixCursor; + +import org.dmfs.iterables.elementary.Seq; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.predicate.PredicateMatcher.satisfiedBy; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ContainsValuesTest +{ + @Test + public void test() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b", "a", "d", "f" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", "123", null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValueX", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 1234, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, "123", "xyz")); + cursor.addRow(new Seq<>(321, "stringValueX", "1234", "123", "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1, 0 }, "stringValueX", 1234, "123", "xyz")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } + + + @Test + public void testMissingColumns() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java new file mode 100644 index 0000000..6dafc00 --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.hamcrest.FeatureMatcher; +import org.hamcrest.Matcher; + +import static org.hamcrest.Matchers.is; + + +/** + * A {@link Matcher} to test if {@link ContentValues} contain a specific Long value. + *

+ * TODO: can we convert that into a more generic {@link ContentValues} matcher? It might be useful in other places. + *

+ * TODO: also consider moving this to "Test-Bolts" + */ +public final class ContentValuesWithLong extends FeatureMatcher +{ + private final String mKey; + + + public ContentValuesWithLong(String valueKey, long value) + { + this(valueKey, is(value)); + } + + + public ContentValuesWithLong(String valueKey, Matcher matcher) + { + super(matcher, "Long value " + valueKey, "Long value " + valueKey); + mKey = valueKey; + } + + + @Override + protected Long featureValueOf(ContentValues actual) + { + return actual.getAsLong(mKey); + } +} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java new file mode 100644 index 0000000..81eff9c --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import android.content.ContentValues; + +import org.dmfs.iterables.elementary.Seq; +import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TaskInstanceIterableTest +{ + @Test + public void testAbsolute() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } + + + @Test + public void testAllDay() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("20170606"), + DateTime.parse("20170608"), + DateTime.parse("20170610"), + DateTime.parse("20170612"), + DateTime.parse("20170614"), + DateTime.parse("20170616"), + DateTime.parse("20170618"), + DateTime.parse("20170620"), + DateTime.parse("20170622"), + DateTime.parse("20170624") + )); + } + + + @Test + public void testFloating() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("20170606T121314"), + DateTime.parse("20170608T121314"), + DateTime.parse("20170610T121314"), + DateTime.parse("20170612T121314"), + DateTime.parse("20170614T121314"), + DateTime.parse("20170616T121314"), + DateTime.parse("20170618T121314"), + DateTime.parse("20170620T121314"), + DateTime.parse("20170622T121314"), + DateTime.parse("20170624T121314") + )); + } + + + @Test + public void testRDate() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } + + + @Test + public void testRDateAndRRule() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170624T121313") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121313"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java new file mode 100644 index 0000000..552f43d --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2021 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.junit.Test; + +import java.util.TimeZone; + +import static org.dmfs.jems.hamcrest.matchers.iterator.IteratorMatcher.iteratorOf; +import static org.junit.Assert.assertThat; + + +/** + * @author Marten Gajda + */ +public class TaskInstanceIteratorTest +{ + private final static String TIMEZONE = "Europe/Berlin"; + + + @Test + public void testAbsolute() throws InvalidRecurrenceRuleException + { + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse(TIMEZONE, "20210201T120000"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(TimeZone.getTimeZone(TIMEZONE), start.getTimestamp()), TIMEZONE), + iteratorOf( + DateTime.parse(TIMEZONE, "20210201T120000"), + DateTime.parse(TIMEZONE, "20210202T120000"), + DateTime.parse(TIMEZONE, "20210203T120000") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse(TIMEZONE, "20210201T120000"), + DateTime.parse(TIMEZONE, "20210202T120000"), + DateTime.parse(TIMEZONE, "20210203T120000") + ) + ); + } + + + @Test + public void testFloating() throws InvalidRecurrenceRuleException + { + + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse("20210201T120000"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), + iteratorOf( + DateTime.parse("20210201T120000"), + DateTime.parse("20210202T120000"), + DateTime.parse("20210203T120000") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse("20210201T120000"), + DateTime.parse("20210202T120000"), + DateTime.parse("20210203T120000") + ) + ); + } + + + @Test + public void testAllDay() throws InvalidRecurrenceRuleException + { + + RecurrenceSet recurrenceSet = new RecurrenceSet(); + recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); + DateTime start = DateTime.parse("20210201"); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), + iteratorOf( + DateTime.parse("20210201"), + DateTime.parse("20210202"), + DateTime.parse("20210203") + ) + ); + + assertThat( + () -> new TaskInstanceIterator(start, recurrenceSet), + iteratorOf( + DateTime.parse("20210201"), + DateTime.parse("20210202"), + DateTime.parse("20210203") + ) + ); + } +} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java new file mode 100644 index 0000000..843b94a --- /dev/null +++ b/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.function.BiFunction; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.jems.single.elementary.ValueSingle; +import org.junit.Test; + +import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; +import static org.dmfs.jems.mockito.doubles.TestDoubles.dummy; +import static org.dmfs.jems.mockito.doubles.TestDoubles.failingMock; +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.doReturn; + + +/** + * @author Marten Gajda + */ +public class ZippedTest +{ + @Test + public void testPresent() + { + Object dummyPresentValue = new Object(); + Object dummySingleValue = new Object(); + Object dummyResult = new Object(); + BiFunction mockFunction = failingMock(BiFunction.class); + doReturn(dummyResult).when(mockFunction).value(dummyPresentValue, dummySingleValue); + assertThat(new Zipped<>(new Present<>(dummyPresentValue), new ValueSingle<>(dummySingleValue), mockFunction), hasValue(sameInstance(dummyResult))); + } + + + @Test + public void testAbsent() + { + Object dummyObject = new Object(); + // AGENDULA CHANGE: the diamond was `new Zipped<>(...)`. `absent()` pins nothing, so with no + // target type javac infers Zipped for the argument and Matcher> for the + // matcher, and the two don't meet. Java 8 let it through; we compile at 17. Naming the type + // is the whole fix — the assertion is upstream's. + assertThat(new Zipped(absent(), new ValueSingle<>(dummyObject), dummy(BiFunction.class)), hasValue(sameInstance(dummyObject))); + } +} \ No newline at end of file diff --git a/provider/src/test/resources/robolectric.properties b/provider/src/test/resources/robolectric.properties new file mode 100644 index 0000000..d5e7bba --- /dev/null +++ b/provider/src/test/resources/robolectric.properties @@ -0,0 +1,26 @@ +# Robolectric normally takes its SDK level from the module's targetSdk, but a +# library module has none to read — only :app sets one — so without this every +# @RunWith(RobolectricTestRunner.class) class fails at DefaultSdkPicker before a +# single assertion runs. +# +# 34 rather than :app's targetSdk 36: these tests exercise the provider's pure +# data plumbing (ContentValues, MatrixCursor, instance-data processors), none of +# which changed across those levels, and every level listed here costs a separate +# android-all jar download on a cold test run. +sdk=34 + +# Robolectric installs the Conscrypt security provider during environment setup +# whether a test needs TLS or not, and conscrypt-openjdk-uber ships no +# linux-aarch_64 native, so on an ARM64 machine every Robolectric test dies in +# setUpApplicationState with UnsatisfiedLinkError before reaching an assertion. +# Nothing here opens a socket — these are ContentValues and cursor tests — so the +# provider is pure overhead. Turning it off also keeps the suite arch-portable +# rather than passing on x86_64 CI and failing on an ARM laptop. +conscryptMode=OFF + +# Note for anyone running the suite on ARM64: Robolectric has no aarch64 SQLite +# in *either* backend — the native runtime refuses outright and the LEGACY +# sqlite4java shadow throws "Architecture 'aarch64' is not supported". So the one +# test class that needs a database, ProviderAccountCleanupTest, skips itself +# there rather than failing; see the assumption at the top of that class. Every +# other test in this module is architecture-independent and runs everywhere. diff --git a/settings.gradle.kts b/settings.gradle.kts index f522deb..3f9d68d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,9 @@ dependencyResolutionManagement { rootProject.name = "Agendula" include(":app") +// Agendula's own task store — the dmfs task provider vendored under our +// authority. In-tree rather than a submodule or a Maven artifact: F-Droid +// requires from-source, and the upstream AAR hardcodes dmfs's permission names +// in its manifest where they cannot be renamed. See provider/PROVENANCE.md. +include(":provider") includeBuild("floret-kit") From c5041d3f29d8db297d252721913e985aebf031c6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 21:27:36 +0200 Subject: [PATCH 03/21] feat(export): write task lists out as iCalendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in the app's private storage, a Local-mode user's tasks exist in exactly one place and uninstalling deletes them — on Play, where most people will never have a sync engine, that is the majority case. So export is a v1 feature, not a nicety. One .ics per list, because a list is a CalDAV collection and that is the unit other clients understand; folding everything into one file would flatten the lists away, and list membership is not recoverable from a VTODO afterwards. ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip (ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri the user picked. Two things needed care: Export reads the tasks table, not the instances view the rest of the app reads from. In the instances view a recurring task appears once per occurrence with its times resolved and no rule attached, so exporting from there would write the same task fifty times and lose the RRULE that generated them. And local tasks have no UID. The dmfs provider only lets a sync adapter assign one, so in Local mode every task arrives with _uid null — and a VTODO without a UID is both invalid and un-mergeable, meaning a re-imported backup would duplicate every task rather than match it. ICalendarWriter synthesises one from the row id, stable across exports and tagged so it is recognisable as synthetic. Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a matching VTIMEZONE with its transition rules, and a TZID referencing an absent definition is what actually breaks importers. All-day values keep VALUE=DATE, the only form that survives a timezone change intact. The writer is pure Kotlin with no Android in it and is covered by 40 tests — line folding counted in octets and never splitting a UTF-8 sequence, TEXT escaping, forward references from a subtask to a parent later in the file, and CRLF endings. An export is only as good as its ability to be read back, and nothing about a malformed .ics is obvious until someone needs the backup. The SAF plumbing is marked in the storage doc as floret-kit material. Kept app-local for now on the kit's own stated principle of not extracting before a second consumer exists; the seam is in place, so moving it is a file move. Backend only — no UI yet; that comes with the frontend pass. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 1 + .../agendula/data/export/ExportWriter.kt | 109 ++++++ .../agendula/data/export/TaskExporter.kt | 76 +++++ .../data/tasks/AndroidTasksDataSource.kt | 21 ++ .../agendula/data/tasks/TaskMapper.kt | 37 +++ .../agendula/data/tasks/TasksDataSource.kt | 7 + .../agendula/domain/export/ExportModels.kt | 66 ++++ .../agendula/domain/export/ICalendarWriter.kt | 193 +++++++++++ .../agendula/data/export/TaskExporterTest.kt | 61 ++++ .../domain/export/ICalendarWriterTest.kt | 313 ++++++++++++++++++ gradle/libs.versions.toml | 5 + 11 files changed, 889 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd13d5c..8768112 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,6 +163,7 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt new file mode 100644 index 0000000..0376c58 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.agendula.data.export + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** Where an export ended up, for the UI to report. */ +data class ExportResult(val fileCount: Int, val taskListNames: List) + +/** The export could not be written. Carries a cause worth showing a user. */ +class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause) + +/** + * Writes [ExportDocument]s to a user-chosen location through the Storage Access + * Framework. + * + * No storage permission anywhere: SAF hands us a `Uri` the user picked + * themselves, which is both the modern approach and the only one that still works + * on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for + * [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the + * result here. + * + * Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is + * not task-domain and Calendula will want the same thing. Kept app-local for now + * on the kit's own stated principle of not extracting until a second consumer + * actually exists; the seam is here, so moving it later is a file move. + */ +@Singleton +class ExportWriter @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** + * Writes every document into [treeUri], a directory the user picked. + * + * Overwrites same-named files rather than letting SAF append " (1)" — an + * export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes + * the folder useless as a backup. + */ + suspend fun writeToTree(treeUri: Uri, documents: List): ExportResult = + withContext(io) { + val tree = DocumentFile.fromTreeUri(context, treeUri) + ?: throw ExportFailedException("Cannot open the chosen folder") + if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable") + + documents.forEach { document -> + tree.findFile(document.fileName)?.delete() + val file = tree.createFile(MIME_ICALENDAR, document.fileName) + ?: throw ExportFailedException("Cannot create ${document.fileName}") + write(file.uri, document.content) + } + ExportResult(documents.size, documents.map { it.fileName }) + } + + /** + * Writes every document into a single zip at [target]. + * + * The one-file form, for sharing or for a backup the user filed somewhere + * themselves — one attachment rather than one per list. + */ + suspend fun writeZip(target: Uri, documents: List): ExportResult = + withContext(io) { + runCatching { + context.contentResolver.openOutputStream(target, "wt")?.use { raw -> + ZipOutputStream(raw.buffered()).use { zip -> + documents.forEach { document -> + zip.putNextEntry(ZipEntry(document.fileName)) + zip.write(document.content) + zip.closeEntry() + } + } + } ?: throw ExportFailedException("Cannot write to the chosen file") + }.getOrElse { throw asExportFailure(it) } + ExportResult(documents.size, documents.map { it.fileName }) + } + + private fun write(target: Uri, bytes: ByteArray) { + runCatching { + // "wt" truncates. Without it a shorter export leaves the tail of the + // previous, longer one behind and produces a corrupt file. + context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) } + ?: throw ExportFailedException("Cannot write to the chosen file") + }.getOrElse { throw asExportFailure(it) } + } + + private fun asExportFailure(cause: Throwable): Throwable = when (cause) { + is ExportFailedException -> cause + // A SAF grant can be revoked between the picker and the write (the volume + // was unmounted, the provider's process died, the user cleared the grant). + is SecurityException -> ExportFailedException("Lost access to the chosen location", cause) + is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause) + else -> cause + } + + private companion object { + const val MIME_ICALENDAR = "text/calendar" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt new file mode 100644 index 0000000..dcf40a8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.data.export + +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import de.jeanlucmakiola.agendula.domain.export.ExportList +import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Turns the user's task lists into `.ics` documents. + * + * Export is a v1 feature rather than a nicety because of where the data now + * lives: our own provider is inside the app's private storage, so in Local mode a + * user's tasks exist in exactly one place and uninstalling deletes them. On Play, + * where most people will never have a sync engine, that is the majority case. + * + * **One document per list**, because a list is a CalDAV collection and that is the + * unit every other client understands. Bundling everything into a single file + * would flatten the lists away, and list membership is not recoverable from a + * VTODO afterwards. + */ +@Singleton +class TaskExporter @Inject constructor( + private val dataSource: TasksDataSource, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** + * Serialises [listIds] — every visible list when null. + * + * A list with no tasks still produces a document. An empty `.ics` is a real + * answer ("this list is empty"), whereas a missing file is indistinguishable + * from the export having gone wrong. + */ + suspend fun export(listIds: Set? = null): List = withContext(io) { + dataSource.taskLists() + .filter { listIds == null || it.id in listIds } + .map { list -> + val document = ExportList( + listId = list.id, + name = list.name, + accountName = list.accountName, + tasks = dataSource.exportTasks(list.id), + ) + ExportDocument( + fileName = fileNameFor(list.name, list.id), + content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8), + ) + } + } + + companion object { + + /** + * A file name derived from the list name, safe on every filesystem the + * user might pick through SAF (including FAT32 on an SD card). + * + * The list id is appended rather than trusted to be redundant: two lists on + * different accounts may share a name, and two exports landing on the same + * file would silently lose one of them. + */ + fun fileNameFor(listName: String, listId: Long): String { + val safe = listName + .map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' } + .joinToString("") + .trim('-') + .take(60) + .ifBlank { "list" } + return "$safe-$listId.ics" + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 7487a95..a7d46fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks 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 java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -84,6 +85,26 @@ class AndroidTasksDataSource @Inject constructor( } ?: emptyList() } + override fun exportTasks(listId: Long): List { + // projection = null for the same reason queryInstances uses it: the tasks + // table's shape varies across provider versions, and the by-name mapper + // reads what's there. + val uri = TasksContract.tasksUri(authority()) + return resolver.query( + uri, + null, + // _deleted marks a row awaiting a sync round-trip. It's gone as far as + // the user is concerned, so exporting it would resurrect deleted tasks + // in the backup. + "${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)", + arrayOf(listId.toString()), + null, + )?.use { c -> + val reader = CursorColumnReader(c) + buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } + } ?: emptyList() + } + // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index 11273e8..73cfa1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks 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.statusFromInt import kotlin.time.Instant @@ -52,6 +53,42 @@ object TaskMapper { ) } + /** + * Maps a row of the **`tasks` table** — a master task, not an occurrence. + * + * Export reads there rather than from `instances` on purpose: in the instances + * view a recurring task appears once per occurrence with its times already + * resolved and no rule attached, so exporting from it would write the same + * task many times over and drop the RRULE that produced them. Here each task + * appears exactly once, carrying the rule itself. + */ + fun exportTask(r: ColumnReader): ExportTask { + fun instant(name: String): Instant? = + r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } + + return ExportTask( + taskId = r.getLong(Tasks.ID) ?: 0L, + uid = r.getString(Tasks.UID), + title = r.getString(Tasks.TITLE).orEmpty(), + description = r.getString(Tasks.DESCRIPTION), + location = r.getString(Tasks.LOCATION), + url = r.getString(Tasks.URL), + priority = priorityFromICal(r.getInt(Tasks.PRIORITY)), + status = statusFromInt(r.getInt(Tasks.STATUS)), + percentComplete = r.getInt(Tasks.PERCENT_COMPLETE), + // The task's own columns, not the instance view's resolved ones. + start = instant(Tasks.DTSTART), + due = instant(Tasks.DUE), + isAllDay = r.getBoolean(Tasks.IS_ALLDAY), + completedAt = instant(Tasks.COMPLETED), + created = instant(Tasks.CREATED), + lastModified = instant(Tasks.LAST_MODIFIED), + rrule = r.getString(Tasks.RRULE), + rdate = r.getString(Tasks.RDATE), + parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 }, + ) + } + fun taskList(r: ColumnReader): TaskList = TaskList( id = r.getLong(Lists.ID) ?: 0L, name = r.getString(Lists.NAME).orEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index 6054f09..33a08ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -42,6 +42,13 @@ interface TasksDataSource { /** Every task's reminder lead, by task id. One query, for the scheduler. */ fun alarms(): Map + /** + * Every task in [listId] read from the **`tasks` table**, for export. Masters, + * not occurrences — see [TaskMapper.exportTask] for why that distinction + * matters. Excludes rows the provider has flagged deleted-but-unsynced. + */ + fun exportTasks(listId: Long): List + fun setCompleted(taskId: Long, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt new file mode 100644 index 0000000..153a34c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * One task as it goes out to iCalendar — a **master** task, not an occurrence. + * + * Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read + * from the `instances` view, where a recurring task appears once per occurrence + * with resolved times and no rule; exporting from it would write the same task + * fifty times and lose the RRULE that generated them. Export reads the `tasks` + * table instead, and needs two fields the UI never asks for ([uid], [rrule]). + */ +data class ExportTask( + /** `tasks._id` — the fallback identity when [uid] is absent. */ + val taskId: Long, + /** + * The iCalendar UID, or `null` for a task created on this device and never + * synced. The dmfs provider only lets a *sync adapter* assign one, so in Local + * mode this is null for everything — see [ICalendarWriter.uidFor], which + * synthesises a stable substitute rather than emitting a VTODO with no UID. + */ + val uid: String?, + val title: String, + val description: String?, + val location: String?, + val url: String?, + val priority: Priority, + val status: TaskStatus, + val percentComplete: Int?, + val start: Instant?, + val due: Instant?, + val isAllDay: Boolean, + val completedAt: Instant?, + val created: Instant?, + val lastModified: Instant?, + /** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */ + val rrule: String?, + /** Raw `RDATE` value as stored. Null when absent. */ + val rdate: String?, + /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ + val parentId: Long?, +) + +/** A task list and everything in it, ready to become one `.ics` document. */ +data class ExportList( + val listId: Long, + val name: String, + val accountName: String, + val tasks: List, +) + +/** A single file the export produced: [fileName] and its finished bytes. */ +data class ExportDocument( + val fileName: String, + val content: ByteArray, +) { + // ByteArray gets identity equals/hashCode, which makes this data class lie. + override fun equals(other: Any?): Boolean = + this === other || + (other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content)) + + override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt new file mode 100644 index 0000000..e310bdf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -0,0 +1,193 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.calendarDate +import de.jeanlucmakiola.agendula.domain.toICal +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.time.Instant + +/** + * Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components. + * + * Pure Kotlin and deliberately free of any Android type, so the format — the part + * that decides whether an exported backup can actually be read again — is + * unit-testable on the JVM. Serialising tasks is task-domain and stays here; the + * SAF/file plumbing that carries the bytes out is not, and lives in the data + * layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit + * candidate). + * + * **Times are always written in UTC.** Emitting a local `TZID` would oblige us to + * also emit a matching `VTIMEZONE` component with its full transition rules, and + * a `TZID` referencing an absent definition is what actually breaks importers. UTC + * is unambiguous and universally accepted, so the exported instant is exact even + * though the original wall-clock zone is not carried. All-day values keep their + * `VALUE=DATE` form and stay date-only, which is the only representation that + * survives a timezone change intact. + */ +object ICalendarWriter { + + private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN" + + /** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */ + private const val MAX_LINE_OCTETS = 75 + + private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd") + private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + + /** Serialises [list] to a complete `.ics` document. */ + fun write(list: ExportList): String = buildString { + line("BEGIN:VCALENDAR") + line("VERSION:2.0") + line("PRODID:$PRODUCT_ID") + line("CALSCALE:GREGORIAN") + // Non-standard but near-universally understood, and the only way the list's + // name survives into a calendar app. Importers that don't know it skip it. + property("X-WR-CALNAME", list.name) + + // Parents must be addressable by UID, and a subtask may appear before its + // parent in the list, so resolve every id up front. + val uidsById = list.tasks.associate { it.taskId to uidFor(it) } + list.tasks.forEach { task -> writeTask(task, uidsById) } + + line("END:VCALENDAR") + } + + /** + * The UID to write for [task]. + * + * Local tasks have none: the dmfs provider only permits a sync adapter to + * assign `_uid`, so in Local mode every task arrives here with `uid == null`. + * A VTODO without a UID is invalid and, worse, un-mergeable — re-importing a + * backup would duplicate every task instead of matching it. So we synthesise + * one from the row id, which is stable for as long as the row is, and tag it + * with our own domain so a synthesised UID is recognisable as such. + */ + fun uidFor(task: ExportTask): String = + task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de" + + private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map) { + line("BEGIN:VTODO") + property("UID", uidFor(task)) + // DTSTAMP is mandatory. It means "when this representation was written", + // which for an export is now — not the task's own timestamps. + property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis()))) + property("SUMMARY", task.title) + + task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) } + task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } + // URL is a URI, not TEXT: it must not be escaped like one. + task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } + + task.start?.let { dateProperty("DTSTART", it, task.isAllDay) } + task.due?.let { dateProperty("DUE", it, task.isAllDay) } + task.created?.let { rawProperty("CREATED", formatUtc(it)) } + task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) } + // COMPLETED is defined as UTC date-time even for an all-day task. + task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) } + + rawProperty("STATUS", task.status.toICalName()) + if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString()) + task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) } + + // Passed through as stored. The provider keeps these in iCalendar form + // already, and re-deriving them would risk changing what the user's + // recurrence actually means. + task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) } + task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) } + + // Only emit the link when the parent is in this same document; a + // RELATED-TO pointing outside the file would dangle on import. + task.parentId?.let { uidsById[it] }?.let { + property("RELATED-TO;RELTYPE=PARENT", it) + } + + line("END:VTODO") + } + + private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) { + if (allDay) { + // Read in UTC, matching the storage convention (see AllDayTime): an + // all-day value *is* UTC midnight of the intended calendar date. + rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE)) + } else { + rawProperty(name, formatUtc(instant)) + } + } + + private fun formatUtc(instant: Instant): String = + java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC) + .format(DATE_TIME_UTC) + + /** A property whose value is TEXT, and so must be escaped. */ + private fun StringBuilder.property(name: String, value: String) = + line("$name:${escapeText(value)}") + + /** A property whose value is already in its final form (dates, numbers, URIs, rules). */ + private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value") + + private fun StringBuilder.line(content: String) { + append(fold(content)) + append(CRLF) + } + + /** + * Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would + * double the backslashes introduced by the later replacements. + */ + internal fun escapeText(value: String): String = value + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n") + + /** + * Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with + * CRLF + a single space. + * + * Counted in **octets, not characters** — the limit is defined that way, and an + * emoji in a task title is four of them. Splits are kept on character + * boundaries so folding can never cut a UTF-8 sequence in half and corrupt the + * text; an importer unfolds by removing CRLF + leading whitespace, recovering + * the original exactly. + */ + internal fun fold(content: String): String { + if (content.utf8Size() <= MAX_LINE_OCTETS) return content + + val out = StringBuilder() + var octets = 0 + // First line takes the full budget; every continuation loses one octet to + // the leading space. + var budget = MAX_LINE_OCTETS + var index = 0 + while (index < content.length) { + val codePoint = content.codePointAt(index) + val charCount = Character.charCount(codePoint) + val size = String(Character.toChars(codePoint)).utf8Size() + if (octets + size > budget) { + out.append(CRLF).append(' ') + octets = 0 + budget = MAX_LINE_OCTETS - 1 + } + out.append(content, index, index + charCount) + octets += size + index += charCount + } + return out.toString() + } + + private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size + + private fun TaskStatus.toICalName(): String = when (this) { + TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION" + TaskStatus.IN_PROCESS -> "IN-PROCESS" + TaskStatus.COMPLETED -> "COMPLETED" + TaskStatus.CANCELLED -> "CANCELLED" + } + + private const val CRLF = "\r\n" +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt new file mode 100644 index 0000000..b477620 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.data.export + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The export file name. The user picks the folder, so whatever comes out of here + * is what they will be looking at in a file manager a year from now. + */ +class TaskExporterTest { + + private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id) + + @Test + fun `keeps a plain name readable`() { + assertThat(name("Groceries")).isEqualTo("Groceries-3.ics") + } + + @Test + fun `replaces characters a filesystem would reject`() { + // SAF can land on FAT32 (an SD card), where these are simply illegal. + val result = name("Work / Home: notes?") + assertThat(result).doesNotContain("/") + assertThat(result).doesNotContain(":") + assertThat(result).doesNotContain("?") + assertThat(result).endsWith("-3.ics") + } + + @Test + fun `keeps the id so same-named lists cannot collide`() { + // Two accounts may each have a list called "Personal"; without the id one + // export would silently overwrite the other. + assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2)) + } + + @Test + fun `falls back when the name has nothing usable in it`() { + assertThat(name("///")).isEqualTo("list-3.ics") + assertThat(name("")).isEqualTo("list-3.ics") + } + + @Test + fun `does not leave dangling separators`() { + assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics") + } + + @Test + fun `caps the length`() { + // Many filesystems stop at 255 bytes for a name; a pathological list title + // should not be the thing that fails an export. + assertThat(name("x".repeat(500)).length).isAtMost(80) + } + + @Test + fun `keeps non-latin names instead of blanking them`() { + // isLetterOrDigit is Unicode-aware, so these survive rather than collapsing + // to the "list" fallback. + assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics") + assertThat(name("買い物")).isEqualTo("買い物-3.ics") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt new file mode 100644 index 0000000..86c6b8c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -0,0 +1,313 @@ +package de.jeanlucmakiola.agendula.domain.export + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import kotlin.time.Instant + +/** + * The export format. Worth testing closely: an export is only as good as its + * ability to be read back, and nothing about a malformed `.ics` is obvious until + * someone actually needs the backup. + */ +class ICalendarWriterTest { + + private fun task( + taskId: Long = 1L, + uid: String? = null, + title: String = "Buy oat milk", + description: String? = null, + location: String? = null, + url: String? = null, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, + completedAt: Instant? = null, + created: Instant? = null, + lastModified: Instant? = null, + rrule: String? = null, + rdate: String? = null, + parentId: Long? = null, + ) = ExportTask( + taskId, uid, title, description, location, url, priority, status, percentComplete, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + ) + + private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = + ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList())) + + /** Unfolds the way an importer does, so assertions can read logical lines. */ + private fun String.unfolded(): String = replace("\r\n ", "") + + private fun linesOf(ics: String): List = ics.unfolded().split("\r\n").filter { it.isNotEmpty() } + + @Nested + inner class Structure { + + @Test + fun `wraps the todos in a calendar`() { + val lines = linesOf(write(task())) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") + } + + @Test + fun `uses CRLF line endings`() { + // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected + // by a strict importer while looking perfectly fine in an editor. + val ics = write(task()) + assertThat(ics).contains("\r\n") + assertThat(ics.replace("\r\n", "")).doesNotContain("\n") + } + + @Test + fun `carries the list name`() { + assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping") + } + + @Test + fun `every todo has a UID and a DTSTAMP`() { + // Both are mandatory; a VTODO missing either is invalid. + val lines = linesOf(write(task(), task(taskId = 2))) + assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2) + assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2) + } + } + + @Nested + inner class Identity { + + @Test + fun `prefers the synced UID`() { + assertThat(linesOf(write(task(uid = "abc-123@example.org")))) + .contains("UID:abc-123@example.org") + } + + @Test + fun `synthesises a stable UID for a local task`() { + // Local tasks never get a UID from the provider (only a sync adapter may + // assign one), and re-importing UID-less todos would duplicate rather + // than match them. + val first = ICalendarWriter.uidFor(task(taskId = 42)) + val second = ICalendarWriter.uidFor(task(taskId = 42)) + assertThat(first).isEqualTo(second) + assertThat(first).contains("42") + } + + @Test + fun `distinct tasks get distinct UIDs`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 1))) + .isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2))) + } + + @Test + fun `blank stored UID falls back to the synthesised one`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7") + } + } + + @Nested + inner class Dates { + + private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z + + @Test + fun `timed values are written in UTC`() { + assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z") + } + + @Test + fun `all-day values are date-only`() { + // A DATE-TIME here would drift by a day for anyone east or west of UTC — + // the exact bug the AllDayTime convention exists to prevent. + val due = allDayInstantOf(LocalDate.of(2026, 8, 2)) + val lines = linesOf(write(task(due = due, isAllDay = true))) + assertThat(lines).contains("DUE;VALUE=DATE:20260802") + } + + @Test + fun `completion is always a UTC date-time even when all-day`() { + val lines = linesOf( + write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)), + ) + assertThat(lines).contains("COMPLETED:20250802T120000Z") + } + + @Test + fun `absent dates emit no property at all`() { + val lines = linesOf(write(task())) + assertThat(lines.none { it.startsWith("DUE") }).isTrue() + assertThat(lines.none { it.startsWith("DTSTART") }).isTrue() + } + } + + @Nested + inner class Fields { + + @Test + fun `maps every status to its iCalendar name`() { + fun statusLine(status: TaskStatus) = + linesOf(write(task(status = status))).first { it.startsWith("STATUS:") } + + assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION") + assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS") + assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED") + assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED") + } + + @Test + fun `omits priority when there is none`() { + // PRIORITY:0 means "undefined" but reads as a real value to some + // importers; leaving it out is unambiguous. + assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue() + assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1") + } + + @Test + fun `clamps percent complete into range`() { + assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100") + assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0") + } + + @Test + fun `passes recurrence through unchanged`() { + val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE"))) + assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE") + } + + @Test + fun `does not escape a URL`() { + // URL is a URI, not TEXT. Escaping its commas would corrupt the address. + val lines = linesOf(write(task(url = "https://example.org/a,b;c"))) + assertThat(lines).contains("URL:https://example.org/a,b;c") + } + + @Test + fun `skips blank optional fields`() { + val lines = linesOf(write(task(description = " ", location = "", url = ""))) + assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue() + assertThat(lines.none { it.startsWith("LOCATION") }).isTrue() + assertThat(lines.none { it.startsWith("URL") }).isTrue() + } + } + + @Nested + inner class Subtasks { + + @Test + fun `links a child to its parent by UID`() { + val parent = task(taskId = 1, uid = "parent@example.org") + val child = task(taskId = 2, parentId = 1) + assertThat(linesOf(write(parent, child))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `resolves a parent that appears after the child`() { + // Nothing guarantees provider order, and a forward reference must still + // resolve or half the hierarchy silently disappears. + val child = task(taskId = 2, parentId = 1) + val parent = task(taskId = 1, uid = "parent@example.org") + assertThat(linesOf(write(child, parent))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `drops a link to a parent outside this list`() { + // A RELATED-TO pointing at a UID not in the file would dangle on import. + val orphan = task(taskId = 2, parentId = 999) + assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue() + } + } + + @Nested + inner class Escaping { + + @Test + fun `escapes the special characters`() { + assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c") + assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak") + assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere") + } + + @Test + fun `escapes backslashes first`() { + // Doing it later would re-escape the backslashes the other rules add, + // turning "a;b" into "a\\;b". + assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash") + assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b") + } + + @Test + fun `a multiline description stays one logical line`() { + val ics = write(task(description = "first\nsecond")) + assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond") + } + } + + @Nested + inner class Folding { + + @Test + fun `short lines are untouched`() { + assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short") + } + + @Test + fun `long lines are folded to 75 octets`() { + val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200)) + folded.split("\r\n").forEachIndexed { index, segment -> + val octets = segment.toByteArray(Charsets.UTF_8).size + assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space + } + } + + @Test + fun `folding round-trips`() { + val original = "DESCRIPTION:" + "long text ".repeat(40) + assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original) + } + + @Test + fun `never splits a multi-byte character`() { + // The limit is in octets but an emoji is four of them; splitting mid + // sequence would emit invalid UTF-8 and mangle the title. + val emoji = "SUMMARY:" + "🌼".repeat(40) + val folded = ICalendarWriter.fold(emoji) + assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji) + folded.split("\r\n").forEach { segment -> + // A broken surrogate pair round-trips through UTF-8 as U+FFFD. + assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)) + .isEqualTo(segment) + } + } + + @Test + fun `a long title survives the full write`() { + val title = "Remember to ".repeat(20) + assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title") + } + } + + @Nested + inner class EmptyList { + + @Test + fun `still produces a valid calendar`() { + // An empty list is a real answer; a missing file is indistinguishable + // from a failed export. + val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList()))) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b72b303..c9f5f9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ composeBom = "2026.05.01" # Re-evaluate when 1.5.0 stable lands. material3 = "1.5.0-alpha21" datastore = "1.2.1" +# SAF directory writing for export/backup (DocumentFile). +documentfile = "1.1.0" junit = "6.1.0" junitPlatform = "6.1.0" truth = "1.4.5" @@ -72,6 +74,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", v # DataStore androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +# SAF — writing the export into a user-chosen folder +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } + # Unit tests junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } From 98ed339346a3f158aeb54ce7a6648cd2a2f2df1a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 21:32:17 +0200 Subject: [PATCH 04/21] docs: bring the docs in line with what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STORAGE-AND-SYNC.md asked for a follow-up pass on ARCHITECTURE.md §7 and the ProviderResolver KDoc, which still defined Posture B as "bundle OpenTasks and find org.dmfs.tasks first" — the plan that was withdrawn as a dead end. That pass, plus the status the doc left open. ARCHITECTURE.md now describes the app as built: two modules, the storage-mode table with the permission each needs, the autoMode rule and why it keys on holding an external provider's permission, the two-not-three mode vocabulary, and a manifest section that says what :provider contributes and what is deliberately absent (GET_ACCOUNTS, INTERNET). §7 records squatting the dmfs authority as a dead end rather than a road not yet taken, so it doesn't get re-proposed. ROADMAP.md turns "Posture B, later" into what actually landed and lists what didn't: the frontend surfaces, the DAVx5 issue, the sync adapter, and device verification. Two open decisions resolved and struck through — the authority choice, and recurrence-aware editing, which fix/provider-interaction-review made stale. STORAGE-AND-SYNC.md gets per-step status. Open question 3 ("does it work with no account?") is answered, with the caveat that the test proving it is Robolectric and skips on ARM64 — answered by construction, not yet on a device. PLAN.md gets a banner. It's the original design document and still holds the reasoning behind the layering, but two of its premises are overturned and it should not be read as current. README.md was telling users they need a tasks provider installed. They don't, and that's the headline feature: a table of where tasks can live, that our provider coexists with OpenTasks rather than replacing it, and that everything exports as standard .ics. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 50 ++++++++---- docs/ARCHITECTURE.md | 168 ++++++++++++++++++++++++++++----------- docs/PLAN.md | 19 +++++ docs/ROADMAP.md | 73 ++++++++++++----- docs/STORAGE-AND-SYNC.md | 111 ++++++++++++++++++-------- 5 files changed, 301 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index a67a934..bd88bbd 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

Agendula

A modern Material 3 Expressive task app for Android.
-Reads, writes, and reminds — on top of an existing tasks provider, with no own -sync stack.

+Keeps your tasks on your device, or on top of a tasks provider you already use. +Open standards, no account required.

CI Android 10+ @@ -15,31 +15,47 @@ sync stack.

Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). -Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is -a pure front-end over the **OpenTasks `TaskContract` provider** — the store that -DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own -database, no reinvented sync. +Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula +speaks the **dmfs `TaskContract`** — the same shape DAVx5 (and SmoothSync, +DecSync, …) syncs your CalDAV `VTODO` tasks into. The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days; Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of many small *florets* — so the two apps are florets of one bloom.) -> **Status: data layer done, UI in progress.** The full non-visual stack over -> the `TaskContract` provider — provider resolution, live-updating reads, -> writes, smart-list filtering, and a self-scheduled reminder engine — is built -> and unit-tested. The Material 3 Expressive screens are now being built on top, -> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status, +## Where your tasks live — your choice + +| | Where | Sync | Needs | +|---|---|---|---| +| **On your device** *(default)* | Agendula's own task store, bundled in the app | none yet — sync of our own is planned | nothing. No account, no permissions, no other app | +| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission | + +Agendula carries its own copy of the Apache-2.0 dmfs task provider, under its own +name — so it **coexists with OpenTasks rather than replacing it**, and installing +one never breaks the other. It is a fork of a proven schema, not a database +written from scratch, which is why every CalDAV engine already understands it. + +Your tasks are exportable as standard iCalendar `.ics` files at any time, because +data you can't take with you isn't really yours. + +> **Status: backend complete, UI catching up.** Storage, provider, reads and +> writes, smart-list filtering, a self-scheduled reminder engine, and export are +> built and unit-tested. The Material 3 Expressive screens are being built on +> top, one at a time — the storage-mode picker and export screen are not there +> yet. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status, > [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and -> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale. +> [`docs/STORAGE-AND-SYNC.md`](docs/STORAGE-AND-SYNC.md) for why storage works +> the way it does. ## Sync sources (by design) -Agendula works with anything that writes to the tasks provider — **DAVx5** -(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync -adapter — because it builds on the provider, not on any one sync app. Google -Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean -owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane. +In provider mode Agendula works with anything that writes to that provider — +**DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any +Android sync adapter — because it builds on the provider, not on any one sync +app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they +would mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — +are the lane. ## Translations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7c562f..4fadb62 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,12 +8,21 @@ This document describes how Agendula is built **as it stands today**. For the ## 1. The thesis in one sentence -Agendula is a Material 3 Expressive **front-end** over the OpenTasks -`TaskContract` provider — it reads, writes, and reminds on top of a tasks store -that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over -CalDAV. **Agendula owns no database and no sync stack.** It is the task-list -sibling to [Calendula](https://codeberg.org/jlmakiola/calendula), -which does the same thing for `CalendarContract`. +Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it +reads, writes, and reminds against a task store the user chooses: **its own +bundled provider** (the default) or an external provider app already on the +device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the +like. It is the task-list sibling to +[Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing +for `CalendarContract`. + +**Agendula owns storage but not, yet, sync.** That is a deliberate change from +the original "owns no database" thesis, settled in +[`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being +installed made someone else's roadmap a gate on the app working at all. The +database is the vendored dmfs provider under *our* authority — our namespace, not +a schema written from scratch — so every CalDAV engine still understands it. A +sync adapter of our own is the 1.x arc. The whole design hangs off one rule: @@ -22,9 +31,8 @@ The whole design hangs off one rule: > **Provider column names and the authority string never leak above the data > layer.** -This is what lets "Posture A" (front-end over an installed provider) become -"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching -the UI, the ViewModels, or the domain. See §7. +That rule is what let Posture B land as an addition rather than a rewrite: the +UI, the ViewModels and the domain were untouched by it. See §7. --- @@ -49,9 +57,14 @@ the UI, the ViewModels, or the domain. See §7. │ ProviderResolver / ContentObserver│ │ reminders/ prefs/ di/ demo/ │ └───────────────┬──────────────────────────────┘ - │ content:// + dangerous perms + │ content:// ┌───────────────▼──────────────────────────────┐ - External │ OpenTasks provider ←sync← DAVx5 / DecSync… │ + Storage │ Local mode (default): │ + │ :provider — our own bundled task provider │ + │ same uid, no permission grant needed │ + │ External mode: │ + │ OpenTasks / tasks.org ←sync← DAVx5 / … │ + │ dangerous perms, requested at point of use │ └──────────────────────────────────────────────┘ ``` @@ -69,15 +82,21 @@ Both are bound in Hilt in `data/di/DataModule.kt`. ## 3. Module & package layout -Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`. +Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`. -| Package | Contents | +| Module | Contents | |---|---| -| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. | -| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | +| `:provider` | Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) for the upstream commit and every deviation. No app code imports from it except `ProviderResolver`, which reads the authority out of its resources. | + +| Package (`:app`) | Contents | +|---|---| +| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `AllDayTime` (the two date conventions), `DayWindow` (local-midnight maths). No Android imports. | +| `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. | +| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` + `ProviderEnvironment` + `StorageMode` + `StorageModeHolder` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | +| `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). | | `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | | `data/prefs/` | `SettingsPrefs` (DataStore). | -| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | +| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | | `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. | @@ -88,20 +107,45 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`. ### 4.1 Provider targeting — `ProviderResolver` -`ProviderResolver.resolve()` walks a preference-ordered candidate list and -returns the first provider actually installed (via -`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate -is a `TaskProvider(authority, readPermission, writePermission, packageName)`. +`ProviderResolver.resolve()` returns the active `TaskProvider(authority, +readPermission, writePermission, packageName, isOwn)` for the selected +`StorageMode`. -| Provider | Authority | Permissions | -|---|---|---| -| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | -| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | +| Mode | Provider | Authority | Permissions | +|---|---|---|---| +| **Local** (default) | ours, bundled | `de.jeanlucmakiola.agendula.tasks` | **none** — same uid | +| External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | +| External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | -Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract` -columns apply** regardless of which is present. `null` from `resolve()` drives -the "install a tasks provider" onboarding gate. `hasPermission()` checks both -runtime perms for the active provider. +All three are backed by the same dmfs `TaskProvider` — ours *is* that provider, +vendored — so the **same `TaskContract` columns apply** throughout. + +`hasPermission()` short-circuits to `true` for our own provider: a same-uid +caller bypasses a provider's permission checks outright, so +`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode +it checks both runtime perms, and `null` from `resolve()` drives the "install a +tasks provider" gate. + +**Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and +mirrored into the resolver by `StorageModeHolder` — the resolver is consulted +synchronously on every query and cannot read DataStore itself. When there is no +explicit choice (the normal case), `autoMode()` decides: + +> **External if we already hold an external provider's runtime permission, +> otherwise Local.** + +That permission is dangerous-level, so it can only be there because an earlier +version asked and the user agreed — the signature of an existing Posture A user, +who must not be dropped onto an empty store and left to conclude their tasks were +deleted. A fresh install holds nothing and gets local-first storage. + +The platform calls sit behind `ProviderEnvironment` so this decision is unit +tested on the JVM (`ProviderResolverTest`) rather than only on a device. + +**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md` +describes three, but *Synced* is not a third store — it is Local with an account +attached, so it is derived state, and modelling it as a separate mode would imply +that turning sync on is a migration. It isn't. ### 4.2 `TasksContract` @@ -192,17 +236,32 @@ This is the single largest piece of genuinely-new code in Agendula. ## 7. The A / B seam (why the layering is shaped this way) -- **Posture A (today):** front-end over whatever provider is installed. Ships - fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding - moment). -- **Posture B (later):** add a `:provider` module bundling the Apache-2.0 - `opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks` - first; external CalDAV engines sync directly into it. **The UI, ViewModels, - domain, and `TasksRepository` do not change** — only the resolver's default and - some manifest perms. +Both terms were **redefined** by [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md). +They no longer mean what earlier drafts of this document said. -Bundling the provider bundles **storage, not sync** — Agendula stays a pure -front-end over open backends either way. +- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). + Still fully supported; it stopped being the only option and became a user + choice, `StorageMode.EXTERNAL`. +- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider + vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our + own permission namespace. It **coexists with everything and replaces nothing**. + +> **Dead end, do not revisit:** bundling the provider under dmfs's *own* +> authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the +> same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same +> `` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with +> OpenTasks installed simply could not have installed Agendula. Account +> visibility is also keyed by *package*, not authority, which would have left the +> bundled provider seeing zero accounts and pruning synced lists as orphaned. +> Full reasoning in `STORAGE-AND-SYNC.md`. + +The seam earned its keep: `ProviderResolver` is still the only thing that knows +an authority exists and `AndroidTasksDataSource` the only thing that touches a +resolver, so vendoring an entire content provider **changed no UI, no ViewModel, +no domain type, and not one line of `TasksRepository`.** + +Bundling the provider bundles **storage, not sync**. Our own sync adapter is a +separate, later piece of work — see `STORAGE-AND-SYNC.md`. --- @@ -241,8 +300,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; | Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 | | SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | -| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | -| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | +| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines | +| `:provider` | Java 17, `org.dmfs` jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; `settings.gradle.kts` stays `google()` + `mavenCentral()` under `FAIL_ON_PROJECT_REPOS`) | +| Tests | `:app` is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and `ProviderEnvironment` as the JVM-testable seams. **`:provider` is JUnit4 + Robolectric** — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add `useJUnitPlatform()` there. | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | | CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs | @@ -251,13 +311,25 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; ## 11. Manifest surface -- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms - declared statically (the active set is requested at runtime); - `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm +- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks + perms (static manifest, so they are always declared; requested at runtime only + in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). -- **``** for package visibility: both provider authorities + a LAUNCHER - intent (so `resolveContentProvider` works and onboarding can open the - provider / a store listing). +- **Declared by `:provider`:** the `` itself plus our own + `de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their + permission group. These exist for **other** apps — Agendula reaches its own + provider same-uid and neither declares a `uses-permission` for them nor asks. + The provider is `exported="true"` on purpose: that is what would let DAVx5 + write into it once it knows our authority. +- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider — + see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until + sync actually ships. Export needs no storage permission at all; SAF hands us a + `Uri` the user picked. +- **``** for package visibility: both *external* provider authorities + + a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open + the provider / a store listing). Our own provider needs no entry. - **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - `ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver — - that's a Calendula thing that doesn't apply here. + `ProviderChangeReceiver` (all three authorities, ours first — an intent-filter + host must be a literal), and the vendored provider's own + `TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a + Calendula thing that doesn't apply here. diff --git a/docs/PLAN.md b/docs/PLAN.md index 4852655..90c7a2c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,5 +1,24 @@ # Agendula — implementation plan +> ⚠️ **Historical document.** This is the original design plan, kept for the +> reasoning behind decisions that are still in force — the layering, the data +> model, the reminder engine, what transfers from Calendula. It is **not** a +> description of the app as it stands. +> +> Two things here have since been overturned, both by +> [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md), which supersedes this document +> wherever they disagree: +> +> 1. **"No own storage."** Agendula now ships its own bundled task provider, and +> depending on an external provider app is a user choice rather than a +> requirement. +> 2. **"Posture B = bundle OpenTasks under `org.dmfs.tasks`."** That is a dead +> end, not a later step — two apps cannot declare the same authority or +> permission name. Posture B shipped under *our own* authority instead. +> +> For the current picture see [`ARCHITECTURE.md`](ARCHITECTURE.md); for status, +> [`ROADMAP.md`](ROADMAP.md). + > A modern Material 3 Expressive **task** app for Android. Reads, writes, and > reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / > DecSync over CalDAV), with no own sync stack. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c7e47d..81839d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,16 +10,18 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started ## Current state (one line) -The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` -provider is **done and unit-tested**, and the Material 3 Expressive UI is built -through **M5**: lists → task list (swipe gestures, inline add, smart-list section -headers) → detail / edit with full CRUD, date-time pickers, priority, -percent-complete, conflict-safe saves, per-task reminders, and subtask -create + reparent — plus a one-time reminder onboarding step and a Settings -screen (theme, dynamic colour, due-reminder master toggle + default offset + -exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining -work is M6 (Glance widget, translations, F-Droid release; the Settings screen -landed early with M5 and still needs a language entry). +Agendula now **carries its own task store**: the `:provider` module ships the +vendored dmfs provider under our authority, so the app is complete and local-first +with nothing else installed, and an external provider (OpenTasks / tasks.org) is a +user choice rather than a requirement. The non-visual stack over `TaskContract` is +done and unit-tested, export to iCalendar has landed, and the Material 3 +Expressive UI is built through **M5**: lists → task list (swipe gestures, inline +add, smart-list section headers) → detail / edit with full CRUD, date-time +pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and +subtask create + reparent — plus a one-time reminder onboarding step and a +Settings screen. Remaining work is the **frontend surfaces for what just landed** +(a storage-mode picker, an export screen), then M6 (Glance widget, translations, +F-Droid release) and the sync adapter. --- @@ -128,11 +130,33 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync, - ⬜ Translations — only `res/values/` (English); no `values-XX`. - ⬜ Finalize F-Droid metadata, confirm CI release flow. -### ⬜ Posture B (separate track, later) -Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; -`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter -permissions; ship self-contained. UI / repository / domain untouched — see -[`ARCHITECTURE.md`](ARCHITECTURE.md) §7. +### ✅ Posture B — our own task store +Agendula stopped depending on a provider app being installed. Direction and +reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined** +what Posture B means (our own authority, coexisting with everything — *not* +squatting `org.dmfs.tasks`, which is a dead end). +- ✅ `fix/provider-interaction-review` merged (step 1). +- ✅ `:provider` — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree + under `de.jeanlucmakiola.agendula.tasks` and our own permission namespace. + `GET_ACCOUNTS` dropped, with the account-cleanup path reworked so it can only + prune account types we authenticate ourselves — the deletion is unsafe without + that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17. + [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation, + each also marked `AGENDULA CHANGE` at the site. Upstream's 51 JVM tests pass. +- ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION` + can no longer fire in Local mode, and an upgrading Posture A user stays on the + provider that holds their data (`ProviderResolver.autoMode`). +- ✅ Export to iCalendar (step 3) — a v1 feature now that Local-mode data lives + only in our app's private storage. One `.ics` per list, to a folder or a zip, + via SAF. Backend only. +- ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and + an export screen. The backend is done and unused until these exist. +- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. +- ⬜ Sync adapter (step 5) — the 1.x arc. Design discussion still open: protocol + coverage, account model, conflict resolution, and `ical4android`'s licence + against our MIT. +- ⬜ Verify on a device: the local path with no account, and the vendored + provider's timezone-change behaviour (change 3 in `PROVENANCE.md`). --- @@ -145,11 +169,20 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through `org.tasks.opentasks` + `org.tasks.permission.*`. 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? (Not in the candidate list today.) -4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a - *replacement* for OpenTasks (one authority owner per device). Intended, but a - conscious choice. -5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); - recurrence-aware editing is out of scope for v1. +4. ~~**Posture B authority choice**~~ resolved: **our own** + `de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, + not merely a trade-off — two apps cannot declare the same authority or + permission name, so anyone with OpenTasks installed could not have installed + Agendula at all. +5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ stale: + `fix/provider-interaction-review` routes edits to a recurring task through the + instances URI, so the provider forks an override instead of re-anchoring the + series. +6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default + today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it + assumes is not built yet. +7. **Sync protocol coverage**, account model, conflict resolution — the next + design discussion. --- diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index d6831a9..aba0a6f 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -4,9 +4,13 @@ > bundle OpenTasks" working notes, which are withdrawn (see > [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to > `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines -> what Posture B means** — those two need a follow-up edit. -> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass; -> this document does not attempt it. +> what Posture B means.** +> +> **Status, 2026-08-02: steps 1–3 are done** — see +> [Sequencing](#sequencing). `ARCHITECTURE.md` and `ROADMAP.md` have had their +> follow-up pass and now match what shipped; `PLAN.md` is the original design +> document and is left as the historical record. What is built, and what is still +> only described here, is marked step by step below. ## The plan, in short @@ -24,13 +28,13 @@ **In what order** -| # | Step | Why now | -|---|---|---| -| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | -| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | -| 3 | Export / backup | our data now lives only in our app's private storage | -| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | -| 5 | Sync adapter | the 1.x arc; design discussion pending | +| # | Step | Why now | Status | +|---|---|---|---| +| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done | +| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | +| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | +| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | +| 5 | Sync adapter | the 1.x arc; design discussion pending | ⬜ | Everything below is the reasoning behind those choices, the alternatives that were rejected, and the constraints they have to survive. @@ -64,8 +68,8 @@ Plus a standing rule: **anything that isn't task-domain goes to floret-kit.** ### The vocabulary, redefined -`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as -"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with: +`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc used to describe Posture B as +"bundle OpenTasks and find `org.dmfs.tasks` first." Both now read as below: - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). Still fully supported; it stops being the default and becomes a **user @@ -173,19 +177,36 @@ feature — see [Storage modes](#storage-modes--the-users-choice). Local and Synced are the same store — Synced is Local with an account attached, so switching on sync is not a migration. -**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a -fixed priority list and the first hit wins. Once we bundle our own provider, -"first hit" is the wrong rule: someone who used Agendula locally and *later* -installs DAVx5 + OpenTasks would see an external candidate outrank the provider -that actually holds their data. Options: rank ours first whenever it's -non-empty, or make the mode an explicit Settings choice (it's user-visible -either way, so probably both — auto-pick a sane default, let Settings override). +**Resolver ordering — decided, and it went both ways as expected.** +`ProviderResolver` now takes an explicit `StorageMode` from Settings when there +is one, and otherwise calls `autoMode()`. The auto rule turned out to be sharper +than "rank ours first whenever it's non-empty", and needs no database probe: -**Export/backup is a v1 feature.** Not, as previously framed, a migration safety -net for "uninstall OpenTasks" — that scenario no longer exists. It's data -portability for Local-mode users, whose tasks otherwise exist in exactly one -place with no second copy. On Play, where most users won't have a sync engine, -that's the majority. +> **External if we already hold an external provider's runtime permission, +> otherwise Local.** + +That permission is dangerous-level, so it can only be there because an earlier +version asked and the user agreed — which is exactly what "existing Posture A +user" means. A fresh install holds nothing and gets local-first. ⬜ The Settings +override the rule assumes is not built yet. + +**Note on the mode vocabulary.** The code has two modes, not three: +`StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two +paragraphs up, Synced *is* Local with an account attached — so it is derived +state, and giving it its own constant would imply switching sync on is a +migration when the whole point is that it isn't. + +**Export/backup is a v1 feature.** ✅ Backend built (no UI yet). Not, as +previously framed, a migration safety net for "uninstall OpenTasks" — that +scenario no longer exists. It's data portability for Local-mode users, whose +tasks otherwise exist in exactly one place with no second copy. On Play, where +most users won't have a sync engine, that's the majority. + +One `.ics` per list — a list is a CalDAV collection, and that's the unit other +clients understand — written through SAF to either a folder or a single zip. +Local tasks have no `_uid` (only a sync adapter may assign one), so the writer +synthesises a stable UID per task; without it a re-imported backup would +duplicate every task instead of matching it. --- @@ -241,10 +262,11 @@ mechanisms, and conflating them is how apps end up over-permissioned: | `INTERNET` | **don't declare it until sync ships** | | `GET_ACCOUNTS` | never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts | -**Work item:** the permission gate in `RootScreen` / `PermissionViewModel` / -`ProviderResolver.hasPermission` currently assumes an external provider always -needs a grant. It needs a bypass for our own provider. Modest, but it's the -exact flow `fix/provider-interaction-review` just touched — merge that first. +~~**Work item:** the permission gate … needs a bypass for our own provider.~~ +✅ Done. `ProviderResolver.hasPermission` short-circuits to `true` when +`TaskProvider.isOwn`, so `ProviderStatus.NEEDS_PERMISSION` cannot fire in +Local/Synced mode, and `PermissionViewModel` never offers our own permissions to +the request launcher. Covered by `ProviderResolverTest`. --- @@ -259,7 +281,7 @@ task-domain, it goes to the kit. | ContentProvider seam — `ColumnReader`, failures, observer→Flow | `core-provider` | **already on the kit's deferred list**, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. | | Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. `core-permissions` | pure mechanics, and Calendula has the identical problem | | DAV client + iCalendar parse/serialize | new, e.g. `core-dav` | **the big one.** Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later | -| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't | +| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't. **Built app-local for now** (`data/export/ExportWriter`), on the kit's own principle of not extracting before a second consumer exists — the seam is in place, so moving it is a file move. `ICalendarWriter` stays app-local permanently: it's domain. | | Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain | **Stays app-local:** the vendored `:provider` module (task-specific, and @@ -337,14 +359,33 @@ the roadmap should say so rather than inheriting the old estimate. ## Open questions 1. **Sync protocol coverage**, account model, conflict resolution — the next - discussion. -2. **Resolver ordering / mode selection UX** once our provider coexists with - external ones (see [Storage modes](#storage-modes--the-users-choice)). -3. **Does the vendored provider work with no account at all?** Local-only mode - depends on it entirely. First thing the vendoring work should prove. -4. **`ical4android` licensing** vs our MIT. + discussion. Still open. +2. **Resolver ordering** — ✅ decided, see + [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX** + is still open: `autoMode()` picks a default, but the Settings override it + assumes does not exist yet. +3. **Does the vendored provider work with no account at all?** ✅ Answered, with + a caveat about *how* it was answered. + + By construction: `cleanUpLists` exempts local lists explicitly (upstream's own + rule), and our rework restricts pruning to account types this package + authenticates — currently none — so nothing can be pruned at all. + `ProviderAccountCleanupTest` creates a local list and a task in it with zero + accounts present and reads both back. + + ⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric + has no SQLite backend in either mode. It runs on x86_64 CI. It is not a + substitute for a device, and this remains on the device-verification list. +4. **`ical4android` licensing** vs our MIT. Still open — and note the export path + does *not* depend on it: `ICalendarWriter` is our own ~200 lines, no library. + The question is really about the sync adapter's iCalendar *parsing*. 5. **jtx Board** as an additional External-mode candidate — richer contract, later. (`PLAN.md` decision #3, still open.) +6. **The vendored provider's timezone-change behaviour** — upstream's receiver + has a comment describing `break`s that were never written. We preserved the + observed behaviour and wrote it out explicitly; which of code or comment is the + bug wants a device to settle. Change 3 in + [`provider/PROVENANCE.md`](../provider/PROVENANCE.md). --- From 13cb27b2ab0a36143ddd0be9a5468d29b3f27852 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 14:46:07 +0200 Subject: [PATCH 05/21] docs: decide to build our own store and delete the vendored provider The vendored dmfs provider was kept on the grounds that it hands us the sync bookkeeping for free. The phase-1 sync audit measured that bookkeeping and found most of it broken, absent, or unusable: _DIRTY not set on delete, no home for a per-collection sync token, read-only collections inexpressible, ACCOUNT_TYPE write-once so enabling sync is a full migration, and cleanUpLists able to delete a user's lists after a backup restore. Sixteen findings are provider-imposed rather than platform- or protocol-imposed. Costing the alternative showed the swap is far smaller than assumed. TasksDataSource is already a 14-method, domain-shaped interface; exactly one file above the data layer references TasksContract. The work is a second implementation behind an interface built for it, not a rewrite. Against ~5 weeks to build, owning the store removes 2.5-4 weeks from the sync plan, and 8,200 of the vendored 14,555 lines are things we would never write - 23 migrations from a 2013 schema, 798 lines of full-text search the app has zero call sites for, and 1,581 lines of a type-safe layer over ContentValues that Room deletes. External mode (OpenTasks, tasks.org) is unaffected and keeps every file that describes somebody else's schema. STORAGE-DECISION.md is the reasoning; OWN-STORE.md is the architecture and the six-phase plan. :provider stays in-tree until phase 5 so recurrence parity can be tested against it before it goes. Also corrected here: the provider's JVM test count (51 -> 56, measured from the test-results XML) and a fourth site of the debunked "switching sync on is never a migration" claim, in StorageMode.kt. Co-Authored-By: Claude Opus 5 (1M context) --- .../agendula/data/tasks/StorageMode.kt | 11 +- .../agendula/domain/export/ICalendarWriter.kt | 23 +- docs/ARCHITECTURE.md | 18 +- docs/OWN-STORE.md | 439 ++++++ docs/README.md | 19 +- docs/ROADMAP.md | 20 +- docs/STORAGE-AND-SYNC.md | 58 +- docs/STORAGE-DECISION.md | 246 ++++ docs/SYNC.md | 1185 +++++++++++++++++ provider/PROVENANCE.md | 4 +- 10 files changed, 1984 insertions(+), 39 deletions(-) create mode 100644 docs/OWN-STORE.md create mode 100644 docs/STORAGE-DECISION.md create mode 100644 docs/SYNC.md diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt index 85789d7..7105f46 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -5,9 +5,14 @@ package de.jeanlucmakiola.agendula.data.tasks * * Only two values, though the document describes three modes. **Synced is not a * third store**: it is [LOCAL] with an account attached, so it is derived state - * (does an account of ours exist?) rather than something the user picks. That - * also means switching sync on is never a migration. Adding a `SYNCED` constant - * here would imply otherwise. + * (does an account of ours exist?) rather than something the user picks. Adding + * a `SYNCED` constant here would imply otherwise. + * + * ⚠️ This used to add "so switching sync on is never a migration". That is wrong. + * `TaskLists.ACCOUNT_TYPE` is write-once in the provider — `processors/lists/ + * Validating.java:68-76` throws `IllegalArgumentException` on any attempt to + * change it — so attaching an account to an existing local list means recreating + * every list and every task under the new account. See `docs/SYNC.md`. * * Nothing above the data layer reads this; it selects an authority for * [ProviderResolver] and stops there. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt index e310bdf..520fa7a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -57,12 +57,23 @@ object ICalendarWriter { /** * The UID to write for [task]. * - * Local tasks have none: the dmfs provider only permits a sync adapter to - * assign `_uid`, so in Local mode every task arrives here with `uid == null`. - * A VTODO without a UID is invalid and, worse, un-mergeable — re-importing a - * backup would duplicate every task instead of matching it. So we synthesise - * one from the row id, which is stable for as long as the row is, and tag it - * with our own domain so a synthesised UID is recognisable as such. + * Local tasks have none, because nothing assigns one: the provider never + * generates a `_uid` itself, and our write path does not set it either, so in + * Local mode every task arrives here with `uid == null`. + * + * Note this is a gap we leave open, not one the provider imposes. + * `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters + * on *update* only; `insert` does not check it, so any caller may assign a UID + * at creation. Doing that would be strictly better than synthesising here — + * see `docs/SYNC.md`, where it is a phase-1 item, because a real UID minted at + * creation is what lets a local task later be pushed to CalDAV without + * duplicating. + * + * Until then: a VTODO without a UID is invalid and, worse, un-mergeable — + * re-importing a backup would duplicate every task instead of matching it. So + * we synthesise one from the row id, which is stable for as long as the row + * is, and tag it with our own domain so a synthesised UID is recognisable as + * such. */ fun uidFor(task: ExportTask): String = task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4fadb62..36d125e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ the original "owns no database" thesis, settled in installed made someone else's roadmap a gate on the app working at all. The database is the vendored dmfs provider under *our* authority — our namespace, not a schema written from scratch — so every CalDAV engine still understands it. A -sync adapter of our own is the 1.x arc. +sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md). The whole design hangs off one rule: @@ -143,9 +143,15 @@ The platform calls sit behind `ProviderEnvironment` so this decision is unit tested on the JVM (`ProviderResolverTest`) rather than only on a device. **Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md` -describes three, but *Synced* is not a third store — it is Local with an account -attached, so it is derived state, and modelling it as a separate mode would imply -that turning sync on is a migration. It isn't. +describes three, but *Synced* is not a third store — it is the same store with an +account attached, so it stays derived state. + +⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.** +A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider +(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at +an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a +mode is still right, but the migration it implied away is real. See +[`SYNC.md`](SYNC.md). ### 4.2 `TasksContract` @@ -261,7 +267,9 @@ resolver, so vendoring an entire content provider **changed no UI, no ViewModel, no domain type, and not one line of `TasksRepository`.** Bundling the provider bundles **storage, not sync**. Our own sync adapter is a -separate, later piece of work — see `STORAGE-AND-SYNC.md`. +separate, later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands +*underneath* this same seam: it writes through `TaskContract` with +`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time. --- diff --git a/docs/OWN-STORE.md b/docs/OWN-STORE.md new file mode 100644 index 0000000..00ab459 --- /dev/null +++ b/docs/OWN-STORE.md @@ -0,0 +1,439 @@ +# Agendula's own task store — architecture and plan + +**Branch:** `feat/own-store` +**Decision:** taken. `docs/STORAGE-DECISION.md` costed it; this is the build. +**Supersedes:** the "keep `:provider`" position in `STORAGE-AND-SYNC.md` and the +"Settled" section of `SYNC.md`. + +--- + +## The decision, in one paragraph + +Agendula stops vendoring the dmfs OpenTasks provider. The `:provider` module — +14,555 lines of Java, 1.66× the size of the app itself — is **deleted**. In its +place the app gets its own Room database, designed for the two things Agendula +actually does: show tasks, and sync them over CalDAV. Support for *external* +providers (OpenTasks, tasks.org) **stays**, unchanged, as a user choice — so +anyone already syncing through DAVx5 keeps working exactly as they do today. + +Agendula becomes a normal Android app with a normal database, plus an optional +compatibility path into somebody else's ContentProvider. + +--- + +## What changes and what does not + +``` +BEFORE AFTER + + UI / ViewModels UI / ViewModels + │ │ + TasksRepository TasksRepository ← unchanged + │ │ + TasksDataSource (interface) TasksDataSource ← unchanged + │ ╱ ╲ + AndroidTasksDataSource RoomTasksDataSource AndroidTasksDataSource + │ │ │ + ContentResolver Room / SQLite ContentResolver + │ │ │ + ┌────┴─────┐ our tables ┌──────┴──────┐ + │ │ │ │ +:provider OpenTasks OpenTasks tasks.org +(deleted) tasks.org (external, unchanged) +``` + +**Unchanged above the data layer.** `TasksRepository`, every ViewModel, every +screen. Verified: exactly one file outside `data/tasks` references +`TasksContract` (`domain/Models.kt`, for five constants), and it stops doing so +in phase 0. + +**Deleted.** The `:provider` Gradle module, its manifest ``, its two +custom permissions, its 84 Java files, its 13 translated string resources, and +its three dmfs runtime dependencies from `:provider`'s own build file. + +**Kept for External mode.** `TasksContract.kt`, `ColumnReader.kt`, +`TaskMapper.kt`, `TaskWriteMapper.kt`, `AndroidTasksDataSource.kt`, +`ProviderResolver.kt`, `ProviderEnvironment.kt`, `TaskProjections.kt`. These +describe *somebody else's* schema and are exactly right for that job. + +--- + +## Storage modes after the change + +```kotlin +enum class StorageMode { + /** Agendula's own Room database. The default; always available. */ + OWN, + /** A tasks provider app already installed — OpenTasks, tasks.org. */ + EXTERNAL, +} +``` + +`LOCAL` (meaning "our bundled dmfs provider") is gone. `ProviderResolver.own` +and the `TaskProvider(isOwn = true)` case go with it: in `OWN` mode there is no +authority, no ContentResolver and no permission to grant. + +`ProviderResolver` narrows to what it was always really for — **discovering +external providers** — and `ProviderStatus.READY` becomes unconditional in `OWN` +mode. + +### Consequences worth stating plainly + +- **No runtime permission is needed for the default path.** Today's permission + prompt only ever applied to External mode; now that is visibly true. +- **Third-party apps can no longer read Agendula's tasks.** We publish no + ContentProvider. Users who need interop pick External mode, or wait for a + possible read-only facade (explicitly out of scope — see *Deliberately not + doing*). +- **Auto Backup gets simpler and safer.** One Room file we control, with a + documented restore path, instead of a provider database whose `cleanUpLists` + routine could delete restored lists whose accounts no longer exist. + +--- + +## The schema + +Four tables. Designed from Agendula's actual reads and writes plus RFC 5545's +`VTODO`, not inherited from a 2013 schema. + +### `task_lists` + +| Column | Type | Notes | +|---|---|---| +| `id` | INTEGER PK | | +| `name` | TEXT NOT NULL | | +| `color` | INTEGER NOT NULL | ARGB | +| `account_id` | INTEGER NULL | FK → `accounts`, NULL = device-only | +| `is_visible` | INTEGER NOT NULL | default 1 | +| `is_synced` | INTEGER NOT NULL | default 1 | +| `owner` | TEXT NULL | CalDAV owner display name | +| `is_read_only` | INTEGER NOT NULL | **new** — the provider could not express this at all | +| `sort_order` | INTEGER NOT NULL | user ordering, which the provider also lacked | +| `href` | TEXT NULL | collection URL, relative to the account root | +| `ctag` | TEXT NULL | | +| `sync_token` | TEXT NULL | RFC 6578, **per collection** — not squatted into a shared slot | +| `is_dirty` | INTEGER NOT NULL | a real boolean, not dmfs's monotonic counter | + +> `account_id` being nullable is the single most important schema change. In the +> dmfs provider `ACCOUNT_TYPE` is write-once and throws on change, which made +> "turn sync on" a full data migration. Here, attaching a local list to an +> account is `UPDATE task_lists SET account_id = ?`. + +### `tasks` + +Master rows *and* recurrence overrides live here; an override is a row with +`recurrence_id` set and `parent_task_id` pointing at its master. + +| Group | Columns | +|---|---| +| identity | `id`, `list_id`, `uid` (NOT NULL, minted at creation), `href`, `etag` | +| content | `title`, `description`, `location`, `url`, `color` | +| state | `status`, `percent_complete`, `completed_at`, `priority`, `classification` | +| time | `dtstart`, `due`, `duration`, `is_all_day`, `timezone` | +| recurrence | `rrule`, `rdate`, `exdate`, `recurrence_id`, `master_id` | +| hierarchy | `parent_id`, `sort_order` | +| audit | `created_at`, `last_modified`, `sequence` | +| sync | `is_dirty`, `is_deleted`, `unknown_properties` | + +Two entries deserve explanation. + +**`uid` is NOT NULL and assigned at creation.** Every task gets a real +RFC 4122 UUID the moment it is inserted, in every mode, synced or not. This +closes the gap `ICalendarWriter.uidFor` currently papers over by synthesising +`agendula-@…`, and it means any local task can later be pushed to a +server without duplicating. The provider never assigned one. + +**`unknown_properties`** holds the raw unfolded iCalendar lines of every +property we do not model — `ATTENDEE`, `CATEGORIES`, `X-*`, `GEO`, and anything +a future RFC adds. On write we re-emit them verbatim after the properties we do +own. This is what makes an honest round-trip possible, and it replaces the +provider's `data0`–`data15` bag with something that cannot silently lose a field +it has no column for. + +Indices: `(list_id, is_deleted)`, `(parent_id)`, `(uid)` unique per list, +`(master_id, recurrence_id)`, `(is_dirty)`. + +### `task_alarms` + +| Column | Notes | +|---|---| +| `id`, `task_id` | FK, `ON DELETE CASCADE` | +| `minutes_before` | positive = before the reference | +| `reference` | `DUE` or `START` | +| `message` | optional | + +Replaces `AlarmHandler` (133 lines of Java) and the `dataN` slot convention. +Delete-and-reinsert stops being necessary — the provider's re-validate-everything +behaviour was the only reason `setAlarm` worked that way. + +### `accounts` + +| Column | Notes | +|---|---| +| `id`, `display_name`, `principal_url`, `home_set_url` | +| `username` | the app password is **not** here — Keystore only, per `SYNC.md` | +| `last_sync_at`, `last_sync_error` | + +Not populated until `SYNC.md` phase 2, but the FK exists from v1 so enabling +sync never requires a schema migration. + +--- + +## Recurrence: expand at read, not on write + +The provider maintained a materialised `instances` table, recomputed by +`Instantiating.java` on every write — and still only ever materialised **one** +upcoming occurrence. + +Agendula expands lazily instead: + +``` +tasks (masters + overrides) ──► RecurrenceExpander ──► List + rrule/rdate/exdate (lib-recur, in memory) occurrences +``` + +This is the right call here because **the repository already filters and sorts +in Kotlin, not SQL**. `TasksRepositoryImpl.loadTasks` reads the whole set, +applies `TaskFiltering.matches`, then `TaskSorting.DEFAULT`. Nothing depends on +the database being able to order by instance time, so nothing is lost — and a +materialised table's entire class of staleness bugs never exists. + +- Bounded window: expansion is capped (default: 1 year back, 2 years forward, + hard ceiling of N occurrences per series) so an unbounded `RRULE` cannot hang + the UI. +- `lib-recur` **pinned at 0.12.2** — 0.16.0 removed `RecurrenceSet`. We pin + because we chose to, and the pin is now ours to lift on our own schedule. +- Client-side expansion is required for CalDAV regardless: server-side + `CALDAV:expand` on `VTODO` is broken on every server `SYNC.md` targets. + +### Completing one occurrence of a recurring task + +The provider's `Detaching.java` implemented **model (d): detach the occurrence +as a brand-new task with its own UID**. We inherited that without ever choosing +it, and it is the model least compatible with CalDAV. + +**We implement model (a): a `RECURRENCE-ID` override.** Completing one +occurrence writes a second `tasks` row with the same `uid`, a `recurrence_id` +naming the occurrence, `master_id` pointing at the series, and the completed +state. This is what RFC 5545 specifies and what every other CalDAV client +expects to receive. + +This decision is now made explicitly, recorded here, and testable. + +--- + +## Reactivity + +`TasksDataSource.registerObserver(onChange: () -> Unit): AutoCloseable` **stays +as-is**. The Room implementation backs it with `InvalidationTracker.Observer` +over the four tables; the External implementation keeps its `ContentObserver`. +One interface, two mechanisms, `TasksRepositoryImpl.observing()` untouched. + +Going Flow-native in the DAOs is a later, optional refinement. Doing it now +would change the interface and therefore the External path, for no user-visible +gain. + +--- + +## Migrating existing users + +Anyone on v0.3.x has their tasks inside the bundled provider's SQLite file at +`/data/data/de.jeanlucmakiola.agendula/databases/tasks.db` (dmfs schema +version 23). Removing the Gradle module does **not** remove that file — an app +update leaves the data directory intact. + +So the migration reads the file directly, with no provider and no +ContentResolver involved: + +``` +OneShotImport + 1. does databases/tasks.db exist? no → nothing to do, mark done + 2. open SQLiteDatabase.OPEN_READONLY + 3. read tasklists → task_lists (account_type LOCAL → account_id NULL) + 4. read tasks → tasks (skip _deleted = 1; mint uid where NULL) + 5. read properties → task_alarms (mimetype = …/alarm only) + 6. verify counts, inside one Room transaction + 7. record completion in DataStore + 8. rename tasks.db → tasks.db.imported (kept one release, then deleted) +``` + +Rules that make this safe: + +- **Read-only, single transaction, verified counts.** Either the whole import + lands or none of it does. +- **The source file is renamed, never deleted**, for one release. If the import + is wrong we can still recover from a user's device. +- **Idempotent.** Guarded by a DataStore flag *and* by the rename, so a crash + mid-import cannot double-import. +- **Runs before first UI read**, gated the same way `StorageModeHolder.awaitReady()` + already gates the launch reminder re-sync. +- Tasks that were in an *external* account inside our bundled provider (only + possible if the user had pointed DAVx5 at our authority) are imported as + local lists, with their `uid` preserved. Rare, but preserving the UID is what + lets them be re-attached to an account later. + +`tasks.db.imported` is excluded from Auto Backup; the new Room database is +included, which is the whole point of owning it. + +--- + +## Effects on the sync plan + +`SYNC.md`'s phase list was written against the provider. Owning the store +deletes work from it outright: + +| `SYNC.md` item | Fate | +|---|---| +| "Assign UIDs at creation" (phase 0) | **gone** — `uid` is NOT NULL from v1 | +| Auto Backup / `cleanUpLists` data-loss guard (phase 0) | **gone** — no `cleanUpLists` | +| `lib-recur` pin rationale (phase 0) | reduced to a normal version choice | +| Local → Synced migration (phase 3) | **gone** — `account_id` is a nullable FK | +| ETag / href / CTag squats into `SYNC1`–`SYNC8` | **gone** — real columns | +| Per-collection sync token (phase 3) | **gone** — real column | +| `_DIRTY` set-on-delete workaround (phase 3) | **gone** — tombstones are ours | +| `CALLER_IS_SYNCADAPTER` ignored by instances URI | **gone** — no URIs | +| `Moving` dual-UID collision (phase 4) | **gone** | +| Read-only collections (phase 4) | now *possible* — `is_read_only` exists | +| Recurring-completion model | **decided here** — RECURRENCE-ID override | +| Byte-stable round-trip | improved — `unknown_properties` preserves the rest | + +Everything platform-level and protocol-level in `SYNC.md` is untouched: the +`targetSdk 34` sync-framework gate, the stub sync-adapter pattern, credential +storage, Play compliance, discovery, conditional `PUT`, conflict policy, and +every per-server quirk in the server-reality table. + +--- + +## Plan + +### Phase 0 — Untangle (0.5 wk) + +- `domain/Models.kt` stops importing `TasksContract`; the status and priority + constants move into `domain`. This is the last contract reference above the + data layer. +- `StorageMode`: `LOCAL` → `OWN`; `ProviderResolver` loses `own` / `isOwn`. +- `ProviderChangeReceiver`'s manifest filter drops our own authority. +- Add Room + KSP to the version catalog (KSP is already applied to `:app`). + +**Done when:** the app still builds and behaves identically, with the provider +still present and still default. + +### Phase 1 — Schema and DAOs (1 wk) + +- The four entities above, plus DAOs, plus `schemas/` exported for migration + testing (`room.schemaLocation`, committed). +- `RoomTasksDataSource` implementing all 14 `TasksDataSource` methods except the + recurrence-dependent ones, which throw until phase 2. +- `DataModule` binds by `StorageMode`. + +**Done when:** a JVM test creates lists and non-recurring tasks through +`TasksDataSource` against an in-memory Room database and reads them back. + +### Phase 2 — Recurrence (1.5–2 wk) + +The hard phase. Budget accordingly. + +- `RecurrenceExpander` over `lib-recur`: `RRULE`, `RDATE`, `EXDATE`, overrides, + all-day handling, bounded window, `distanceFromCurrent`. +- `RECURRENCE-ID` override creation on single-occurrence edit and completion. +- A test suite that is the deliverable, not an afterthought: daily/weekly/ + monthly-by-day/yearly, `COUNT` and `UNTIL`, DST boundaries, all-day series, + a series with an override, a series with an exception, and an unbounded rule + hitting the window ceiling. + +**Done when:** `updateInstance` and recurring reads pass parity tests written +against the current provider's observed behaviour, *except* where model (a) +deliberately differs from model (d) — those differences enumerated as tests. + +### Phase 3 — Semantics parity (1 wk) + +- Completion coherence: `status` ↔ `percent_complete` ↔ `completed_at` ↔ closed, + replacing `AutoCompleting.java` and — importantly — the reopen asymmetry that + `TaskWriteMapper` currently works around in the app. +- Parent/child integrity, orphan handling on delete. +- Validation: `DUE` xor `DURATION`, `due >= dtstart`, all-day pinned to UTC + midnight, list must exist. +- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set. + +**Done when:** `TaskWriteMapper`'s provider-quirk workarounds are demonstrably +unnecessary on the Room path (they stay for External). + +### Phase 4 — Import and cutover (1 wk) + +- `OneShotImport` per the rules above, with tests over a fixture `tasks.db` + captured from a real v0.3.x install. +- `OWN` becomes the default for new installs and for upgraders after import. +- Backup rules updated: include the Room database, exclude `tasks.db.imported` + and the Keystore blob. + +**Done when:** an upgrade from a v0.3.2 APK with seeded data lands every task, +list and reminder in Room, verified by count and by content. + +### Phase 5 — Delete `:provider` (0.5 wk) + +- Remove the module, its `settings.gradle.kts` include, its `:app` dependency, + the three dmfs deps it pulled in, `provider/PROVENANCE.md`. +- Add `lib-recur` (and `rfc5545-datetime`) directly to `:app`. +- Attribution screen: dmfs code is gone, but `lib-recur` stays and is + Apache-2.0. `PROVENANCE.md` is replaced by a short note in + `STORAGE-DECISION.md` recording that the fork existed and why it ended. + +**Done when:** `./gradlew build` is green with `:provider` absent, and the APK +declares no ContentProvider and no custom permissions. + +### Phase 6 — Harden (1 wk) + +- Room migration test infrastructure (`MigrationTestHelper`) wired up, so v1 → + v2 is cheap when sync adds columns. +- Restore-path test: Auto Backup restore into a fresh install. +- Performance check at 5,000 tasks with 20 recurring series. + +**Total: 6–6.5 weeks** to a shipping app with its own store, before any CalDAV +work begins. `SYNC.md`'s own estimate drops by 2.5–4 weeks in exchange. + +--- + +## Testing posture + +| Layer | How | +|---|---| +| Entities, DAOs, migrations | Room in-memory + `MigrationTestHelper`, JVM | +| `RecurrenceExpander` | pure JVM, no Android — the largest suite | +| Semantics (completion, hierarchy, validation) | JVM through `TasksDataSource` | +| `OneShotImport` | fixture `tasks.db` committed as a test resource | +| External mode | unchanged; existing `TaskMapper` / `TaskWriteMapper` tests stay | + +The 93 existing app tests must stay green throughout. The 56 provider tests +leave with the module in phase 5 — replaced, not abandoned: phases 2 and 3 owe +equivalent coverage of the behaviour those tests protected, and phase 2's +parity suite is written against them. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| **Recurrence is subtler than estimated** | Phase 2 is isolated and pure-JVM; it can overrun without blocking phases 3–4. The provider stays in-tree until phase 5, so we can always compare against it. | +| **Import loses a user's data** | Read-only source, single transaction, count verification, source file renamed not deleted, fixture-based tests. | +| **Regression in a behaviour nobody documented** | Phase 2's parity tests are written *against the provider while it is still present*. That is why deletion is phase 5, not phase 0. | +| **Losing third-party interop** | External mode covers users who need it. A read-only facade stays possible later; nothing in this design forecloses it. | +| **Room + KSP build cost** | KSP is already in the build for Hilt; Room adds one processor. | + +--- + +## Deliberately not doing + +- **An exported ContentProvider facade over Room.** Possible later (~1–1.5 wk), + not now. Shipping one would recreate the public-API surface whose validation + and URI plumbing is most of what we are deleting. +- **A domain-native schema.** The table shapes above stay recognisably close to + `TaskContract` where `TaskContract` was right, because it is a proven design + for `VTODO` and because it keeps a future facade cheap. +- **Flow-native DAOs.** Later refinement; changes the interface for no + user-visible gain today. +- **FTS / search.** The provider carried 798 lines of it. The app has never + called it. If search is wanted it is a feature request, designed on its own + terms. +- **Categories and attendees as first-class tables.** They round-trip through + `unknown_properties` until a feature actually needs them. diff --git a/docs/README.md b/docs/README.md index eb146c7..b672819 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,10 @@ # Agendula — documentation -Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over -the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync -over CalDAV), with no own database or sync stack. Sibling to +Agendula is a Material 3 Expressive **task** app for Android. It **carries its +own task store** — the dmfs task provider vendored under our own authority — so +it is complete and local-first with nothing else installed; an external provider +(OpenTasks / tasks.org, synced by DAVx5 / SmoothSync / DecSync) is a user choice +rather than a requirement, and our own CalDAV sync is the 1.x arc. Sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). See the top-level [`../README.md`](../README.md) for the project pitch. @@ -12,15 +14,24 @@ top-level [`../README.md`](../README.md) for the project pitch. |---|---| | [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. | +| [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) | **Where task data lives** — the decision to ship our own provider, the storage modes, permissions, distribution, and the dead ends. Supersedes `PLAN.md` on storage. | +| [`SYNC.md`](SYNC.md) | **How data reaches a server** — the CalDAV sync adapter: the VTODO ↔ `TaskContract` mapper, Nextcloud sign-in, the engine, libraries and their licenses. Step 5 of `STORAGE-AND-SYNC.md`. | +| [`STORAGE-DECISION.md`](STORAGE-DECISION.md) | **Keep the vendored provider, or build our own?** The measured cost of both. **Decided: build our own.** | +| [`OWN-STORE.md`](OWN-STORE.md) | **Agendula's own Room store** — the schema, recurrence design, migration off the vendored provider, and the six-phase plan that deletes `:provider`. Supersedes the "keep the provider" position in `STORAGE-AND-SYNC.md`. | | [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | +| [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) | What the vendored `:provider` module is, where it came from, and **every** deviation from upstream dmfs. | Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections feed the release notes). ## How the docs relate -- **PLAN** is the design decisions (mostly stable; the "why"). +- **PLAN** is the original design decisions (the "why"), left as the historical + record. On storage it is **superseded by STORAGE-AND-SYNC**. +- **STORAGE-AND-SYNC** and **SYNC** are the standing decision documents: the + first settles where data lives, the second how it syncs. Both record rejected + alternatives on purpose, so decisions don't get relitigated. - **ARCHITECTURE** is the current shape of the code (kept in sync with the source as it grows). - **ROADMAP** is the moving status layer (update as milestones land). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 81839d0..564f999 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -142,7 +142,7 @@ squatting `org.dmfs.tasks`, which is a dead end). prune account types we authenticate ourselves — the deletion is unsafe without that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17. [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation, - each also marked `AGENDULA CHANGE` at the site. Upstream's 51 JVM tests pass. + each also marked `AGENDULA CHANGE` at the site. Upstream's 56 JVM tests pass. - ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION` can no longer fire in Local mode, and an upgrading Posture A user stays on the provider that holds their data (`ProviderResolver.autoMode`). @@ -152,9 +152,12 @@ squatting `org.dmfs.tasks`, which is a dead end). - ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and an export screen. The backend is done and unused until these exist. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. -- ⬜ Sync adapter (step 5) — the 1.x arc. Design discussion still open: protocol - coverage, account model, conflict resolution, and `ical4android`'s licence - against our MIT. +- ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**, + not started: mapper → auth → engine → hardening, ~8–9 weeks. The account model + is settled (`AccountManager`) and `ical4android` is closed out (superseded by + `synctools`, GPLv3, so we write the mapper in-house); what's still open is + dav4jvm's JitPack-only distribution, conflict policy, and whether External mode + survives the milestone. - ⬜ Verify on a device: the local path with no account, and the vendored provider's timezone-change behaviour (change 3 in `PROVENANCE.md`). @@ -168,7 +171,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through 2. ~~**tasks.org provider authority**~~ verified on device: `org.tasks.opentasks` + `org.tasks.permission.*`. 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? - (Not in the candidate list today.) + (Not in the candidate list today.) Note this is now downstream of + [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync + ourselves, the question disappears with it. 4. ~~**Posture B authority choice**~~ resolved: **our own** `de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, not merely a trade-off — two apps cannot declare the same authority or @@ -181,8 +186,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through 6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it assumes is not built yet. -7. **Sync protocol coverage**, account model, conflict resolution — the next - design discussion. +7. ~~**Sync protocol coverage**, account model, conflict resolution — the next + design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens + live on that document's list. --- diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index aba0a6f..4cfb065 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -1,5 +1,15 @@ # Agendula — storage and sync +> ⚠️ **Partly superseded, 2026-08-13.** The core decision below — *vendor the +> dmfs provider in-tree as `:provider`* — has been **reversed**. Agendula builds +> its own Room store and deletes the vendored provider; External mode (OpenTasks, +> tasks.org) is unaffected and everything this document says about it still +> stands. See [`STORAGE-DECISION.md`](STORAGE-DECISION.md) for why and +> [`OWN-STORE.md`](OWN-STORE.md) for what replaces it. The permissions, +> distribution, storage-mode and dead-end sections below remain accurate; treat +> the "our own provider" sections as the historical record of a decision that was +> made, shipped, and then costed properly. + > Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = > bundle OpenTasks" working notes, which are withdrawn (see > [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to @@ -34,7 +44,7 @@ | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | | 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | -| 5 | Sync adapter | the 1.x arc; design discussion pending | ⬜ | +| 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ | Everything below is the reasoning behind those choices, the alternatives that were rejected, and the constraints they have to survive. @@ -174,8 +184,13 @@ feature — see [Storage modes](#storage-modes--the-users-choice). | **Synced** | our bundled provider | our sync adapter | network + an account the user configures | | **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime | -Local and Synced are the same store — Synced is Local with an account attached, -so switching on sync is not a migration. +Local and Synced are the same store — but ⚠️ **switching on sync *is* a +migration, contrary to what this document said until 2026-08-13.** The provider +enforces `ACCOUNT_NAME` and `ACCOUNT_TYPE` as **write-once** on a task list +(`processors/lists/Validating.java:68-76`, which throws), so a list created under +`org.dmfs.account.LOCAL` can never be re-pointed at a real account. Enabling sync +means creating new lists under the account and moving tasks into them. See +[`SYNC.md`](SYNC.md) — it is a costed deliverable there, not a free consequence. **Resolver ordering — decided, and it went both ways as expected.** `ProviderResolver` now takes an explicit `StorageMode` from Settings when there @@ -235,10 +250,17 @@ compliance and a small enum-shaped addition with near-zero ongoing maintenance for them. Say that explicitly. "Here's a change that can't break anything" lands very differently from "please support my app." -**Open — the next discussion.** Protocol coverage ("support as much as -possible"), the account model, conflict resolution, and where the DAV/iCalendar -work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0 -and fine, but **verify `ical4android`'s license** before assuming it's usable. +**The design is now written out in [`SYNC.md`](SYNC.md)** — protocol coverage, +the account model, conflict resolution, the VTODO ↔ `TaskContract` mapper, and +where the DAV/iCalendar work lives. Two corrections to what this section +originally said, both verified 2026-08-13: + +- `dav4jvm` is **MPL-2.0**, not Apache-2.0. Still fine against our MIT (file-level + copyleft), but it is ⚠️ **JitPack-only**, which collides with our + `FAIL_ON_PROJECT_REPOS` + `google()`/`mavenCentral()` policy and with the + JitPack dead end below. `SYNC.md` open question 1. +- `ical4android` is **superseded by `synctools`, which is GPLv3** — so it is + unusable, and the open question below is closed. We write the mapper in-house. --- @@ -336,6 +358,13 @@ Not on either yet; both are targets, so build *for* them rather than retrofittin throwaway spike* (a library string resource can be overridden from the app module, so the authority rename works), but not for anything we ship. + ⚠️ **This one comes back.** It is a dead end *for the provider*, where + vendoring was mandatory anyway. `dav4jvm` is JitPack-only, so the sync adapter + has to answer the same question on its own terms — and F-Droid turns out not to + be the obstacle (its inclusion policy trusts jitpack.io for freely-licensed + artifacts); our own trust-surface policy is. See [`SYNC.md`](SYNC.md) open + question 1. + --- ## Sequencing @@ -358,8 +387,11 @@ the roadmap should say so rather than inheriting the old estimate. ## Open questions -1. **Sync protocol coverage**, account model, conflict resolution — the next - discussion. Still open. +1. **Sync protocol coverage**, account model, conflict resolution — ✅ taken up in + [`SYNC.md`](SYNC.md). The account model is answered there (`AccountManager`, + which `PROVENANCE.md` change 1 already assumes); what stays open moves to that + document's own list — dav4jvm's distribution, conflict policy, External mode's + future, and recurring-task completion. 2. **Resolver ordering** — ✅ decided, see [Storage modes](#storage-modes--the-users-choice). The **mode-selection UX** is still open: `autoMode()` picks a default, but the Settings override it @@ -376,9 +408,11 @@ the roadmap should say so rather than inheriting the old estimate. ⚠️ **But that test is Robolectric, and it skips on ARM64**, where Robolectric has no SQLite backend in either mode. It runs on x86_64 CI. It is not a substitute for a device, and this remains on the device-verification list. -4. **`ical4android` licensing** vs our MIT. Still open — and note the export path - does *not* depend on it: `ICalendarWriter` is our own ~200 lines, no library. - The question is really about the sync adapter's iCalendar *parsing*. +4. ~~**`ical4android` licensing** vs our MIT.~~ ✅ Closed: `ical4android` is + superseded by `synctools`, which is **GPLv3**, so it is out — as is + `cert4android`. The export path never depended on it anyway (`ICalendarWriter` + is our own ~200 lines). The sync adapter's iCalendar parsing goes through an + in-house mapper over `ical4j`/`biweekly`; see [`SYNC.md`](SYNC.md). 5. **jtx Board** as an additional External-mode candidate — richer contract, later. (`PLAN.md` decision #3, still open.) 6. **The vendored provider's timezone-change behaviour** — upstream's receiver diff --git a/docs/STORAGE-DECISION.md b/docs/STORAGE-DECISION.md new file mode 100644 index 0000000..bf21c4d --- /dev/null +++ b/docs/STORAGE-DECISION.md @@ -0,0 +1,246 @@ +# Storage: keep the vendored provider, or build our own? + +**Status:** **decided — build our own.** See [`OWN-STORE.md`](OWN-STORE.md) for +the architecture and plan; this document is the reasoning that got there. + +The decision went further than the recommendation below: `:provider` is not kept +alongside a Room store, it is **deleted**. External mode (OpenTasks, tasks.org) +stays. The staged sequencing survives in a different form — the provider remains +in-tree until `OWN-STORE.md` phase 5 so recurrence parity can be tested against +it, then goes. + +This reopens a question `SYNC.md` marked settled. It is reopened on purpose: the +argument that settled it was *"the provider hands us the sync bookkeeping for +free"*, and the phase-1 audit found most of that bookkeeping broken, absent, or +unusable for our purposes. A conclusion is only as good as its premise. + +--- + +## The three options + +| | What it means | Store | Exported provider | +|---|---|---|---| +| **A** | Keep `:provider` as-is | dmfs `TaskProvider` | yes, ours today | +| **B** | Room, same `TaskContract` shape | our Room DB | dropped, or a later facade | +| **C** | Room, clean domain schema, `TaskContract` only as an export format | our Room DB | no | + +External mode (talking to OpenTasks / tasks.org) is orthogonal and survives all +three. It is the reason nothing below gets deleted. + +--- + +## What the swap actually touches — measured, not estimated + +### The seam is already there, and it is clean + +`TasksDataSource` (`data/tasks/TasksDataSource.kt`, 58 lines) is a **14-method, +domain-shaped interface**. It takes and returns `Task`, `TaskList`, `TaskForm` — +no `Cursor`, no `Uri`, no `ContentValues`. + +Above it, **17 files** import from `data.tasks`. What they import: + +``` +8 × TasksRepository 4 × TasksDataSource 3 × ProviderResolver +4 × recoveringFromProviderFailure 2 × ProviderStatus +1 each: StorageMode, StorageModeHolder, ProviderEnvironment, TaskQuery, … +``` + +**Exactly one file outside the data package touches `TasksContract` at all** — +`domain/Models.kt`, and only for four status integers, one priority constant and +one account-type string. Ten lines. Nothing else above the data layer knows a +ContentProvider exists. + +> The whole UI, all five milestones of it, is untouched by a storage swap. +> That is not luck — `AndroidTasksDataSource`'s own KDoc says the seam exists so +> that *"swapping the provider never reaches above this file."* It holds. + +### Nothing gets deleted + +| File | Lines | Under a Room store | +|---|---|---| +| `AndroidTasksDataSource.kt` | 220 | **kept** — External mode still needs it | +| `TasksContract.kt` | 178 | **kept** — External mode speaks it | +| `TasksRepositoryImpl.kt` | 151 | unchanged | +| `ProviderResolver.kt` | 143 | unchanged | +| `TaskWriteMapper.kt` | 118 | **kept** for External | +| `TaskMapper.kt` | 102 | **kept** for External | +| `TasksDataSource.kt` | 58 | unchanged — it is the interface | +| `TasksRepository.kt` | 53 | unchanged | +| `ProviderEnvironment.kt` | 51 | unchanged | +| `StorageModeHolder.kt` | 47 | unchanged | +| `ColumnReader.kt` | 40 | **kept** for External | +| `ProviderFlow.kt` | 31 | unchanged | +| `StorageMode.kt` | 30 | one new constant | +| `TaskProjections.kt` | 24 | **kept** for External | +| `Failures.kt` | 16 | unchanged | +| | **1,262** | **0 removed** | + +The work is **additive**: a second `TasksDataSource` implementation, a third +`StorageMode`, and one `@Binds` becoming a dispatcher. `DataModule.kt` has a +single binding to change. + +This reframes the question. It is not *rewrite vs. keep*. It is **write a second +backend behind an interface that exists for exactly this purpose, and run both +until one wins.** + +--- + +## What the new backend has to do + +Room entities and DAOs for the ~50 columns the app actually uses across four +tables are mechanical. The real work is the behaviour the provider's processors +perform. Measured against `:provider`'s Java: + +| Behaviour | Provider | Notes | +|---|---:|---| +| Instance expansion | ~1,070 | `Instantiating` + `instancedata` + iterables. **The hard one.** | +| Recurring-instance edit | 337 | `Detaching` — this *is* the recurrence-model decision | +| Completion coherence | 210 | `AutoCompleting`: status ↔ percent ↔ completed ↔ is_closed | +| Validation | 601 | three processors, mostly defending a *public* API | +| Parent / child | 269 | we use `parent_id` only | +| Alarm property rows | 133 | one Room entity | +| | **~2,620** | | + +And what we would **not** write, of the 14,555 vendored lines: + +| Not needed | Lines | Why | +|---|---:|---| +| `TaskDatabaseHelper` | 895 | 23 migrations from a 2013 schema. We start at v1. | +| `FTSDatabaseHelper` + ngrams | 798 | **the app never searches the provider** — verified, zero call sites | +| `model/adapters` | 1,581 | a type-safe layer over `ContentValues`. Room entities delete the problem. | +| `model` | 1,811 | cursor ↔ entity adaptation. Room's job. | +| `TaskProvider` + `SQLiteContentProvider` | 1,772 | URI matching, permissions, batch ops — for a public API | +| `CategoryHandler` + `RelationHandler` | 553 | unused | +| `utils` (most) | ~800 | dmfs jems idiom → Kotlin stdlib | +| **≈ 8,200 lines we would simply not have** | | | + +Two things make instance expansion less frightening than its line count: + +1. **We need client-side recurrence expansion regardless.** Server-side + `CALDAV:expand` on `VTODO` is broken on every server we target (`SYNC.md`), + so `lib-recur` is in the build either way. +2. **We would use the same eight `lib-recur` classes the provider does** — + `RecurrenceRule`, `RecurrenceSet`, `RecurrenceSetIterator`, `RecurrenceList`, + `RecurrenceRuleAdapter`, `DateTime`, `Duration`, + `InvalidRecurrenceRuleException`. The algorithm is in the library, not in the + provider. +3. And the provider's expansion **materialises only one upcoming occurrence + anyway** — it is not the complete implementation its size suggests. + +--- + +## The cost, both directions + +### Building it + +| | | +|---|---:| +| Schema, entities, DAOs | 1 wk | +| Instance expansion on `lib-recur`, with a real test suite | 1.5–2 wk | +| Completion / parent / validation semantics | 1 wk | +| Recurring-edit model — *shared cost, phase 1 either way* | (0.5–1 wk) | +| Migrating existing users' local data out of the provider | 0.5 wk | +| Tests to parity with the current 93 + 56 | 1 wk | +| **Net additional** | **4.5–6 wk** | + +### What it removes from the sync plan + +Roughly sixteen of the phase-1 audit's storage findings are **provider-imposed** +— they exist only because we run dmfs's implementation, and vanish when we own +the store: + +- `_DIRTY` not set on delete, and defaulting to `1` +- `TaskLists._DIRTY` as a monotonic counter, not a flag +- the instances URI ignoring `CALLER_IS_SYNCADAPTER` +- no home for a per-collection sync token, href, ETag or CTag — all four squat + into generic `SYNC1`–`SYNC8` slots +- **read-only collections cannot be represented at all** (`ACCESS_LEVEL` inert) +- sync-adapter delete ignoring the account parameters it forces you to supply +- `Moving` leaving a dual-UID collision +- `ACCOUNT_TYPE` write-once → enabling sync is a full data migration +- Auto Backup restore arming `cleanUpLists` → silent task loss +- `Detaching` deciding the recurring-completion model for us +- the `lib-recur` version trap (0.16.0 removed `RecurrenceSet`) — we pin because + the provider does, not because we want to + +Conservatively that is **2.5–4 weeks** off phases 0, 3 and 4 of the 11.5–15 week +sync plan, plus a class of bug that is currently *unfixable without patching +vendored Java*. + +### Net + +**≈ +1 to +3.5 weeks**, for a store we control, in exchange for two real losses. + +--- + +## The honest case for keeping it (Option A) + +Not nothing, and it should not be waved away: + +- **It works, and it has 56 passing JVM tests** over recurrence, reparenting, + instances and observers. A Room reimplementation is *new code with new bugs*, + in the layer that holds the user's only copy of their data. That risk is real + and it points at A. +- **Tombstones actually work.** Soft delete for account rows, hard delete for + sync adapters, hidden from normal queries, undelete refused. Of all the sync + bookkeeping, this is the piece that held up under audit. +- **The exported provider under our own authority** — third-party apps can read + Agendula's tasks, and asking DAVx5 to sync us stays possible. +- Eleven local modification sites, all marked `AGENDULA CHANGE`, all documented + in `provider/PROVENANCE.md`. The fork is under control today. + +And the case against keeping it: + +- **14,555 lines of Java — 1.66× the entire app** (8,783 lines of Kotlin). We + carry, build, lint, translate and ship all of it to use maybe a third. +- Upstream is effectively dormant; every future `targetSdk` bump and every + Android SQLite behaviour change lands on us, in someone else's code, in a + language the rest of the app does not use. +- It makes behavioural decisions on our behalf (`Detaching`, `AutoCompleting`) + that we then have to reverse-engineer before we can honour them over CalDAV. + +--- + +## Recommendation + +**Option B — build our own store on Room, keeping the `TaskContract` *shape* as +the internal model — and keep `:provider` in-tree while we do.** + +Three reasons, in order of weight: + +1. **The seam already exists and the work is additive.** Nothing is deleted, + nothing above `data/tasks` changes, and both backends can ship side by side + behind `StorageMode`. The "big rewrite" this decision was originally weighed + against does not exist. +2. **The premise that settled it is gone.** The provider was kept for sync + bookkeeping we have since measured as broken. Sixteen findings deep, keeping + it is now a *cost* to the sync plan, not a saving. +3. **The window is now.** After phase 1 the mapper and engine are written against + whichever store won, and this stops being a two-file change. + +Keeping the `TaskContract` *shape* rather than going domain-native (Option C) is +deliberate: it is a proven schema for exactly this problem, other engines +understand it, and it keeps a future exported facade cheap — without obliging us +to run a 2015 Java implementation of it. + +### Sequencing that keeps the risk low + +1. Add `StorageMode.OWN` and a Room `TasksDataSource`. Both backends live. +2. Ship it behind a setting; the vendored provider stays the default. +3. Run the sync engine against Room only. +4. Once Room has real production mileage, decide whether the exported + ContentProvider is worth re-implementing as a thin facade (~1–1.5 wk) or + whether External mode already covers everyone who wanted it. + +Step 4 is a genuinely open question and does not need answering now. That is the +point of sequencing it last. + +--- + +## Open + +- **Is an exported provider worth keeping at all?** It matters only if third + parties should read our tasks, or if we want DAVx5 to sync our store. External + mode arguably already serves the second. Undecided. +- **The Room estimate is mine, not measured.** Instance expansion is the item + that could overrun; everything else is well-bounded. diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..4938dd8 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,1185 @@ +# Agendula — CalDAV sync + +> Design notes for Agendula's own sync adapter. Drafted 2026-08-13; **audited the +> same day** against the platform, the vendored provider source, and the current +> state of every library named. The audit refuted or corrected a substantial part +> of the first draft — the corrections are marked ⚠️ **inline and kept visible** +> rather than quietly rewritten, because most of them are things the next person +> would otherwise assume again. +> +> This is step 5 of [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md)'s sequencing. +> That document decided **where task data lives**; this one decides **how it gets +> to a server**. +> +> Status: **draft / decision document.** Nothing here is built. Where a question +> is already answered by shipped code, it is marked ✅ and the code is named. +> +> ⚠️ **The storage question this document declared settled was reopened, and the +> answer changed.** Agendula is building its own Room store and deleting the +> vendored provider — see [`STORAGE-DECISION.md`](STORAGE-DECISION.md) and +> [`OWN-STORE.md`](OWN-STORE.md). Roughly sixteen of the findings below are +> **provider-imposed** and disappear with it; `OWN-STORE.md` § *Effects on the +> sync plan* lists them item by item. Everything platform-level (the targetSdk 34 +> sync gate, the stub adapter, credential storage, Play compliance) and everything +> protocol-level (discovery, RFC 6578, conditional PUT, conflict policy, the +> server-reality table) is unaffected and remains the plan. + +Scope: self-hosted CalDAV first (Nextcloud), F-Droid and Play, MIT license. + +--- + +## The plan, in short + +| Question | Direction | +|---|---| +| Where does task data live? | Our vendored `:provider` — **settled, shipped** | +| Who syncs it? | Agendula, via its own sync adapter | +| Account model | `AccountManager` **+ a real (stub) sync adapter** — ⚠️ the hybrid without one does not work | +| Scheduling | WorkManager, triggered *through* the sync framework | +| Protocol library | `dav4jvm` (MPL-2.0) — ⚠️ costs more than the first draft assumed | +| Self-signed certs | `cert4android` — ⚠️ **MPL-2.0, not GPLv3.** The first draft rejected it on a false premise | +| iCalendar | In-house mapper over `ical4j`; **no `synctools`** (GPLv3) | +| Recurrence | Read/write `RRULE`/`RDATE`/`EXDATE` directly — ⚠️ **not** the Instances table | +| Primary read path | ⚠️ `REPORT calendar-query` (VTODO filter, no time-range); `sync-collection` is the optimisation | +| Recurring completion | ⚠️ Accept all four models; `:provider` has **already chosen model (d)** for us | +| Sign-in | Nextcloud Login Flow v2 + generic CalDAV discovery + Digest | +| Conflict policy | `If-Match`; on 412 the server wins, local copy preserved | +| Reference implementations | jtx Board and DAVx5 — read, never link against (GPLv3) | + +| # | Phase | Deliverable | Effort | +|---|---|---|---| +| 0 | **Groundwork** | UIDs at creation, backup/prune safety, licence-attribution screen, Java-21 decision | 1 week | +| 1 | Mapper | VTODO ↔ `TaskContract`, unknown-property round-trip, fixture corpus | 2–3 weeks | +| 2 | Auth | Discovery, Login Flow v2, Digest, credential storage, cert trust | 1.5–2 weeks | +| 3 | Engine | `calendar-query` baseline, `sync-collection` optimisation, full reconciliation, conflicts, scheduling, **Local→Synced migration** | 5–6 weeks | +| 4 | Hardening | Per-server trap matrix, error UX, re-auth, Play compliance | 2–3 weeks | + +⚠️ **Revised upward from the first draft's 8–9 weeks to 11.5–15.** Phase 0 is new; +the migration in phase 3 was previously believed not to exist at all; and the +engine grew a second sync path plus a permanent reconciliation pass. + +**Calibration, for sanity:** Evolution shipped RFC 6578 in **June 2026** against +a request open since 2019. vdirsyncer has declined to implement it for twelve +years. Thunderbird still carries unlanded patches for one of its error paths. +This is not a phase that gets shortened by trying harder. + +--- + +## Settled — the storage question is not reopened here + +A later working draft re-argued storage as a fresh choice between *bundle the +dmfs provider* and *a Room-native store with a `TaskContract` facade*, and +recommended Room. Not adopted; this is the audit trail so it does not come back. + +The provider shipped in `f978c37`. Three of the arguments against it do not +survive contact with the repository: + +1. **"`synctools` is GPLv3 and propagates to the app."** Misattributed. + `:provider` depends on `jems`, `rfc5545-datetime` and `lib-recur` — all + Apache-2.0. `synctools` is a *mapper* choice, equally avoidable either way. +2. **"Cursor access everywhere; the widget pays the Calendula cost."** Calendula + queries `CalendarProvider` **cross-process**; ours is same-package, same-uid, + so `ContentResolver` returns the local provider instance — no Binder hop, no + `CursorWindow` marshalling. The observer→Flow seam already exists. +3. **"The dmfs schema can't hold what we need."** The `Properties` table takes + arbitrary mimetypes — `PropertyHandlerFactory.get` falls through to + `DEFAULT_PROPERTY_HANDLER`, and `PropertyHandler.insert` is a bare + `db.insert` with no validation, over sixteen real `data0`–`data15` TEXT + columns. That is the mechanism for unknown-property round-tripping, this + document's single most important correctness requirement. + +**But the audit also weakened the positive case.** The first draft claimed the +provider hands us the sync bookkeeping for free. It hands us *some* of it, with +sharp edges — see the next section. Room would have been the wrong trade anyway +(it buys Flow ergonomics we already have and costs the tombstone/instance +machinery we already run), but "for free" was too generous. + +--- + +## What the provider actually gives us + +⚠️ **This section is almost entirely rewritten.** Every line is verified against +`provider/src/main/java/`. + +| Mechanism | Reality | +|---|---| +| `CALLER_IS_SYNCADAPTER` | Works on `tasks` and `tasklists`. ⚠️ **Ignored on the `instances` URI** — `processors/instances/TaskValueDelegate` hardcodes `false` on every delegation | +| `_DIRTY` on user writes | ⚠️ **Set on insert/update only, never on delete.** `AutoCompleting.delete` skips `updateFields`; `TaskCommitProcessor.delete` sets only `_DELETED` | +| `_DIRTY` default | ⚠️ **Defaults to `1`** (`TaskDatabaseHelper:456`). Every downstream insert must write `_dirty=0` explicitly or it uploads straight back | +| `TaskLists._DIRTY` | ⚠️ **A monotonic counter, not a flag** — a trigger does `_dirty = _dirty + new._dirty + new._deleted` and nothing ever decrements it | +| `_DELETED` tombstones | ✅ Real. Soft-delete for account-backed tasks, hard delete for sync adapters *and* for the local account. Hidden from non-sync queries | +| Instances | ⚠️ **Materialises exactly one upcoming occurrence** (`UPCOMING_INSTANCE_COUNT_LIMIT = 1`), on write only, never re-expanded as time passes. Useless as a recurrence representation for sync | +| `_UID` | ⚠️ **Settable by anyone on insert** — restricted to sync adapters on *update* only. Never generated by the provider | +| `SYNC1`–`SYNC8`, `_SYNC_ID`, `SYNC_VERSION` | Exist and are free — but ⚠️ **`Moving` nulls all of them on a list move while keeping `_UID`**, leaving a live row and a tombstone sharing one UID | +| Per-collection sync state | ⚠️ **Does not exist.** The `SyncState` table is one blob **per account**, `db.replace`d | +| Read-only collections | ⚠️ **Cannot be represented.** `ACCESS_LEVEL` is inert — the contract says "not used yet", and `Validating.java:60` still carries upstream's `// TODO: ensure that the list is writable` | +| Account scoping on delete | ⚠️ The provider **requires** account params on a sync-adapter task delete and then **ignores them** (`TaskProvider.java:793` — upstream `// TODO`) | + +### The consequences, as rules + +1. **The upload query is `_dirty = 1 OR _deleted = 1`.** Deleting is not dirtying. +2. **Every downstream insert sets `_dirty = 0` explicitly.** +3. **Name the squats now**, because none of these columns exist: CTag → + `TaskLists.SYNC_VERSION`; per-task ETag → `Tasks.SYNC_VERSION`; href → + `Tasks._SYNC_ID`; per-collection sync-token → a `TaskLists.SYNC*` slot (the + `SyncState` blob is per-account and cannot hold it). +4. **Never write through the `instances` URI.** Read and write `RRULE`, `RDATE` + and `EXDATE` on `tasks` — they are raw TEXT and round-trip cleanly. +5. **Scope every adapter delete by `list_id` yourself.** +6. **Sequence DELETE before PUT on a list move**, or the two rows sharing a + `_UID` collide on the server. +7. **Read-only collections are enforced in the app layer**, not the provider. +8. **Soft-deleted subtasks lose their `Relation` rows** before the adapter sees + the tombstone (`Reparenting.unlinkParent` runs regardless of `isSyncAdapter`). + Either cache the relation or accept that `RELATED-TO` is unrecoverable on + delete. + +### Two collisions with the shipped app layer + +- **`AndroidTasksDataSource.setAlarm` deletes every alarm property row on the + task** before re-inserting. Once the adapter round-trips `VALARM`s, one local + reminder edit destroys all server-side alarms on that task. Decide ownership: + either the adapter owns `VALARM`s and the app stops bulk-deleting, or reminders + stay local-only and are never serialised. +- **`updateInstance` routes through the instances URI**, which per the table + above is permanently non-sync-adapter and forks override rows. Those overrides + arrive `_dirty=1` with no `_uid`/`_sync_id` and must be uploaded as + `RECURRENCE-ID` components. + +--- + +## ⚠️ The migration that was believed not to exist + +The first draft, `STORAGE-AND-SYNC.md` and `ARCHITECTURE.md` all asserted: +*"Synced is Local with an account attached, so switching on sync is not a +migration."* **That is false.** + +`processors/lists/Validating.java:68-76` throws on any attempt to change a task +list's `ACCOUNT_NAME` or `ACCOUNT_TYPE` — both are write-once, and the contract +documents them as such. Local lists live under `org.dmfs.account.LOCAL` and can +never be re-pointed at a real account. + +Turning on sync therefore means, as a real deliverable with its own tests: + +1. Create new lists under the account (sync-adapter insert; account params come + from the **URI**, never the values, and are frozen thereafter). +2. `UPDATE list_id` on every task — this *is* permitted, and it is the one path + the provider gives us. +3. Assign `_UID`s (or better: have them already, see phase 0). +4. Delete the old local lists (sync-adapter only). +5. Re-point `DEFAULT_LIST_ID`, per-list reminder overrides in DataStore, and + every scheduled alarm. + +Not modelling `SYNCED` as a third `StorageMode` is still right — it is derived +state. But the migration it implied away is real, and step 2's `Moving` processor +nulls `_sync_id`/`sync_version`/`SYNC1`–`SYNC8` and clones a tombstone as it goes. + +**Alternative worth considering:** don't migrate. Synced lists are always *new* +lists, and moving local tasks into them is an explicit user action with a visible +UI. Cheaper, more honest, and it never silently rewrites the user's data. + +--- + +## Account model — and the trap under it + +**Decision: `AccountManager` with our own account type, plus a registered +`` service whose entire job is to enqueue a WorkManager job.** + +⚠️ **The first draft's justification was circular** and its architecture did not +work. Both corrected: + +### The justification + +The draft argued AccountManager was *required* because the provider prunes lists +whose `ACCOUNT_TYPE` has no authenticator in this package. That is backwards — +`PROVENANCE.md` change 1 made the prunable set **empty by construction** +precisely so that shipping no authenticator prunes nothing. The provider imposes +no requirement at all. tasks.org proves the alternative: no AccountManager, Room +accounts, pure WorkManager, `GET_ACCOUNTS` stripped with `tools:node="remove"`. + +The real reasons, which are still good ones: a stable account identity that +third-party engines can address (the DAVx5 ask, open question 5), presence in +system Settings, and the sync framework as a change-trigger. + +### The trap ⚠️ + +`ContentService.hasAuthorityAccess()` gates `requestSync`, `setSyncAutomatically`, +`addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and seven more behind a +compat change `@EnabledAfter(TIRAMISU)` — **on for targetSdk ≥ 34**, which we +are. With no package registering a sync adapter for our authority, every one of +those calls **returns silently**: no exception, no log. It is documented on no +Android behaviour-changes page. + +So "AccountManager accounts for visibility + WorkManager for scheduling + no sync +adapter" — exactly what the first draft described — yields: + +- every `ContentResolver` sync API a no-op that passes on a Robolectric shadow, +- an account in system Settings permanently reading **"Sync off for all items"**, +- a **greyed-out "Sync now"**, because `enabledSyncNowMenu()` needs at least one + checked authority switch. + +**Fix:** register a real `AbstractThreadedSyncAdapter` whose `onPerformSync` +enqueues a WorkManager job and waits — DAVx5's own comment: *"We use the sync +adapter framework only for the trigger, actual syncing is implemented with +WorkManager."* Declare `READ_SYNC_SETTINGS` / `WRITE_SYNC_SETTINGS`. Ship an +in-app sync button too, since "Sync now" stays greyed out under +`userVisible="false"`. + +**Free trigger already running:** `TaskProvider.syncToNetwork()` returns `true` +unconditionally, so `SQLiteContentProvider` already fires +`notifyChange(uri, null, syncToNetwork=true)` on every user write. The system is +*already* requesting a sync for our authority on each edit — there is simply +nothing registered to receive it. + +### ⚠️ Auto Backup will arm `cleanUpLists` into a data-loss path + +`backup_rules.xml` and `data_extraction_rules.xml` are both **empty rule sets**, +and `allowBackup="true"`. An empty set means Auto Backup's default: databases +included. So the provider's `tasks.db` is backed up and restored — while +AccountManager accounts, which live in `/data/system_ce/`, are not. + +`TaskProvider.onCreate` registers `addOnAccountsUpdatedListener(…, updateImmediately=true)`. +On the first callback after a restore, `cleanUpLists` sees lists carrying our +`ACCOUNT_TYPE` with no matching account and **deletes them, and their tasks, +silently** — cascading through `task_list_cleanup_trigger`. Exactly the failure +`PROVENANCE.md` change 1 exists to prevent, reintroduced from behind. + +Latent today (no authenticator ⇒ nothing prunable); live the day phase 3 ships. + +**Do not fix this by excluding the database from backup.** That was the audit's +suggestion and it is wrong for us: Local-mode data lives in exactly one place, +and Auto Backup is currently its only automatic safety net — removing it to +protect *synced* lists would trade a latent bug for a live one. Fix it at the +cause instead: + +1. Make pruning **event-driven** — react to `AccountManager`'s account-removed + broadcast, not to "absent from the visible set". +2. Add a post-restore reconciliation that offers to **re-attach the account** + rather than deleting. +3. Exclude only the **Keystore-encrypted credential blob** from backup — a + restored ciphertext is permanently undecryptable, since Keystore keys are + non-exportable. +4. Device-verify: restore a backup onto a fresh device, confirm nothing is pruned. + +--- + +## VTODO ↔ `TaskContract` + +In-house, behind an interface, so the iCalendar library stays swappable. + +### Unknown properties: the one non-negotiable + +Any `X-` property, unrecognised component or parameter written by another client +**must survive a read-modify-write cycle unchanged.** Failing this silently +destroys other people's data and is invisible in our own UI. + +Storage exists: a `Properties` row with our own `unknown-property` mimetype and +the serialised property in `DATA0`. Verified: no validation rejects an unknown +mimetype, the only index is non-unique so repeats are fine, and there is no FTS +interaction (`updateFTSEntry` is called only from `CategoryHandler`). Note +`MIMETYPE` is *declared* `INTEGER` while holding strings — harmless under SQLite +affinity, but don't be alarmed by it. `Tasks.HAS_PROPERTIES` is never set by +anything; do not filter on it. + +⚠️ **But "byte-stable" is unachievable as the draft stated it, and stating it that +way is dangerous** — the corpus would fail on day one and then be normalised +until the only thing that matters, *no unknown property is dropped*, is no longer +tested. Six independent reasons a faithful implementation cannot be byte-identical: +`PRODID` **must** change (emitting another product's is a lie); fold position +carries no information and a line may split between any two characters; parameter +quoting is optional (`TZID=Europe/Berlin` ≡ `TZID="Europe/Berlin"`); property +order within a component is unconstrained, and storing known fields as columns +destroys the original interleaving **by construction**; `DTSTAMP` is regenerated +and `VTIMEZONE` re-emitted; and the server will not return what we sent anyway. + +**Restate it as a semantic round-trip with byte-stable property values.** Re-parse +both sides into a canonical multiset of +`(component path, property name, params as a sorted map, unfolded unescaped value)` +and assert equality **modulo an explicitly enumerated allowlist** — `PRODID`, +`DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`, VTIMEZONE bodies, fold positions, +parameter quoting. Nothing else may differ. Byte-equality is then asserted where +it means something: the **unfolded, unescaped value octets** of every untouched +property, plus full parameter preservation including unknown parameters. RFC 5545 +§3.1 is the requirement being encoded: *"Applications MUST preserve the value data +for x-name and iana-token values that they don't recognize."* + +⚠️ **And the requirement is necessary but not sufficient.** A client that +round-trips perfectly, passing every fixture, still destroys the owner's data if +it PUTs back a body Nextcloud filtered on the way out (see +[Server reality](#️-server-reality--the-matrix-audited)). Never write back a body +whose ETag does not match the hash of what we downloaded. + +**Three gaps in the storage sketch.** A flat property-per-row model does not +represent unknown properties **nested inside a known sub-component** (an `X-` prop +on a `VALARM`), or entirely unknown components, or RFC 9074's alarm properties +(`ACKNOWLEDGED`, `PROXIMITY`) which are what other clients now write. Decide +between an opaque sub-component blob and reconstructing nesting from +`DATA0`–`DATA15`. And **set a size cap** — DAVx5 drops unknown properties above +~25 kB — for two reasons: Android's `CursorWindow` row limit, and +`CALDAV:max-resource-size`, whose violation is a failed PUT. + +**Test-first.** The corpus comes before the mapper, and the fixtures that catch +bugs are the adversarial ones, not a clean server-generated VTODO: a +`CLASS:CONFIDENTIAL` task fetched from a **shared** Nextcloud calendar; a resource +carrying a master plus `RECURRENCE-ID` overrides; unknown properties nested inside +a `VALARM`; a `TZID` the device's tzdb does not know; a UID containing `/` and +`@`; and one exceeding `max-resource-size`. + +### The rest of the minefield + +- `DUE` vs `DTSTART`; `VALUE=DATE` vs `DATE-TIME`; floating times and `TZID`. + Match the all-day/UTC convention `fix/provider-interaction-review` established. +- **`STATUS` / `PERCENT-COMPLETE` / `COMPLETED` disagree across clients.** Pick a + canonical reading, normalise on write only. Local convention to reconcile + against: the edit form writes `PERCENT_COMPLETE` clamped 0–100 and leaves + `STATUS` to the complete toggle. +- `RELATED-TO` for subtask trees, including orphans in another collection. The UI + nests one level; the *data* must not assume it. +- `VALARM` — see the `setAlarm` collision above before writing a line of this. +- `CATEGORIES`, `PRIORITY` (**0 = undefined, 1 = highest**). +- ⚠️ **`SEQUENCE` is preserve-verbatim, not ours to bump.** It is the *Organizer's* + revision counter (§3.8.7.4); a client that increments it on every save confuses + scheduling-aware peers. Related: **`DTSTAMP` is regenerated per serialisation, + `LAST-MODIFIED` changes only when the data actually did.** Conflating them makes + every sync look like an edit. +- ⚠️ **`COMPLETED` MUST be UTC** (§3.8.2.1) — no TZID, no floating, no DATE. +- ⚠️ **A `VALARM` with `TRIGGER;RELATED=END` requires `DUE`, or `DTSTART` plus + `DURATION`** (§3.8.6.3). A user clearing the due date on a task that has an + end-relative reminder produces an invalid resource, permanently rejected. Validate + before PUT — this is reachable from ordinary UI actions. +- ⚠️ **`RELATED-TO;RELTYPE` reads backwards to most implementers.** §3.8.4.5: + `PARENT` means *the referencing component is subordinate to the referenced + one*. Both Nextcloud Tasks and tasks.org put `RELTYPE=PARENT` on the child + pointing up — that is the correct reading. Also: the RFC explicitly disclaims + cascade semantics, so "completing a parent completes its subtasks" is a local + UI convention peers will not reproduce. +- ⚠️ **Round-trip `X-MOZ-LASTACK` / `X-MOZ-SNOOZE-TIME` unmodified.** Dropping + them causes documented **alarm storms** on Thunderbird. Emit RFC 9074 + `ACKNOWLEDGED` for our own writes rather than minting `X-MOZ-*`. Preserve + `X-APPLE-SORT-ORDER`; never interpret it. + +### ⚠️ Recurring completion — and the model our provider already chose + +The draft said "no standard; choose one". That is **confirmed and understated**, +and the choice is less free than it looked. + +**How settled the non-standard is.** `draft-ietf-calext-ical-tasks-17` — the +active IETF work item whose entire purpose is extending VTODO, `Updates: RFC5545` +— contains the substring **"recur" zero times** in 1,904 lines. It adds +`SUBSTATE`, `REASON`, `TASK-MODE` and a `VSTATUS` component and leaves this +untouched. RFC 8984 §5.2.6 (JSCalendar) is the only RFC that addresses it at all, +and it **blesses two mutually incompatible approaches and declines to pick**. +RFC 5545 permits `COMPLETED` on a recurring master with no interaction rule — +undefined, which is worse than forbidden, because every client picks differently +and all stay conformant. + +The four models in the wild: + +| | Model | Who | +|---|---|---| +| **a** | Write a `RECURRENCE-ID` override; master stays open | jtx Board, Thunderbird, eM Client | +| **b** | Advance the master's `DUE`/`DTSTART` in place, clear completion | tasks.org, Evolution, Nextcloud Tasks *in practice* | +| **c** | `STATUS:COMPLETED` on the master — kills the series | Nextcloud Tasks ≤ 0.17. **Always a bug** | +| **d** | Detach the completed occurrence as a **new task with a new UID**, and advance the master | **OpenTasks — i.e. our `:provider`** | + +**The finding that matters to us: `:provider` has already chosen model (d).** +`processors/instances/Detaching.java` nulls `_UID`, `_SYNC_ID` and every +`ORIGINAL_INSTANCE_*` on the detached row, and `detachAll` advances the master +and decrements `RRULE;COUNT`. dmfs did this deliberately — on the record, *"the +primary reason is to support Apple clients; they don't support overrides"*. So +our storage layer emits a UID-less orphan plus a moved master, and our sync +adapter has to either honour that, bypass the processor, or reconcile after it. +**That is a design constraint we inherited without deciding it**, and it is the +strongest single argument for treating this as a phase-1 decision rather than a +phase-4 detail. + +**Rules the audit establishes, regardless of which model we write:** + +- **Never write model (c).** Every instance found was filed as a defect; + Thunderbird fixed it fifteen years ago. +- **Never do (a) and (b) together.** `RECURRENCE-ID` is *defined* as the + instance's original `DTSTART`, so advancing the master orphans your own + override. Nextcloud Tasks 0.18 attempts exactly this — and is saved only by an + accident: its override write dispatches a Vuex action that **does not exist**, + which Vuex 4 swallows without throwing. Shipped behaviour is therefore silent + model (b) with no record the instance was ever completed. +- **Accept all four models on read, unconditionally**, including an inbound + master whose `DUE` moved and whose `COMPLETED` vanished. That is not corruption. +- **Never abort a sync batch on a multi-VTODO resource.** tasks.org returns from + its whole sync function on one — so a single Thunderbird-completed recurring + task **stops that entire collection from syncing**, ctag never advances, and + every other change in the batch is silently lost. Degrade to the master and + continue. +- **Keep overrides in the same calendar object resource** (RFC 4791 §4.1). +- ⚠️ **`RRULE` + `DUE` with no `DTSTART` has no well-defined `RECURRENCE-ID` + value** — undefined in RFC 5545, ubiquitous in the wild. Synthesising + `DTSTART := DUE` is the common workaround and is itself the source of visible + DTSTART/DUE desync between clients. Handle it explicitly. +- ⚠️ **Repeat-from-completion has no interoperable encoding at all.** Either drop + it or document that peers read it as repeat-from-due. +- Consider tasks.org's escape hatch: a per-account **"let the server schedule + recurring tasks"** switch. + +Open question 4 — but now with a default: **write (a) if we own the whole path; +accept that `:provider`'s `Detaching` pushes us toward (d) unless we bypass it.** + +> **Process note.** During this research a summarising fetch **fabricated a +> verbatim RFC 5545 sentence** ("A 'to-do' calendar component without the +> 'dtstart' property MUST NOT be part of a recurring set") that appears nowhere +> in the RFC — grep confirms zero hits — along with an invented DTSTART/DUE +> exclusivity rule. Normative text gets read from the raw RFC, never from a +> summary. Both fabrications would have inverted a design decision here. + +--- + +## Libraries + +⚠️ **The first draft's table had one outright licence error and understated two +libraries by roughly an order of magnitude.** Re-verified 2026-08-13 against +published POMs, Gradle module metadata and extracted jars. + +### Take + +| Library | Licence | Real cost | +|---|---|---| +| **dav4jvm** 4.0.1 | MPL-2.0 | ⚠️ **Ktor-only** (the OkHttp package was deleted in 3.0.0); ⚠️ **requires Java 21 bytecode** — we target 17 everywhere, including all seven floret-kit modules; pulls Ktor (~2.45 MB), `guava-jre` (wrong flavour — force `-android`), and `xpp3` (371 KB, duplicates framework `org.xmlpull.v1`). The library itself is only 433 KB / 246 classes | +| **cert4android** | ⚠️ **MPL-2.0 — not GPLv3** | Same org, same licence, same JitPack question as dav4jvm. See below | +| **ical4j** 4.3.0 | BSD-3-Clause | ⚠️ Not "needs desugaring" — `java.time` is native at minSdk 26 and we are 29. Real costs: **2.2 MB of duplicated tz data** in the jar (`zoneinfo/` *and* `zoneinfo-global/`, ~596 `.ics` each), a `ZoneRulesProvider` that pre-allocates 1500 synthetic zone IDs **and exhausts in production**, and a mandatory `ical4j.properties` + `MapTimeZoneCache` + registry shim | +| **lib-recur** | Apache-2.0 | ⚠️ **Version trap, see below** | + +### ⚠️ cert4android was rejected on a false premise + +The first draft listed it as GPLv3 and budgeted a hand-rolled trust-on-first-use +dialog instead. **It is MPL-2.0** — verbatim MPL text in `LICENSE`, SPDX +boilerplate in the README, GitHub agrees. The same licence as dav4jvm, which the +same document accepts two rows above. Two independent audit tracks caught this. + +That matters because the hand-rolled version is not a dialog: + +- **A background sync has no UI to show a dialog in.** cert4android's bound + service + notification approval *is* the library, not an accessory to it. +- **Network Security Config cannot express runtime trust** — it is a static + manifest resource. And since API 24, user-installed CAs aren't trusted without + an NSC entry, so "tell the user to install their CA" fails too. +- **The 3-arg `checkServerTrusted(chain, authType, host)` is mandatory**; + 2-arg-only TrustManagers have repeatedly broken on OkHttp. +- **Hostname verification is a second override** and the other thing Play flags. +- **Play has blocked publishing on unsafe `X509TrustManager` since 2016**, and + the guidance explicitly names "buggy or incomplete custom verification". + +This roughly dissolves the self-signed-cert line item in phase 4. + +### ⚠️ lib-recur is a version trap, not a free dependency + +The first draft said "already in the build at 0.12.2 — no new dependency". Both +halves are wrong. `provider/build.gradle.kts:56` declares it `implementation`, not +`api`, so it is **not** on `:app`'s compile classpath. And lib-recur **0.16.0 +removed `RecurrenceSet`**, which the vendored provider uses in +`TaskInstanceIterable`/`TaskInstanceIterator`. The moment `:app` adds a current +lib-recur, Gradle's highest-wins resolution upgrades the graph and **the provider +stops compiling.** + +Decide explicitly: pin `strictly = "0.12.2"` and accept the known fixes we forgo +(0.15.1 `FastForwarded`, 0.15.2 empty-`ByDay`), or budget the iterator rewrite +onto the post-0.16 API. Note upstream is dormant — last release 0.17.1, last +commit 2024-04. + +### Consider + +**biweekly** (BSD-2) is stronger than the first draft credited: 639 KB / 432 +classes, **no bundled tz database at all**, legacy `java.util.Date` so no +`ZoneRulesProvider` hazard — against ical4j's 2.2 MB of zone data and its +registry shim. Caveats: last release 0.6.8 (2024-01), still 0.x, mandatory +`jackson-core` for jCal (excludable), and the missing tz database means it relies +on `VTIMEZONE` components being present rather than resolving `TZID`s itself — +a real gap for CalDAV round-tripping. + +### Do not take + +| Library | Why not | +|---|---| +| **synctools** | **GPLv3.** Does exactly the mapping we need against exactly the schema we run, which makes it the sharpest temptation here. Still a one-way door. (Repo now archived and folded into `davx5-ose` as a module — the standalone coordinate is stale.) | +| **Android-SingleSignOn** | ⚠️ **GPL-3.0.** The first draft carried it as a harmless optional extra; it is the actual one-way door. It also proxies through the Files app and supports only OCS plus a few WebDAV verbs — not a CalDAV transport | +| **caldav4j** | Apache-2.0, but server-oriented, last release 2022-01 | +| **sardine** | Needs JAXB — a non-starter on Android | + +**Still true:** there is no mature Kotlin-native iCalendar library, and no +Android-suitable CalDAV client on Maven Central at all. That is *why* the JitPack +question is unavoidable rather than optional. + +### On GPL and Play + +The rejection reasoning is right in effect but was imprecise. MIT **is** +GPL-compatible; the constraint is on the terms of the distributed binary, not on +our source headers. And ⚠️ **GPLv3 is not a Play problem** — DAVx5 is GPLv3 and +ships on Play with 287k+ installs; §6 Installation Information is a hardware +provision. If the real reason is wanting Agendula to stay permissively +relicensable, say that, because that is the reason that holds. + +### ⚠️ Licence obligations we cannot currently discharge + +Settings exposes only our own MIT `LICENSE`. There is no third-party attribution +surface and no AboutLibraries in the build. But MPL-2.0 §3.2(a) requires telling +recipients how to obtain source; §3.4 requires retaining file headers; **BSD-3 +requires reproducing the copyright notice in binary distributions** (that is +ical4j, and it is not optional); Apache-2.0 §4(d) propagates NOTICE. Shipping any +of these without an attribution screen is a plain violation, independent of +copyleft. **Phase 0 work item.** Bonus trap: ical4j's POM declares a non-SPDX +licence name and a `LICENSE` URL that 404s, so generators produce empty output. + +### The JitPack question, restated + +`dav4jvm` and `cert4android` are both `com.github.bitfireAT:*` — JitPack only. +F-Droid's inclusion policy does trust jitpack.io for freely-licensed artifacts, +so **F-Droid is not the obstacle; our own `FAIL_ON_PROJECT_REPOS` policy is.** +But F-Droid's own writing is lukewarm — JitPack "hosts whatever is built from +GitHub, without checking the license" — and concretely: JitPack **does not sign +artifacts** (`.asc` 404s; Maven Central's does not), and rebuilds on demand, so a +coordinate is not immutable. We have no `verification-metadata.xml` today. + +Weigh against that: **dav4jvm shipped two breaking majors nineteen days apart** +(3.0.0 OkHttp→Ktor 2026-07-08; 4.0.0 callbacks→coroutines 2026-07-27), which +argues for vendoring a known-good tree rather than a floating pin — and at 433 KB +vendoring is far cheaper than depending, once the Ktor/guava/xpp3 tail is counted. +Open question 1. + +--- + +## Authentication and discovery + +### Nextcloud Login Flow v2 + +The protocol description survives audit against the server source: the endpoint, +the `{poll:{token,endpoint},login}` shape, 404-until-approval, the 20-minute +lifetime (`lifetime = 1200` in `LoginFlowV2Mapper.php`), and "the 200 is returned +exactly once" (the mapper deletes the row inside `poll()` before returning). It is +not deprecated, there is no v3, and OAuth2 is a worse fit. Corrections: + +- ⚠️ **Poll with `POST`, form-encoded.** A `GET` gets 405. The draft didn't say. +- ⚠️ **Set an explicit `User-Agent`.** `init()` passes it to `createTokens()`, + where it becomes the app password's **name** in Settings → Security → Devices & + sessions. With OkHttp's default the user sees `okhttp/4.12.0` and cannot tell + what to revoke — defeating the entire point of the flow. (`OCS-APIRequest` is + *not* needed here; v2 is a Frontpage route.) +- ⚠️ **404 only means pending.** "Treat anything that isn't a 200 as pending" + swallows 429 (brute-force protection), 503 (maintenance), Cloudflare challenge + pages (200 with HTML), and DNS/TLS failure — turning a diagnosable error into a + 20-minute spinner. Require `Content-Type: application/json` before parsing. + Stop polling on anything that is neither 404 nor 200. Note 404 is *also* + returned for expired/consumed, so keep tracking the deadline locally. +- ⚠️ **Validate the `endpoint` origin.** Verbatim is right for the *path*, wrong + as a blanket rule: it is generated from `overwrite.cli.url` / `overwriteprotocol` + / `trusted_proxies`, misconfigured on a large fraction of self-hosted installs. + Refuse a scheme downgrade to `http` outright — the poll token is exchanged for a + long-lived app password, so this is a credential-grade secret. If the host + differs from the one the user typed, confirm explicitly and say *"your server's + `overwrite.cli.url` is wrong"*, which saves a support round-trip. (The draft's + "some deployments return 302" is a proxy symptom, not a Nextcloud variant.) +- ⚠️ **`loginName` is not the uid.** It is what the user typed — possibly an + email, an LDAP-derived value, or the right name in the wrong case. Use it + **only** as the Basic auth username; never interpolate + `remote.php/dav/calendars//`. Discover via `current-user-principal` + → `calendar-home-set`, exactly as the generic path already does. This is the + classic "logged in but no calendars" bug. +- ⚠️ **Custom Tabs needs four things the draft omitted:** a `` entry for + `android.support.customtabs.action.CustomTabsService` (or provider detection + silently fails on API 30+); a try/catch with an `ACTION_VIEW` fallback + (`launchUrl` throws `ActivityNotFoundException` with no Custom Tabs browser — + realistic on GrapheneOS/CalyxOS/AOSP, i.e. disproportionately our users); + persistence of `{token, endpoint, deadline}` to disk immediately, so process + death mid-flow is resumable; and an explicit "I finished / Cancel" affordance, + since Custom Tabs return **no result** when dismissed and Nextcloud's flow ends + on a "you can close this window" page that never returns to the app. + +### Generic CalDAV discovery + +⚠️ **The draft's five steps were the happy path of a much longer pipeline, and +one of its two filters was inverted.** Corrected version, with live probes run +2026-08-13: + +``` +1. Input: email / mailto: / http(s) URL +2. Base URL typed → PROPFIND Depth:0 on it first (principal, home-set and + collection can all come back in one response) +3. Else: + a. SRV _caldavs._tcp. — honour RFC 2782 priority/weight, + honour non-443 ports, target "." = none + b. TXT _caldavs._tcp. — parse path= ⚠️ MISSING FROM DRAFT + c. ladder: [TXT path] → /.well-known/caldav → / ⚠️ "/" MISSING +4. PROPFIND Depth:0 for DAV:current-user-principal + - follow 301/302/303/307/308, re-sending PROPFIND and its body + - relative Location; reject HTTPS→HTTP; cap at 5; PERSIST 301/308 + - 401 → authenticate and retry. NOT a failure ⚠️ MISSING + - reject ⚠️ MISSING + - OPTIONS gate: DAV: header must contain calendar-access ⚠️ MISSING +5. PROPFIND Depth:0 on the principal for calendar-home-set + → iterate ALL hrefs (0..n); cross-host is normative ⚠️ DRAFT ASSUMED ONE +6. PROPFIND Depth:1 per home set, requesting properties BY NAME + → optionally recurse one level into plain {DAV:}collection members +7. Classify: + a. resourcetype as a SET; require CALDAV:calendar ⚠️ MISSING + b. VTODO test: property ABSENT ⇒ INCLUDE ⚠️ DRAFT INVERTED IT + c. privilege-set absent ⇒ assume writable; handle 403 on write +8. sync path: supported-report-set → RFC 6578 keyed on DAV:sync-token +9. Creation: OPTIONS feature-detect → MKCALENDAR / extended MKCOL / disable UI +``` + +**The two filter corrections, which are the important part:** + +- ⚠️ **`supported-calendar-component-set` absent means "supports everything", + not "supports nothing".** The draft kept only collections whose set *includes* + VTODO, which silently drops every server that doesn't advertise it. RFC 4791 + §5.2.3 also says the property SHOULD NOT come back from an allprop request — + so **request properties by name**, or you get none of them. Its grammar is + `(comp+)`; an empty element is non-conformant, and `dav4jvm`'s parser starts + all-`false` and would classify it as supporting nothing. Treat empty as all. +- ⚠️ **Classify on `resourcetype`, with a positive test.** The draft had no + resourcetype check at all, so a Depth:1 listing on Nextcloud yields inboxes, + outboxes, notification collections, trash bins and subscriptions as "task + lists". The test must be **`CALDAV:calendar` is present in the set** — *not* + exclusion by `schedule-outbox`, because **SOGo's main personal calendar reports + `collection` + `calendar` + `schedule-outbox` simultaneously** for every + non-Apple client, which is exactly what we are. The positive rule also keeps + shared calendars (which add `CS:shared` alongside `CALDAV:calendar`) and drops + Nextcloud's `nc:deleted-calendar`, which deliberately strips `caldav:calendar`. + Treat resourcetype as an **unordered set**, never by position. + +**Discovery traps, live-probed:** + +| Trap | Detail | +|---|---| +| SRV TXT `path=` | Live at **Posteo** (`path=/`), **GMX** and **Web.de** (`path=/begenda/dav/users/`). Skip it and GMX/Web.de land on the wrong path | +| Posteo | **SRV-only**, on port **8443**; its `/.well-known/caldav` 404s. Hardcoding 443 fails | +| Null SRV target | `_caldav._tcp.fastmail.com` and `runbox.com` return `0 0 0 .` — "explicitly unavailable" | +| Google SRV | Returns a valid record pointing at `calendar.google.com`, which **is not a DAV server** (PROPFIND → 405). A strict RFC 6764 client follows it into a dead end for every `@gmail.com` | +| well-known 401 | **iCloud and Zoho** answer 401 — the endpoint *is* the DAV root and wants auth. RFC-legal; must not be read as failure | +| Redirect downgrade | `dav.runbox.com` redirects **HTTPS→HTTP** in production today | +| Method preservation | A generic HTTP stack may legally downgrade 301/302 to GET, silently breaking PROPFIND | +| `` | RFC 5397 §3 — a **200** whose body means auth failed. Without this check a failed login looks like a successful discovery that found nothing | +| Cross-host home set | Normative (RFC 4791 §6.2.1's own example), and iCloud depends on it: principal on `caldav.icloud.com`, home set on `pNN-caldav.icloud.com`. Allow it, require HTTPS, surface the host change, never send credentials into an unvalidated redirect chain | +| `caldav.fastmail.com` | `d.fastmail.com` is dead — cert mismatch | + +⚠️ **Two `dav4jvm` defects we would inherit:** it handles 301/302/307/308 but +**not 303**, which RFC 6764 §5 names explicitly; and issue #209 — `location` is +mutated in place, so **permanent redirects never reach the caller**. DAVx5 never +rewrites its stored collection URL after a 301 and re-follows on every sync. +Persist the new URL ourselves on 301/308. + +**Read-only detection is softer than the draft assumed.** RFC 3744 §3.7 defines +a `DAV:read-current-user-privilege-set` privilege, so a server may legally return +`current-user-privilege-set` in a **403 propstat**. Default to writable when it is +absent (as DAVx5 does), expand aggregates yourself (`DAV:write` and `DAV:all` +imply content-write; some servers don't expand), and prefer sabre's +`{DAV:}share-access` where offered as the cleanest signal. + +**Creating lists is not always possible.** MKCALENDAR is only *RECOMMENDED* by +RFC 4791 §5.3.1. **iCloud is MKCOL-only** (and MKCOL under `…/calendars//` +returns 412 while `…///` returns 201); **Google has neither**; +**Posteo disables it** despite running sabre. Feature-detect via OPTIONS and +**disable the "new task list" UI** where neither is available. Set +`supported-calendar-component-set` **at creation — it is protected afterwards**, +and follow up with an explicit PROPPATCH for `displayname`, which most servers +ignore in the MKCALENDAR body. + +**Two more VTODO viability landmines:** Zimbra and OX/mailbox.org restrict tasks +to dedicated task lists (MKCALENDAR with `[VTODO]`), and **OX rejects recurring +VTODOs with 400**. And **server-side `CALDAV:expand` on VTODO is broken on +Nextcloud, Baïkal, SOGo, Radicale and Posteo alike** — expand client-side, which +`lib-recur` already gives us. Request `max-resource-size` too: violating it is a +failed PUT, and long `DESCRIPTION`/`ATTACH` payloads reach it. + +⚠️ **`getctag` is not a cheap pre-check** — `caldav-ctag-03` deprecated it in +2015 in favour of RFC 6578, and `DAV:sync-token` is itself PROPFIND-able, so the +same pre-check comes back in the Depth:1 listing we already make. On Nextcloud +they are literally the same value. Keep `getctag` only as a legacy fallback where +`supported-report-set` omits `sync-collection`. + +⚠️ **Auth is not just Basic:** + +- **Baïkal defaults to Digest** (`dav_auth_type`), and **OkHttp has no Digest + support** — square/okhttp#205 has been open for years. DAVx5 carries a + hand-written `BasicDigestAuthHandler` in dav4jvm precisely for this. Take it + (MPL-2.0, same decision as above) or detect the `WWW-Authenticate: Digest` + challenge and emit a real error instead of "wrong password". Baïkal is squarely + in our target audience. +- **Send Basic preemptively via an `Interceptor`**, gated to HTTPS and the + account's own origin. OkHttp's `Authenticator` is reactive-only — an extra round + trip on every request of a PROPFIND-heavy sync, and it never fires at all on + servers that answer 403/404 without a challenge. Note OkHttp strips + `Authorization` on cross-host redirects (correct, but it breaks `.well-known` + discovery across hosts — re-attach only after validating the target). +- **Fastmail requires an app password** and its Basic plan has no CalDAV at all. + **iCloud requires an app-specific password** and 2FA to mint one. **Google is + OAuth2-only** — refuse it with an explanation rather than a 401. Detect these + by domain at account-add time; "wrong password" that is actually "you used your + account password" is the single most common support ticket any CalDAV client + inherits. + +### Credential storage, rotation, revocation + +⚠️ `androidx.security:security-crypto` is not "effectively stalled" — it is +**formally deprecated and terminal**: deprecated at 1.1.0-alpha07 (2025-04), +shipped deprecated in stable 1.1.0 (2025-07), with release notes saying there +will be no subsequent releases. Its successor `datastore-tink` is alpha only. + +⚠️ And the alternative is weaker than implied: **AccountManager stores passwords +as plain `TEXT`** — no encryption or hashing anywhere in AOSP. FBE plus a +same-signature check is the whole boundary. That is DAVx5's actual posture and is +defensible, but state it rather than implying it is secure storage. + +**Decision:** Keystore `AES/GCM/NoPadding`, blob in DataStore. +`setUserAuthenticationRequired(false)` is the default — don't call it. Do **not** +set `setUnlockedDeviceRequired` (breaks background sync). Handle +`AEADBadTagException` / `KeyPermanentlyInvalidatedException` as *re-authenticate*, +not as a crash. Note `getUserData` returns null while the device is locked, so a +boot-triggered sync must wait for unlock. + +⚠️ **Revocation is bidirectional and the draft had neither direction:** + +- **On 401: stop syncing that account immediately**, mark `NEEDS_REAUTH`, notify + with a deep link into the login flow, and **do not retry on a timer**. + Nextcloud's brute-force protection throttles then 429s **per source IP** — a + retry loop on a dead app password takes down the user's *other* Nextcloud + clients on that network and looks like we broke their server. App passwords do + die in the wild (password change, admin revocation, server bug #39615). + Distinguish 401 (re-auth) from 403 (forbidden, do not re-auth) from 429/503 + (back off, honour `Retry-After`). Nextcloud returns 401 with + `PasswordLoginForbidden` when 2FA is on and a real password was used — worth + detecting for a precise message. +- **On account removal: call `DELETE /ocs/v2.php/core/apppassword`** + (this one *does* need `OCS-APIRequest: true`), best-effort. Otherwise + uninstalling never revokes access, and orphaned entries accumulate that the user + cannot identify — see the User-Agent point above. + +--- + +## The sync engine + +- Collection discovery and refresh; per-collection sync state (in a `TaskLists` + `SYNC*` slot, per the squat table). +- ⚠️ **Baseline is `REPORT calendar-query` with a VTODO comp-filter and *no* + time-range**, matching DAVx5 — which deliberately does not use RFC 6578 for + tasks, and omits the time-range *"because some servers don't return tasks + without time at all"*. `sync-collection` is the **optimisation on top**, not + the primary path. The draft had this the wrong way round. +- CTag / sync-token loop (RFC 6578 `sync-collection`) where it works. ⚠️ **The + full-reconciliation path is not a fallback for weak servers — it is a permanent + safety net on every server**, because a pruned change log behind a still-valid + token is undetectable (below). +- Local change detection: `_dirty = 1 OR _deleted = 1`, scoped by `list_id`. +- `If-Match` conditional PUT. +- Backoff and partial-failure recovery. A failed collection must not fail the + account. + +### ⚠️ RFC 6578, and where every shipping client has bugs + +The audit read RFC 6578 in full (no errata, fourteen years on) plus the +w3c-dist-auth threads that are its only authoritative gloss. Calibration first: +**Evolution shipped `sync-collection` in June 2026** against a request open since +2019; **vdirsyncer has declined it for twelve years**; **Thunderbird still has +unlanded patches** for one of the cases below. The library covers about a third. + +**⚠️ Note DAVx5 does not use RFC 6578 for tasks at all** — its own documentation +says *CalDAV tasks: use `REPORT calendar-query`*, because a collection +advertising both VEVENT and VTODO would stream every event change and force a +fetch to discover it isn't a task. That is a real argument for making +`calendar-query` our primary path and `sync-collection` the optimisation. + +1. **Invalidation has no status code.** §3.2 defines the `DAV:valid-sync-token` + precondition and never assigns an HTTP status. Observed: **403** (sabre ⇒ + Nextcloud, ownCloud, Baïkal, and Radicale 3.1.8), **400** (Google, CalDAV and + CardDAV), **409** (Radicale, per its maintainer), **412** (accepted by + Evolution). One server family, two codes across versions. + **Rule: ignore the status; match `` anywhere in the body + on any 4xx.** Thunderbird's CardDAV code accepts only 400 and therefore never + recovers from the 403 that most of the self-hosted world emits. +2. **Initial sync must not report deletions** (§3.4), so a forced full resync + cannot learn what was deleted. **Mark-and-sweep is mandatory** — and the + `initialIncomplete` flag must be persisted *alongside* the token, or a resumed + partial sync sweeps against an incomplete "present remotely" set and **deletes + live data**. +3. **⚠️ Persist the token only after the bodies are applied.** The RFC's own + Appendix B gets this backwards — it associates the new token with the + collection *first*, then fetches. Death in between loses those changes + permanently. Under WorkManager, process death mid-sync is routine, not + exotic. Persist per page, after step 5, atomically with `initialIncomplete`. +4. **Worse than invalidation: a token the server still accepts over a change log + it already pruned.** Returns 207, zero changes, "you're current" — no error, + no recovery, and **RFC 6578 provides no signal for it.** Nextcloud's + `totalNumberOfSyncTokensToKeep` defaults to 10,000 and its own admin manual + warns this "will lead to premature data deletion and synchronization + problems"; Baïkal #1140 has shipped an empty change set *forever* since 2022. + **The only mitigation is periodic full reconciliation** (PROPFIND `Depth: 1` + + ETag diff) on a slow cadence regardless of the token. +5. **Truncation: detect the 507 on the SELF href, not the error element.** + §3.6's `DAV:number-of-matches-within-limits` is a SHOULD and sabre omits it + entirely. Distinguish it from a **507 as the outer HTTP status**, which means + your `DAV:limit` could not be honoured — retry without the limit, don't page. + iCloud emits a SELF response with status **200**; ignore that one. + **Do not send `DAV:limit`** — Nextcloud regressed it to a localised HTML error + page in 28.0.10/29.0.7/30.0.0. Cap by bytes client-side instead, and still + implement 507 handling. Add an iteration cap **and** a no-progress guard: the + RFC never requires the token to advance, and an unchanged token spins forever. +6. **`supported-report-set` is a hint, not a contract.** Radicale advertised + `sync-collection` for years without implementing it; Cyrus 3.8 advertises it + and rejects the mandated empty token. A 207 with no `` must + **degrade to PROPFIND, not throw**. +7. **Tokens are opaque.** §3.2 says they MUST be URIs; Google, iCloud, fruux + (`0`), and grommunio all violate it. Never parse or validate. And **never + reuse a token across collections** — sabre validates only the prefix, then + returns a wrong-but-plausible delta with no error. Key by + `(accountId, collectionUrl)`. +8. **Deletion is `404` at *``* level.** A 404 + inside a `` is a missing *property* on a resource that exists. + Confusing the two nesting levels deletes live data. +9. **Three membership edge cases** (§3.5): create-then-delete between syncs is + reported as removed, so the delete handler must no-op on an href it has never + seen; delete-then-recreate at the same URI is reported as **changed**, so href + identity is not UID identity — re-read the UID from the body; and **ACL churn + may be reported as removal**, so toggling a share can look like mass deletion. + Apply a sanity threshold before acting on a large delete batch. +10. **`sync-collection` never reports collection property changes.** §3.5.1 keys + "changed" on an entity tag, and a calendar collection has no entity body. So + displayname, colour and **read-only status can only be refreshed by PROPFIND + on the home set** — and on sabre, `CalendarHome` does not implement + `ISyncCollection` at all, so there is no sync-collection there to use. + DAVx5 has this exact gap open as its own bug. +11. **`calendar-data` inside `sync-collection` is sanctioned by neither RFC.** + RFC 4791 §9.6 says it "is not a WebDAV property"; it works on sabre only + because that codebase exposes it as one by explicit accident. Request + `getetag` + `resourcetype`, then batch `calendar-multiget` — **and match the + returned hrefs against what you asked for**, because real servers reply with + responses for unrelated URLs. +12. **`getctag` is formally deprecated** by `caldav-ctag-03` in favour of this + REPORT — and every shipping client still keeps it as a fallback. Do the same, + but never compare a ctag to a sync-token. + +**What `dav4jvm` actually gives us:** spec-correct serialisation, `Depth: 0`, +`"infinite"` spelled right, a streaming `Flow` with the token arriving as an +`ExtraProperty`, and typed exceptions. **What it does not:** the truncation loop, +507 detection, any `valid-sync-token` handling (its `Error.kt` says outright +*"there is no logic for subclassing errors"*), mark-and-sweep, `initialIncomplete` +persistence, or multiget orchestration. And its error extraction only parses XML +content-types within a 20 KB excerpt at depth 1 — so a `` served as +`text/html`, or buried behind a PHP stack trace (exactly what ownCloud and Baïkal +emit), yields no recovery. Add a raw-body substring fallback, as Evolution does. + +### ⚠️ Scheduling has a ceiling the draft didn't price + +"WorkManager with network constraints" was the entire treatment. Reality: + +- An ordinary worker is documented for **< 10 minutes**. An initial full sync of a + large collection over a slow homelab link will exceed it. DAVx5's own + `workerWaitTimeout` is 10 minutes. +- Escalating to `setForeground` pulls in `FOREGROUND_SERVICE` + + `FOREGROUND_SERVICE_DATA_SYNC` (missing ⇒ `SecurityException` at targetSdk 34+), + a `tools:node="merge"` override on WorkManager's own service, and the + **Android 15 six-hours-per-24 `dataSync` budget** whose failure mode is a fatal + `RemoteServiceException`. Android 15 also **forbids starting a `dataSync` FGS + from `BOOT_COMPLETED`** — and we have a boot receiver. +- **Android 16 removed the shield**: jobs running alongside a foreground service + now obey the job runtime quota, and the `active` bucket is capped at 20 min / + rolling 60 min. + +**Therefore:** make sync **chunked and resumable** — persist the sync-token/ETag +cursor per collection so a killed worker resumes rather than restarts. Hard socket +and wall-clock timeouts. Periodic sync is a plain `PeriodicWorkRequest`, no FGS. +"Sync now" from a visible screen uses `setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)` +— and implement `getForegroundInfo` unconditionally, since omitting it crashes +below API 31 and we support 29. FGS only for user-initiated full syncs, with +`Service.onTimeout → stopSelf()` as a backstop. Play requires a video demo per +declared FGS type. + +⚠️ **Be honest about cadence.** `PeriodicWorkRequest`'s 15-minute floor is +nominal. In the `rare` and `restricted` buckets network access is disabled +outright; Doze allows idle apps network roughly **once a day**. Combined with +unmetered-only, worst case is genuinely "once overnight". Promise eventual +consistency, and sync hard on app open and on connectivity-regained. + +### ⚠️ Server reality — the matrix, audited + +The draft listed six servers as a test matrix. What they actually do: + +| Server | VTODO | The thing that will bite | +|---|---|---| +| **Nextcloud** (sabre) | ✅ | ⚠️ **Never round-trip a body fetched from a shared calendar** — see below. Never send ``. Per-calendar UID uniqueness ⇒ **409 `no-uid-conflict`**. Trashbin renames the href to `-deleted.ics` ⇒ 403 on delete-then-recreate. MKCALENDAR is rate-limited 10/hour ⇒ 429, max 30 calendars ⇒ 403 | +| **Baïkal** (sabre) | ✅ | Handles `` and 507 **correctly** — the reference implementation for that path. Never prunes its change log, so tokens stay valid forever. Defaults to **Digest** auth | +| **Radicale** | ✅ | `supported-calendar-component-set` is **never enforced** — a VEVENT PUT into a VTODO-only collection is accepted. Advertises three reports it does not implement. Its VTODO time-range now implements all eight RFC 4791 §9.9 rows — **the widespread "Radicale doesn't do time ranges" claim is stale** | +| **SOGo** | ✅ | ⚠️ **Never invalidates a sync token** (`valid |= …` makes the check always pass) and tokens are **second-granularity**, so you re-receive up to a second of changes every sync. ⚠️ **The ETag is a row-version counter, and the body is regenerated per-principal** — same ETag, different bytes. Cannot create a VTODO-only collection at all | +| **Fastmail** | ✅ | ⚠️ **Reframe as supported.** The backend does VTODO fine; their own UI hides task-only calendars by design. Create collections as **mixed `VEVENT,VTODO`** so the list doesn't vanish from Fastmail's UI. Requires an app password; Basic plan has no CalDAV | +| **iCloud** | ⚠️ | **Reminders left CalDAV at iOS 13.** A new VTODO collection syncs bidirectionally but is **invisible in Reminders.app forever**. Market it as "store tasks in iCloud", never as "sync with Apple Reminders". Cheap detection: no VTODO-capable collection in the home set ⇒ upgraded account | +| **Google** | ❌ | ⚠️ **Drop it.** First-party docs: *"Doesn't support VTODO or VJOURNAL data"* and no MKCALENDAR. Refuse with an explanation, don't fail with a 401 | + +**Two data-destruction landmines, both confirmed from server source:** + +1. **Nextcloud rewrites task bodies on GET from a shared calendar.** + `CalendarObject::get()` strips `VALARM` on read-only shares, and for + `CLASS:CONFIDENTIAL` reduces the object to a VEVENT-shaped whitelist that + **deletes `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and + `RELATED-TO`** — every property that makes it a task. **The ETag is left + untouched**, so `ETag ≠ md5(body)` and re-PUTting what you downloaded destroys + the task. Baïkal never does this. +2. **sabre runs vobject `REPAIR` on every PUT** unless you send + `Prefer: handling=strict` — adding UID/DTSTAMP/PRODID/VERSION — and when it + modifies the object it **suppresses the ETag response header**, so you must + re-GET rather than assume. It also 415s on **`DUE` < `DTSTART`**, value-type + mismatch between them, multiple UIDs, mixed component types in one resource, + and a present `METHOD`. + +**Three consequences for the design:** + +- **`supported-calendar-component-set` is not an invariant.** Unenforced on + Radicale, discarded by SOGo, immutable on sabre (403 if you try to change it). + Filter on it, but never rely on it. +- **VTODO scheduling exists nowhere.** sabre's own docs: *"We don't do VTODO + scheduling yet, and only support VEVENT."* Treat `ORGANIZER`/`ATTENDEE` on a + task as inert text to round-trip — which is a mercy, since it also means + scheduling never rewrites our objects or suppresses our ETags. +- **Never trust an ETag as a content hash** (SOGo, and Nextcloud shares). + +### ⚠️ Writing: conditional PUT, and the conflict policy that had to change + +**`If-None-Match: *` on create. `If-Match` on update and DELETE.** The draft said +"`If-Match` on every PUT", which omits the creation case entirely — RFC 4791 +§5.3.2 asks for `If-None-Match: *` there, and all three reference clients send it. +Without it, a filename collision (two devices minting the same UID, or a sanitiser +folding two UIDs onto one name) makes the second PUT **silently destroy the +first**, with no ETag to protect it because we have never seen the resource. + +⚠️ **412 means three different things** and the draft's single rule conflated them: + +| On | Means | Do | +|---|---|---| +| create (`If-None-Match`) | the filename is taken | re-fetch that href; adopt if the UID matches, else regenerate the name as a UUID | +| update (`If-Match`) | the server has a newer version | conflict resolution, below | +| update, resource gone | no selected representation, so the condition is false — **spec-correct** (Radicale and DAViCal do this) | `HEAD` to disambiguate; 404 ⇒ delete-vs-edit, not a conflict | + +⚠️ **The proposed conflict policy is unimplementable and is withdrawn.** The draft +said the local version would be *"preserved rather than discarded — a duplicate +task, marked, in the same list."* RFC 4791 §4.1 requires a **UID to be unique +within a collection**, and every target server enforces it: Nextcloud, Radicale +and SOGo all answer **409 `CALDAV:no-uid-conflict`**. So the preserved duplicate +can never be uploaded — same UID fails forever, a new UID forks a task that never +reconciles. The "visible clutter" the design wanted is either a permanently +failing row or a permanent fork. + +**Pick one and write the consequence down** (open question 2, now with real +options): **server-wins and discard the local edit** — DAVx5's stated policy — +or **server-wins and fork under a new UID**, marked in the UI, with the new UID +persisted so the fork is first-class from that moment. Prompting is unavailable; +a background sync has nobody to ask. + +⚠️ **The ETag may be weak or absent, and then `If-Match` can never succeed.** +RFC 4791 §5.3.4: when the server does not store your bytes verbatim, *"a strong +entity tag MUST NOT be returned"*. RFC 9110 §13.1.1: *"A weak entity-tag cannot be +used with If-Match."* On sabre this is the **default path** — `validateICalendar` +runs vobject `REPAIR` unless you send `Prefer: handling=strict`, and +`Server::createFile` then deliberately withholds the ETag. Worse, **weak ETags +also arrive from the user's reverse proxy**: any gzip-compressing nginx, +Cloudflare or Traefik in front of Nextcloud produces them, so the risk tracks the +user's deployment rather than their server software. + +Therefore: send **`Prefer: handling=strict`** to sabre-based servers — the +cheapest single fix in this whole audit, since it preserves both our bytes and +the ETag. Request `Accept-Encoding: identity`. Strip `W/` and keep a weak flag. +**If a PUT returns no ETag or a weak one, discard it and re-fetch** for the strong +validator *and* the server's canonical body. Bound every 412 retry loop. + +⚠️ **Errors are not one status.** RFC 4791 §5.3.2.1 defines **eleven** +preconditions, and the one we will hit most is not in the draft at all: **sabre +returns 415** for a VTODO whose `DUE` precedes `DTSTART`, whose `DUE`/`DTSTART` +value types disagree, or which carries a `METHOD`. The first two are reachable +from ordinary UI actions and must be validated client-side. Also: **507 MUST NOT +be auto-retried** (RFC 4918 §11.5 — quota exhaustion is common on hosted +Nextcloud, and a generic backoff loop violates the spec), and **5xx is not safely +retryable either** — a contradictory `RRULE`/`EXDATE` pair returns 500 from +Nextcloud and will do so forever. + +So line-for-line with "a failed collection must not fail the account", add its +twin: ⚠️ **a failed resource must not fail the collection.** Per-resource +quarantine with a failure counter, not backoff. A single HTTP 400 has halted all +calendar sync in DAVx5 for weeks. + +**DELETE needs the same care:** conditional on `If-Match`; **404/410 count as +success**; a resource deleted locally that was never uploaded is never DELETEd. +And Nextcloud's trashbin renames the href to `-deleted.ics`, so +delete → recreate → delete the same href returns **403** — which task apps hit +constantly, because they reuse hrefs. + +**href and UID are unrelated.** RFC 4791 §5.3.2 opens by saying the URL *"is +entirely arbitrary and does not need to bear a specific relationship"* to the +content, and `.ics` is MAY. Sanitise filenames following vdirsyncer's rule — +`a–zA–Z0–9_.-+`, **excluding `@`**, because some servers percent-encode it in the +path and then reject or "repair" the URL, and RFC 4791's own example UID is +`…@example.com`. Cap the basename around 200 bytes; fall back to a UUID. + +### Manifest and permissions for phase 3 + +⚠️ The draft named only `INTERNET`. Actually needed: `INTERNET`, +`READ_SYNC_SETTINGS`, `WRITE_SYNC_SETTINGS`, `ACCESS_NETWORK_STATE` (merged in by +`work-runtime`, but it shows in F-Droid's permission diff), plus +`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC` if the FGS route is taken. +The authenticator `` must be `android:exported="true"` guarded by +`android:permission="android.permission.ACCOUNT_MANAGER"` — note +`android.permission.ACCOUNT_AUTHENTICATOR` **does not exist**. + +Also missing from `libs.versions.toml` entirely: `androidx.work`, +`androidx.hilt:hilt-work`, `androidx.browser`. With Hilt that means a +`HiltWorkerFactory`, removing the default `WorkManagerInitializer`, and an +`@EarlyEntryPoint` for the authenticator service. + +### Two network facts for homelab users + +- ⚠️ **Ship a `network-security-config` with ``.** Since + Android 7, a user who correctly installs their private CA into Android's store + is *still* not trusted by apps. Cleartext `http://` is blocked by default since + API 28; any escape hatch must be a narrow, warned, per-account opt-in — Play's + User Data policy requires modern cryptography in transit. +- ⚠️ **Android 17 / targetSdk 37 breaks LAN CalDAV.** Local network protections + become mandatory: TCP to a local address and `.local` resolution require the + runtime `ACCESS_LOCAL_NETWORK` permission. The failure mode is a **connection + timeout, not a `SecurityException`** — "my Nextcloud at 192.168.1.50 just + hangs", the worst bug-report shape there is. We are safe at targetSdk 36 (which + gets an implicit grant) and must **not** request it before targeting 37 — but + `compileSdk` is already 37 and Play's floor rises annually, so this is a + scheduled break aimed precisely at the self-hosting demographic. + +--- + +## External mode's future + +`STORAGE-AND-SYNC.md` keeps Posture A as a user choice; a later draft proposed +replacing it with a one-time importer. Premature — it argues against something +shipped and working — but it is the right question one phase early. + +Once we sync ourselves, External mode's only job is reading tasks in someone +else's app. Costs are real and permanent: capability divergence (tasks.org's fork +is DB 22 and lacks `is_recurring`, which is why `TaskMapper.task` derives +recurrence from `rrule`/`rdate`), per-backend UI degradation forever, two +dangerous permissions in a static manifest, a doubled device matrix. + +**Decide before phase 1** — it determines whether the mapper and UI stay +dual-capable. Open question 3. + +If retired, the importer spec is sound: read-only, one-time, idempotent; identify +local lists by `ACCOUNT_TYPE`, treating unknown types as synced; **import the +`Properties` table, not just `Tasks`** (categories, alarms, `RELATED-TO`, `X-` +props — the commonly forgotten half); preserve `_UID`s; persist +`(authority, _ID, uid)` for idempotency; and while scanning, read the *names* of +synced lists so the UI can say *"these 3 lists come from cloud.example.de — add +that account to bring them back"*. + +--- + +## Play compliance + +⚠️ Absent from the first draft entirely. + +- **A privacy policy is mandatory regardless of collection**, linked both in Play + Console and **inside the app**. +- **"Not collected" is not defensible.** Play defines collection as transmitting + data off the device *irrespective of recipient*. Neither the on-device nor the + ephemeral exemption applies, and the E2EE exemption doesn't survive TLS to a + server that reads plaintext. File **Collected, not Shared**, encrypted in + transit. There is no credentials category, but "authentication information" is + explicitly named as personal and sensitive data. +- **Account Deletion policy does not apply** (offline-created accounts are out of + scope), but ship a "Remove account and delete local data" action anyway — + cheap insurance against a reviewer pattern-matching. +- **Do not ship `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` in the Play build.** + Generic server sync is not on the acceptable-use list. Use + `ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`. DAVx5 declares the former and is + F-Droid-safe on it; we would not be. + +--- + +## What lands in floret-kit + +| Candidate | Kit module | Note | +|---|---|---| +| DAV client + iCalendar parse/serialise | new, e.g. `core-dav` | **the big one.** Calendula needs the same primitives. Design for two consumers from the start | +| Sync-adapter + authenticator scaffolding | new, e.g. `core-sync` | including the stub-adapter→WorkManager bridge, which is pure mechanics | +| Nextcloud Login Flow v2 | with `core-dav` | pure protocol, zero task knowledge | +| Credential storage | kit | Keystore mechanics, not domain | +| Third-party licence screen | kit | Calendula needs it the moment it takes any of the above | + +**Stays app-local:** the VTODO ↔ `TaskContract` mapper (domain, and where all the +judgement calls live), `ICalendarWriter`, conflict policy and its UI, and +everything about storage modes. + +--- + +## Test strategy + +- **Round-trip corpus first** — VTODO fixtures from each target server; assert + `parse → store → serialise` is byte-stable for untouched properties. The + mapper's specification, not its regression net. +- **Server matrix:** Nextcloud, Radicale, Baïkal (**on Digest**), SOGo, Fastmail, + iCloud — with the per-server traps above as named test cases. Google is out. + Two that must be explicit tests, because both silently destroy data: + **round-tripping a task from a shared Nextcloud calendar**, and **an + ETag-unchanged body change on SOGo**. +- **Conflict scenarios:** concurrent edit, delete-vs-edit, collection removed + server-side, credentials revoked mid-sync. +- **Interop:** edit the same task from Nextcloud web and from tasks.org + DAVx5. +- **Device, not Robolectric,** for account paths — `ProviderAccountCleanupTest` + skips on ARM64. Two specific device cases the audit added: **restore a cloud + backup onto a fresh device** and confirm nothing is pruned; **remove the + account** and confirm only our lists go. +- ⚠️ **Minified-build tests.** `:app` runs `isMinifyEnabled` + `isShrinkResources` + in release, and `proguard-rules.pro` already documents two R8-pruning outages. + ical4j resolves through seven `META-INF/services` files and instantiates its + cache from a class-name string — it needs `-keep class net.fortuna.ical4j.** { *; }` + plus ~8 `-dontwarn` lines, and is effectively unshrinkable. Without them this + fails in `release` only. + +--- + +## Open questions + +1. **dav4jvm + cert4android distribution *and* Java target.** Scoped JitPack, + vendor, or in-house verbs? Now compounded: 4.x requires **Java 21** and we + target 17 across `:app` and all of floret-kit. Vendoring recompiles at our own + target and freezes the API churn — it looks better than it did. Before phase 2. +2. **Conflict policy** — preserve-local-on-412, or documented LWW? +3. **External mode** — survives, or becomes an importer? Before phase 1. +4. **Canonical recurring-completion behaviour** — and specifically, ⚠️ **do we + honour `:provider`'s `Detaching` processor (model d), or bypass it and write + `RECURRENCE-ID` overrides (model a)?** No longer an open-ended taste question: + our storage layer already answered it and we have to ratify or override that. + Moved up to **phase 1**. +5. **The DAVx5 enum ask** — worth filing, and what compatibility we owe if it + lands. +6. ⚠️ **New: does Local→Synced migrate, or do synced lists start empty?** See + [the migration section](#-the-migration-that-was-believed-not-to-exist). +7. ⚠️ **New: lib-recur — pin at 0.12.2, or rewrite the provider's iterators?** + +Answered elsewhere and **not** open: the account model (`AccountManager` **plus a +stub sync adapter**), `ical4android` (superseded by `synctools`, GPLv3), and the +storage question. + +--- + +## Dead ends — do not revisit + +- **Depending on DAVx5 for sync.** Settled in `STORAGE-AND-SYNC.md`. +- **`synctools` / `ical4android`.** GPLv3. The temptation recurs because it does + exactly the right mapping against exactly our schema. +- **Rewriting storage to Room before sync exists.** [See above](#settled--the-storage-question-is-not-reopened-here). +- ⚠️ **AccountManager + WorkManager with no registered sync adapter.** Not a + design choice — a silent no-op at targetSdk ≥ 34. +- ⚠️ **Writing through the `instances` URI as a sync adapter.** The flag is + ignored there; every such write dirties the row and forks an override. +- ⚠️ **Hand-rolled TrustManager to avoid a GPL licence cert4android does not + have.** + +--- + +## Related + +- [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md) — where task data lives. This is + its step 5. +- [`../provider/PROVENANCE.md`](../provider/PROVENANCE.md) — every deviation from + upstream dmfs. Change 1 is load-bearing for the account model here. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) §4 — the data seam the adapter writes + underneath. diff --git a/provider/PROVENANCE.md b/provider/PROVENANCE.md index f911df4..6cfcefe 100644 --- a/provider/PROVENANCE.md +++ b/provider/PROVENANCE.md @@ -159,7 +159,7 @@ drift apart. Keep that convention. `contenttestpal`, which are JitPack-only; adding JitPack would widen the dependency trust surface for test-only code. ⚠️ This is the one place vendoring lost coverage — those were the provider's *integration* tests - (recurrence, reparenting, instances, observers). The 51 JVM tests in + (recurrence, reparenting, instances, observers). The 56 JVM tests in `src/test` all pass and are retained. 15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies `org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority @@ -175,7 +175,7 @@ drift apart. Keep that convention. Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the `AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on -this directory is the audit trail, and the 51 JVM tests are the safety net. +this directory is the audit trail, and the 56 JVM tests are the safety net. Re-read change 1 before touching anything account-related. ## Known-unverified From 8c3cbcf928762f25304e59aa51cce24bd99d71a3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 15:30:37 +0200 Subject: [PATCH 06/21] docs: fix seven defects in the own-store plan Reviewed the plan against the code it describes. Two design holes and five errors. Instance identity was the real one. The plan deleted the materialised instances table without saying what replaces the instance row id, which TasksRepositoryImpl.updateTask passes to updateInstance and which ListsScreen keys a lazy list by. Two occurrences of one series can appear in the same list, so taskId alone is not unique and a hash of (taskId, start) can collide - as a Compose key that is a visible bug. Task.id is dropped for occurrenceStart, updateInstance takes (taskId, occurrenceStart, form), and External mode maps back to a real instance row with one query. This is the single seam change, and the plan's "TasksDataSource unchanged" claim was wrong. Local lists had no account name. TaskList.accountName is non-null, ListsViewModel groups by it and ListsScreen renders it as a section header, so a null account_id must still report "Local". The unique index was wrong: overrides share their master's UID, so unique (list_id, uid) would reject the rows the recurrence design depends on. It needs recurrence_id in the key. Phase 0 broke background reminders. It dropped our authority from ProviderChangeReceiver's manifest filter while the provider was still the store, and renamed StorageMode.LOCAL to OWN four phases before OWN meant Room. Both moved to phase 5. Parity against the provider was overclaimed: the provider materialises one occurrence, so multi-occurrence expansion has nothing to compare against and is tested against RFC 5545 directly. The phases sum to 6.5-7 weeks, not the 6-6.5 stated, and the difference from STORAGE-DECISION.md's 4.5-6 is now explained rather than left as a contradiction. Gaps closed: WAL vs Auto Backup (checkpoint on ON_STOP, sidecars in the backup rules, tested in phase 6), cascade rules for master_id and parent_id, Instant type converters, a rollback path that re-runs the import from tasks.db.imported, the release note for dropping the authority and its permissions, and ICalendarWriter.uidFor's synthesis branch becoming External-only. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 108 +++++++++++++++++ docs/OWN-STORE.md | 254 +++++++++++++++++++++++++++++++++------ docs/STORAGE-DECISION.md | 6 + 3 files changed, 332 insertions(+), 36 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb898b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +## On-device work (USB-connected phone) + +A physical phone is connected over USB. Rules: + +- **Install when asked** — if the user says to install/deploy on device, do it + (build + `adb install`). That's the one action you may take on your own. +- **Do nothing else on the device unprompted.** Do not launch the app, take + screenshots, dump/read logcat, poke UI, or otherwise "test" or verify on the + device on your own initiative — even to confirm a change works. +- Read logs, capture screenshots, and inspect on-device behaviour **only when the + user explicitly asks for it, each time.** The user drives when it's time to + look; wait for that instruction. + +## UI / design conventions + +- Material 3 Expressive throughout. Consult the `material-3` skill before + designing anything new; prefer M3 tokens/components over hardcoded colours. +- **Selection pickers are full-screen** — browse-style "choose one" surfaces + (visibility, reminder, recurrence rule, colour, calendar, add-field, plus the + Settings pickers) use floret-kit's `FullScreenPicker` / `OptionPicker` (a + full-bleed sheet with a pinned title bar and connected grouped rows); a picker + that needs a commit/extra action passes it via the picker's `actions` slot. + The exception is the **recurring scope choosers** — the "this / this & following + / all" prompts shown when you *save an edit to*, *drag* or *delete* a recurring + event — which stay compact `OptionCard`-in-`AlertDialog` popups (a quick 2–3 option + decision reads better as a popup than a near-empty full screen). `AlertDialog` + is otherwise only for plain confirmations. Radio/text-list dialogs are banned. + +## Releases + +The committed `versionCode` / `versionName` in `app/build.gradle.kts` **are the +release trigger**: merging a bumped `versionName` into `main` runs +`.gitea/workflows/release.yaml`, which builds, signs, publishes to the +self-hosted F-Droid repo, then mints the `vX.Y.Z` tag + release. `versionCode` is +pinned to `MAJOR*10000 + MINOR*100 + PATCH` (e.g. 2.13.0 → 21300). Full process +in `docs/RELEASING.md`. **Never tag/release UI changes before on-device review +and explicit go-ahead.** + +Release builds are kept **F-Droid reproducible** — `vcsInfo`, `dependenciesInfo`, +and the AGP metadata block are deliberately disabled in `build.gradle.kts`; don't +re-enable them. Use the `releaseTest` build type (R8-shrunk twin, debug-signed, +own applicationId suffix) to smoke-test a release candidate on-device. + +### Per-version changelogs are written by hand, in every locale + +`fastlane/metadata/android//changelogs/.txt` is the +"What's New" both F-Droid and Play publish. **There is no auto-translation +layer** — Weblate owns `values-*/strings.xml` only, not the fastlane tree — so +when cutting a release you write these files yourself, one per locale, each a +short summary under **500 characters** (Play's hard cap, applied per locale; +F-Droid truncates in-client). + +Write one for **every language the app ships** (`app/src/main/res/values-*`), +using store locale codes: `en-US`, `en-GB`, `de-DE`, `es-ES`, `fr-FR`, `it-IT`, +`pl-PL`, `pt-PT`, `ru-RU`, `zh-CN`, `ar`. Missing locales aren't fatal — both +stores fall back — but see the `en-GB` trap below. + +**`en-GB` is the Play Console's default language.** Play's fallback is the +*default locale*, not `en-US`, so a release with only an `en-US` changelog ships +with **no "What's New" at all** — this is why the latest release had none. +`en-GB` must exist. + +The rest of the plumbing is already locale-agnostic: `sync_changelog_to_fastlane.sh` +seeds `en-US` only and never overwrites a committed file, while +`fastlane_to_fdroid_localized.sh` and `supply` pick up every locale that has a +`changelogs/` dir. So the files are the whole job. + +## Forge / `tea` CLI + +**Codeberg is canonical** (`codeberg.org/jlmakiola/calendula`) for git, issues, +PRs, tags and releases — including the `floret-kit` submodule. The self-hosted +Gitea instance is **build infrastructure only**: signing key, F-Droid publishing, +release pipeline. + +Use the **`tea` CLI** for forge interaction — not raw API calls. Note the flag is +a *subcommand* flag, not global: `tea pulls list --login codeberg`, never +`tea --login codeberg pulls list`. Two accounts, neither default: + +- **Everything → `codeberg`** (user `jlmakiola`). PRs, issues, releases, repo + settings. `tea pulls create --login codeberg ...` +- **Build infra only → `jeanluc`** (`gitea.jeanlucmakiola.de`, user `makiolaj`). + Release-pipeline runs, Actions secrets. `tea ... --login jeanluc` + +Workflows are split by directory and this is load-bearing — Forgejo's lookup is +first-match-wins across `.forgejo/` → `.gitea/` → `.github/`: + +- `.forgejo/workflows/` runs on **Codeberg** (`ci.yaml`, `translations.yaml`) and + must reference **no secrets** — that's what makes fork PRs safe. +- `.gitea/workflows/` runs on **Gitea** (`release.yaml`, `renovate.yml`) and is + where every secret lives. + +Don't add a workflow without deciding which side it belongs on. + +## Translations + +Community translations are managed on a self-hosted **Weblate**, which owns all +`values-*` files (including German — API only, never hand-edit). Partial +translations are expected (`MissingTranslation` is informational, not fatal); +extra/stale keys stay fatal. + + +## Git Operations + +Use a commit format which references the issues, dont add any Co-Authered by Claude lines, and don't write extensive commit and merge messages, simple human ones suffice, for prs, stuff like testing etc, isnt interesting write what has chnaged, and if deviated from the underling issues pls explain why, add a closes issue line at the end of all prs. + +## Comments + +Don't add extensive code comments, methode discription, so as a Java Doc is fine, but no extensive explanbanitory conmments diff --git a/docs/OWN-STORE.md b/docs/OWN-STORE.md index 00ab459..48a9db9 100644 --- a/docs/OWN-STORE.md +++ b/docs/OWN-STORE.md @@ -30,8 +30,8 @@ BEFORE AFTER │ │ TasksRepository TasksRepository ← unchanged │ │ - TasksDataSource (interface) TasksDataSource ← unchanged - │ ╱ ╲ + TasksDataSource (interface) TasksDataSource ← one method + │ ╱ ╲ changes AndroidTasksDataSource RoomTasksDataSource AndroidTasksDataSource │ │ │ ContentResolver Room / SQLite ContentResolver @@ -42,10 +42,18 @@ BEFORE AFTER (deleted) tasks.org (external, unchanged) ``` -**Unchanged above the data layer.** `TasksRepository`, every ViewModel, every -screen. Verified: exactly one file outside `data/tasks` references -`TasksContract` (`domain/Models.kt`, for five constants), and it stops doing so -in phase 0. +**Unchanged above the data layer.** Every ViewModel, every screen. Verified: +exactly one file outside `data/tasks` references `TasksContract` +(`domain/Models.kt`, for five constants), and it stops doing so in phase 0. +Navigation addresses tasks by `taskId` throughout (`Destinations.kt:59`, +`TaskDetailScreen.kt:202,384`) — never by the instance id — so the change below +does not reach the UI. + +**One seam method changes.** `TasksDataSource.updateInstance(instanceId, form)` +becomes `updateInstance(taskId, occurrenceStart, form)`, and `Task` gains +`occurrenceStart: Instant?`. See *Instance identity* — this is the one place the +"nothing above the data layer changes" claim needed qualifying, and +`TasksRepositoryImpl.updateTask` is the only caller. **Deleted.** The `:provider` Gradle module, its manifest ``, its two custom permissions, its 84 Java files, its 13 translated string resources, and @@ -73,6 +81,12 @@ enum class StorageMode { and the `TaskProvider(isOwn = true)` case go with it: in `OWN` mode there is no authority, no ContentResolver and no permission to grant. +> **This rename happens in phase 5, not phase 0.** Between phases 1 and 4 both +> stores exist, so the enum carries `LOCAL` (dmfs), `OWN` (Room) and `EXTERNAL` +> simultaneously. Renaming `LOCAL` → `OWN` up front would make `OWN` mean *dmfs* +> for four phases and *Room* afterwards, which is exactly the kind of thing that +> gets misread six weeks later. + `ProviderResolver` narrows to what it was always really for — **discovering external providers** — and `ProviderStatus.READY` becomes unconditional in `OWN` mode. @@ -85,10 +99,30 @@ mode. ContentProvider. Users who need interop pick External mode, or wait for a possible read-only facade (explicitly out of scope — see *Deliberately not doing*). +- **Local lists still report an account name.** `TaskList.accountName` is a + non-null String that `ListsViewModel.kt:98` groups by and + `ListsScreen.kt:222` renders as a section header. `RoomTasksDataSource` maps + `account_id IS NULL` to `accountName = "Local"`, `accountType = + "local"`, so the existing grouping and `TaskList.isLocal` keep working with no + UI change. (`isLocal` moves off `TasksContract.LOCAL_ACCOUNT_TYPE` in phase 0 + and compares against a `domain` constant instead.) + + Knock-on, benign: `TaskEditViewModel.kt:101` picks the first *non*-local list + as the edit form's default. In `OWN` mode with no account configured every + list is local, so it falls through to `firstOrNull()`. Same practical result, + worth knowing before someone reports it as a bug. - **Auto Backup gets simpler and safer.** One Room file we control, with a documented restore path, instead of a provider database whose `cleanUpLists` routine could delete restored lists whose accounts no longer exist. + ⚠️ With one caveat that has to be handled, not assumed away: **Room enables + write-ahead logging by default**, and Auto Backup copies files without + checkpointing. A `-wal` sidecar can hold writes the backed-up `.db` does not. + We checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) on `ON_STOP` and include + `.db`, `-wal` and `-shm` together in the backup rules, so a restore is + consistent either way. Phase 6 tests this, because "our backup is safer" is + the kind of claim that is worth exactly as much as its test. + --- ## The schema @@ -121,8 +155,14 @@ Four tables. Designed from Agendula's actual reads and writes plus RFC 5545's ### `tasks` -Master rows *and* recurrence overrides live here; an override is a row with -`recurrence_id` set and `parent_task_id` pointing at its master. +Master rows *and* recurrence overrides live here. An override is a row with +`recurrence_id` set and `master_id` pointing at its series master. + +> `master_id` and `parent_id` are different things and must not be conflated. +> **`parent_id`** is task hierarchy — a subtask's parent, the thing +> `RELATED-TO;RELTYPE=PARENT` carries. **`master_id`** is recurrence — which +> series an override belongs to. A row can have both: a subtask can itself +> recur. | Group | Columns | |---|---| @@ -150,8 +190,26 @@ own. This is what makes an honest round-trip possible, and it replaces the provider's `data0`–`data15` bag with something that cannot silently lose a field it has no column for. -Indices: `(list_id, is_deleted)`, `(parent_id)`, `(uid)` unique per list, -`(master_id, recurrence_id)`, `(is_dirty)`. +Indices: `(list_id, is_deleted)`, `(parent_id)`, `(master_id, recurrence_id)`, +`(is_dirty)`, and **unique on `(list_id, uid, recurrence_id)`**. + +> The unique index deliberately includes `recurrence_id`. An override **shares +> its master's UID** — that is what makes it an override rather than a separate +> task — so a unique index on `(list_id, uid)` alone would reject the very rows +> the recurrence design depends on. With `recurrence_id` NULL on the master and +> set on each override, the constraint says the right thing: one master and at +> most one override per occurrence, per UID, per list. + +**Cascades.** `master_id` is `ON DELETE CASCADE` — deleting a series deletes its +overrides, which would otherwise become unreachable rows that still sync. +`parent_id` is `ON DELETE SET NULL`: deleting a parent promotes its subtasks to +top level rather than destroying work the user did not ask to lose. `task_id` on +`task_alarms` cascades. + +**Type converters.** Every time column is `kotlin.time.Instant` in the entity +and INTEGER epoch-millis in SQLite, via one `@TypeConverter` pair. `status` and +`priority` convert through the existing `domain` enums, so `statusFromInt` / +`toInt()` keep their single home. ### `task_alarms` @@ -206,6 +264,43 @@ materialised table's entire class of staleness bugs never exists. - Client-side expansion is required for CalDAV regardless: server-side `CALDAV:expand` on `VTODO` is broken on every server `SYNC.md` targets. +### Instance identity + +Deleting the materialised `instances` table deletes the instance **row id**, and +two things use it today: + +- `TasksRepositoryImpl.updateTask` → `dataSource.updateInstance(current.id, …)` +- `ListsScreen.kt:422` → `items(results, key = { it.id })` + +The Compose key is the constraint that decides the design. Two occurrences of +one series can appear in the same list, so `taskId` alone is not unique, and a +hash of `(taskId, start)` folded into a `Long` can collide — which as a Compose +key is a visible bug, not a theoretical one. + +So we address occurrences by what they actually are: + +```kotlin +data class Task( + val taskId: Long, // the master row — unchanged, what navigation uses + val occurrenceStart: Instant?, // null for a non-recurring task + … +) + +fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) +``` + +`Task.id` is dropped; the Compose key becomes `"$taskId@${occurrenceStart}"`, +which is unique by construction and stable across reloads. + +**External mode absorbs this without loss.** `AndroidTasksDataSource` maps +`(taskId, occurrenceStart)` back to a real instance row with one query — +`WHERE task_id = ? AND instance_start = ?` — before writing through the +instances URI. One extra query on an operation the user performs by hand, in +exchange for a seam that does not depend on a foreign table's row ids. + +This is the **only** change to `TasksDataSource`, and +`TasksRepositoryImpl.updateTask` is its only caller. + ### Completing one occurrence of a recurring task The provider's `Detaching.java` implemented **model (d): detach the occurrence @@ -272,8 +367,25 @@ Rules that make this safe: local lists, with their `uid` preserved. Rare, but preserving the UID is what lets them be re-attached to an account later. -`tasks.db.imported` is excluded from Auto Backup; the new Room database is -included, which is the whole point of owning it. +`tasks.db.imported` is excluded from Auto Backup; the new Room database (with +its `-wal` and `-shm` sidecars) is included, which is the whole point of owning +it. + +### If the import goes wrong in production + +The rename is not just tidiness — it is the rollback. `tasks.db.imported` is a +complete, untouched dmfs database, so recovery does not need `:provider` to +still exist: + +1. `OneShotImport` can be re-run against `tasks.db.imported` as well as + `tasks.db`; the DataStore flag is clearable by a targeted fix release. +2. Re-import truncates the Room tables first and re-runs in one transaction, so + a second attempt is not a merge and cannot duplicate. +3. Only after a release with no import defects reported does a subsequent + version delete `tasks.db.imported`. + +This is the reason phase 5 (deleting `:provider`) ships *after* phase 4 rather +than with it — and the reason the deletion is its own release. --- @@ -308,15 +420,28 @@ every per-server quirk in the server-reality table. ### Phase 0 — Untangle (0.5 wk) -- `domain/Models.kt` stops importing `TasksContract`; the status and priority - constants move into `domain`. This is the last contract reference above the - data layer. -- `StorageMode`: `LOCAL` → `OWN`; `ProviderResolver` loses `own` / `isOwn`. -- `ProviderChangeReceiver`'s manifest filter drops our own authority. -- Add Room + KSP to the version catalog (KSP is already applied to `:app`). +- `domain/Models.kt` stops importing `TasksContract`; the status, priority and + local-account constants move into `domain`. This is the last contract + reference above the data layer. +- `Task.id` → `Task.occurrenceStart`; `updateInstance(taskId, occurrenceStart, + form)`. `AndroidTasksDataSource` gains the lookup query, so the *existing* + provider path exercises the new signature before Room ever does. +- Add `StorageMode.OWN` as a **third** value alongside `LOCAL` and `EXTERNAL`. +- Add Room + `room.schemaLocation` to the version catalog (KSP is already + applied to `:app` for Hilt). -**Done when:** the app still builds and behaves identically, with the provider -still present and still default. +Deliberately **not** here — both were in an earlier draft and both were wrong: + +- *Renaming `LOCAL` → `OWN`.* The provider is still the store until phase 4; + renaming now makes `OWN` mean dmfs for four phases and Room afterwards. + Phase 5. +- *Dropping our authority from `ProviderChangeReceiver`'s manifest filter.* That + receiver is what re-syncs reminders while the app is backgrounded + (`ProviderChangeReceiver.kt:47`). Removing the filter while the provider is + still live would silently stop background reminder updates. Phase 5. + +**Done when:** the app builds and behaves identically, provider still present, +still default, and the seam change is proven on the provider path. ### Phase 1 — Schema and DAOs (1 wk) @@ -341,19 +466,38 @@ The hard phase. Budget accordingly. a series with an override, a series with an exception, and an unbounded rule hitting the window ceiling. -**Done when:** `updateInstance` and recurring reads pass parity tests written -against the current provider's observed behaviour, *except* where model (a) -deliberately differs from model (d) — those differences enumerated as tests. +**Done when:** the expansion suite is green and single-occurrence editing forks +correctly. + +⚠️ **Parity against the provider is only partly available, and the earlier draft +of this plan overclaimed it.** The provider materialises exactly one upcoming +occurrence, so there is no multi-occurrence behaviour to compare against. The +split: + +| Behaviour | Reference | +|---|---| +| Multi-occurrence expansion | RFC 5545 §3.8.5 and `lib-recur` directly — **no provider parity exists** | +| The single next occurrence | provider parity, while it is still in-tree | +| Editing one occurrence (forking) | provider parity — *except* model (a) vs (d), enumerated as explicit difference tests | +| All-day and DST handling | provider parity | + +That partial availability is still the reason deletion is phase 5 rather than +phase 0. It is just not the blanket safety net it was described as. ### Phase 3 — Semantics parity (1 wk) - Completion coherence: `status` ↔ `percent_complete` ↔ `completed_at` ↔ closed, replacing `AutoCompleting.java` and — importantly — the reopen asymmetry that `TaskWriteMapper` currently works around in the app. -- Parent/child integrity, orphan handling on delete. +- Parent/child integrity: `parent_id` is `ON DELETE SET NULL`, so deleting a + parent promotes its subtasks rather than destroying them. - Validation: `DUE` xor `DURATION`, `due >= dtstart`, all-day pinned to UTC midnight, list must exist. -- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set. +- Delete semantics: hard delete when `account_id IS NULL`, tombstone when set; + `master_id` cascades so a deleted series takes its overrides with it. +- `ICalendarWriter.uidFor`'s synthesis branch becomes dead on the Room path + (`uid` is NOT NULL). It stays for External, where UIDs really can be absent — + the KDoc gets updated to say which path each branch now serves. **Done when:** `TaskWriteMapper`'s provider-quirk workarounds are demonstrably unnecessary on the Room path (they stay for External). @@ -363,8 +507,9 @@ unnecessary on the Room path (they stay for External). - `OneShotImport` per the rules above, with tests over a fixture `tasks.db` captured from a real v0.3.x install. - `OWN` becomes the default for new installs and for upgraders after import. -- Backup rules updated: include the Room database, exclude `tasks.db.imported` - and the Keystore blob. +- Backup rules updated: include the Room database **and its `-wal`/`-shm` + sidecars**, exclude `tasks.db.imported` and the Keystore blob. WAL checkpoint + on `ON_STOP`. **Done when:** an upgrade from a v0.3.2 APK with seeded data lands every task, list and reminder in Room, verified by count and by content. @@ -374,9 +519,22 @@ list and reminder in Room, verified by count and by content. - Remove the module, its `settings.gradle.kts` include, its `:app` dependency, the three dmfs deps it pulled in, `provider/PROVENANCE.md`. - Add `lib-recur` (and `rfc5545-datetime`) directly to `:app`. +- `StorageMode`: `LOCAL` is deleted, `OWN` is what remains beside `EXTERNAL`. + `ProviderResolver` loses `own` / `isOwn`. +- `ProviderChangeReceiver`'s manifest filter drops our own authority — safe + *now*, because nothing of ours broadcasts `ACTION_PROVIDER_CHANGED` any more. + In `OWN` mode the in-app `InvalidationTracker` observer covers foreground + changes, and until sync exists nothing outside the app can change our data. + **When `SYNC.md` phase 3 lands, the sync worker must call + `ReminderScheduler.sync()` itself** — that is the replacement for the + broadcast, and it belongs in the sync work, not here. - Attribution screen: dmfs code is gone, but `lib-recur` stays and is Apache-2.0. `PROVENANCE.md` is replaced by a short note in `STORAGE-DECISION.md` recording that the fork existed and why it ended. +- **Release note, user-facing:** dropping the `` also drops the + `de.jeanlucmakiola.agendula.tasks` authority and both custom permissions. + Anyone who pointed DAVx5 or another app at that authority loses it silently — + it has to be called out in the release, with External mode as the answer. **Done when:** `./gradlew build` is green with `:provider` absent, and the APK declares no ContentProvider and no custom permissions. @@ -385,11 +543,32 @@ declares no ContentProvider and no custom permissions. - Room migration test infrastructure (`MigrationTestHelper`) wired up, so v1 → v2 is cheap when sync adds columns. -- Restore-path test: Auto Backup restore into a fresh install. +- Restore-path test: Auto Backup restore into a fresh install, **including the + WAL case** — write, background, restore, verify the last write survived. - Performance check at 5,000 tasks with 20 recurring series. -**Total: 6–6.5 weeks** to a shipping app with its own store, before any CalDAV -work begins. `SYNC.md`'s own estimate drops by 2.5–4 weeks in exchange. +### Total + +| Phase | | | +|---|---|---:| +| 0 | Untangle | 0.5 | +| 1 | Schema and DAOs | 1 | +| 2 | Recurrence | 1.5–2 | +| 3 | Semantics parity | 1 | +| 4 | Import and cutover | 1 | +| 5 | Delete `:provider` | 0.5 | +| 6 | Harden | 1 | +| | | **6.5–7 wk** | + +Against which `SYNC.md`'s own estimate drops by 2.5–4 weeks, so the net cost of +owning the store is roughly **+2.5 to +4.5 weeks** — before counting the bugs +that stop being unfixable. + +> This does not contradict `STORAGE-DECISION.md`'s 4.5–6 week figure; it +> supersedes it. That estimate costed only the new store (schema, expansion, +> semantics, import, tests) on the assumption `:provider` would be *kept* +> alongside it. This plan deletes the provider, which adds phase 0's untangling +> and phase 5's removal — work the earlier figure never had to include. --- @@ -405,8 +584,10 @@ work begins. `SYNC.md`'s own estimate drops by 2.5–4 weeks in exchange. The 93 existing app tests must stay green throughout. The 56 provider tests leave with the module in phase 5 — replaced, not abandoned: phases 2 and 3 owe -equivalent coverage of the behaviour those tests protected, and phase 2's -parity suite is written against them. +equivalent coverage of the behaviour those tests protected. Note the limit +recorded in phase 2: parity covers the single next occurrence, forking and +all-day/DST handling. Multi-occurrence expansion has no provider behaviour to +compare against and is tested against RFC 5545 and `lib-recur` directly. --- @@ -414,10 +595,11 @@ parity suite is written against them. | Risk | Mitigation | |---|---| -| **Recurrence is subtler than estimated** | Phase 2 is isolated and pure-JVM; it can overrun without blocking phases 3–4. The provider stays in-tree until phase 5, so we can always compare against it. | -| **Import loses a user's data** | Read-only source, single transaction, count verification, source file renamed not deleted, fixture-based tests. | -| **Regression in a behaviour nobody documented** | Phase 2's parity tests are written *against the provider while it is still present*. That is why deletion is phase 5, not phase 0. | -| **Losing third-party interop** | External mode covers users who need it. A read-only facade stays possible later; nothing in this design forecloses it. | +| **Recurrence is subtler than estimated** | The likeliest overrun, and the least mitigated — provider parity does not cover multi-occurrence expansion, so the reference is the RFC. Phase 2 is isolated and pure-JVM, so it can overrun without blocking phases 3–4. | +| **Import loses a user's data** | Read-only source, single transaction, count verification, source renamed not deleted, re-runnable from `tasks.db.imported`, fixture-based tests. | +| **Regression in a behaviour nobody documented** | Partial: phase 2 and 3 parity tests run against the provider while it is still present, for the behaviours where parity exists at all. That is why deletion is phase 5. | +| **A restore silently loses recent writes** | WAL checkpoint on `ON_STOP`, sidecars included in the backup rules, and a phase 6 test that exercises exactly this. | +| **Losing third-party interop** | External mode covers users who need it. Called out in the phase 5 release note. A read-only facade stays possible later; nothing here forecloses it. | | **Room + KSP build cost** | KSP is already in the build for Hilt; Room adds one processor. | --- diff --git a/docs/STORAGE-DECISION.md b/docs/STORAGE-DECISION.md index bf21c4d..13491e0 100644 --- a/docs/STORAGE-DECISION.md +++ b/docs/STORAGE-DECISION.md @@ -143,6 +143,12 @@ Two things make instance expansion less frightening than its line count: | Tests to parity with the current 93 + 56 | 1 wk | | **Net additional** | **4.5–6 wk** | +> ⚠️ Superseded by [`OWN-STORE.md`](OWN-STORE.md)'s **6.5–7 wk**. The figure +> above costed the new store only, on the assumption `:provider` would be kept +> beside it. The decision taken was to delete the provider, which adds the +> untangling (phase 0) and the removal (phase 5) that this estimate never had to +> include. The costing logic stands; the total does not. + ### What it removes from the sync plan Roughly sixteen of the phase-1 audit's storage findings are **provider-imposed** From 11b20faf829702d6101df03ef8caaa3470ae2ad8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 15:55:24 +0200 Subject: [PATCH 07/21] refactor(data): address occurrences by (taskId, occurrenceStart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/build.gradle.kts | 11 ++++ .../data/tasks/AndroidTasksDataSource.kt | 29 ++++++++++- .../agendula/data/tasks/ProviderResolver.kt | 3 ++ .../agendula/data/tasks/StorageMode.kt | 11 ++++ .../agendula/data/tasks/TaskMapper.kt | 39 ++++++++++---- .../agendula/data/tasks/TasksContract.kt | 7 +++ .../agendula/data/tasks/TasksDataSource.kt | 16 ++++-- .../data/tasks/TasksRepositoryImpl.kt | 11 ++-- .../jeanlucmakiola/agendula/domain/Models.kt | 52 +++++++++++++------ .../agendula/domain/TaskConstants.kt | 30 +++++++++++ .../agendula/ui/lists/ListsScreen.kt | 2 +- .../agendula/ui/tasklist/TaskListScreen.kt | 10 ++-- .../agendula/data/tasks/TaskMapperTest.kt | 35 ++++++++++++- .../agendula/domain/TaskSortingTest.kt | 4 +- .../agendula/domain/TestTasks.kt | 2 +- gradle/libs.versions.toml | 8 +++ 16 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8768112..488e8ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { // Agendula's own task store — the dmfs provider vendored under our authority. // Contributes a to the merged manifest; no app code imports from it @@ -162,6 +169,10 @@ dependencies { implementation(libs.androidx.navigation.compose) 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.documentfile) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index a7d46fd..4ef3fad 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -20,6 +20,7 @@ import de.jeanlucmakiola.agendula.domain.export.ExportTask import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Instant /** * 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") } - 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 uri = TasksContract.instanceUri(authority(), instanceId) val rows = resolver.update(uri, values.toContentValues(), null, null) 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?) { val uri = TasksContract.propertiesUri(authority()) // Replace rather than update: the provider's AlarmHandler re-validates the diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index da2e700..26678ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -68,6 +68,9 @@ class ProviderResolver @Inject constructor( /** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */ fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) { 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() } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt index 7105f46..d60e45c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -22,9 +22,20 @@ enum class StorageMode { * Agendula's own bundled provider (the `:provider` module). Always available — * 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. + * + * 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, + /** + * 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 * whatever that provider's engine is — DAVx5 and friends. This is the original diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index 73cfa1a..ce856b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -17,10 +17,17 @@ object TaskMapper { fun instant(name: String): Instant? = 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( - id = instanceId, - taskId = r.getLong(Instances.TASK_ID) ?: instanceId, + taskId = r.getLong(Instances.TASK_ID) ?: rowId, listId = r.getLong(Tasks.LIST_ID) ?: 0L, title = r.getString(Tasks.TITLE).orEmpty(), description = r.getString(Tasks.DESCRIPTION), @@ -39,20 +46,30 @@ object TaskMapper { listName = r.getString(Tasks.LIST_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME), parentId = r.getLong(Tasks.PARENT_ID), - // 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. - isRecurring = r.getString(Tasks.RRULE) != null || - r.getString(Tasks.RDATE) != null || - r.getBoolean(Instances.IS_RECURRING), + isRecurring = recurring, + occurrenceStart = if (recurring) occurrenceAnchor(r) else null, distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), created = instant(Tasks.CREATED), 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. * diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index 9a25f46..674bc94 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -99,6 +99,13 @@ object TasksContract { const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val DISTANCE_FROM_CURRENT = "distance_from_current" 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]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index 33a08ec..a8885e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import kotlin.time.Instant /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( @@ -25,12 +26,17 @@ interface TasksDataSource { fun updateTask(taskId: Long, form: TaskForm) /** - * Update a single occurrence of a recurring task, addressed by its *instance* - * row id. The provider forks an override task rather than moving the series - * anchor — which is what [updateTask] would do, since a recurring task's - * start/due are read from the instances view. + * Update a single occurrence of a recurring task, addressed by the task row and + * the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store + * forks an override rather than moving the series anchor — which is what + * [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 * property row. The provider never fires it — [de.jeanlucmakiola.agendula diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 8ac224e..e1fc33b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -105,11 +105,12 @@ class TasksRepositoryImpl @Inject constructor( // task's properties onto the new override row, so setting the alarm // beforehand is what carries it across. dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue) - // A recurring task's start/due come from the instances view, so writing - // them back to tasks/ would re-anchor the whole series. Going through - // the occurrence lets the provider fork an override instead. - if (current != null && current.isRecurring) { - dataSource.updateInstance(current.id, form) + // A recurring task's start/due are one occurrence's resolved times, so + // writing them back to the task row would re-anchor the whole series. + // Going through the occurrence forks an override instead. + val occurrence = current?.takeIf { it.isRecurring }?.occurrenceStart + if (occurrence != null) { + dataSource.updateInstance(taskId, occurrence, form) } else { dataSource.updateTask(taskId, form) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index e19e9cc..5f16df4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.agendula.domain -import de.jeanlucmakiola.agendula.data.tasks.TasksContract import kotlin.time.Instant /** A task list (the `tasklists` table). Lists group under their account. */ @@ -15,7 +14,8 @@ data class TaskList( val owner: String?, ) { /** 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 } @@ -24,11 +24,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } enum class Priority { NONE, LOW, MEDIUM, HIGH } /** - * A task occurrence as read from the `instances` view. [id] is the instance row - * id; [taskId] is the underlying `tasks._id` and the stable target for edits. + * One occurrence of a task. [taskId] is the underlying task row and the stable + * target for edits and navigation; [occurrenceStart] distinguishes occurrences of + * the same series. */ data class Task( - val id: Long, val taskId: Long, val listId: Long, val title: String, @@ -49,12 +49,21 @@ data class Task( val accountName: String?, val parentId: Long?, /** - * This row carries a recurrence rule, so [id] is one occurrence of a series - * and [start]/[due] are that occurrence's resolved times — *not* the master's - * anchor. Edits must go through the instances URI (see - * [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]). + * This row carries a recurrence rule, so it is one occurrence of a series and + * [start]/[due] are that occurrence's resolved times — *not* the master's + * anchor. Edits go through + * [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which + * forks a `RECURRENCE-ID` override instead of re-anchoring the series. */ 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 created: Instant?, val lastModified: Instant?, @@ -71,6 +80,15 @@ data class Task( val isSubtask: Boolean get() = parentId != null && parentId > 0 /** The task's own colour if set, else the list colour. */ 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. */ @@ -91,22 +109,22 @@ fun priorityFromICal(value: Int?): Priority = when { /** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */ fun Priority.toICal(): Int = when (this) { - Priority.NONE -> TasksContract.PRIORITY_NONE + Priority.NONE -> PRIORITY_NONE Priority.HIGH -> 1 Priority.MEDIUM -> 5 Priority.LOW -> 9 } fun statusFromInt(value: Int?): TaskStatus = when (value) { - TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS - TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED - TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED + ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS + ICalStatus.COMPLETED -> TaskStatus.COMPLETED + ICalStatus.CANCELLED -> TaskStatus.CANCELLED else -> TaskStatus.NEEDS_ACTION } fun TaskStatus.toInt(): Int = when (this) { - TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION - TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS - TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED - TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED + TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION + TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS + TaskStatus.COMPLETED -> ICalStatus.COMPLETED + TaskStatus.CANCELLED -> ICalStatus.CANCELLED } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt new file mode 100644 index 0000000..fd3e8c3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt @@ -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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 90bdff0..b3f7439 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -419,7 +419,7 @@ private fun SearchResults( } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(results, key = { it.id }) { task -> + items(results, key = { it.occurrenceKey }) { task -> UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index c4c6a0b..4294383 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -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. */ private sealed interface ListRow { - val key: Long + val key: String 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 { - 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. */ data class AddSub(val parent: Task) : ListRow { - // Negative so it never collides with a real (positive) provider task id. - override val key: Long get() = -parent.taskId + // Prefixed so it never collides with the task row it belongs to. + override val key: String get() = "add-${parent.occurrenceKey}" } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt index 1876328..781b02a 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt @@ -36,7 +36,6 @@ class TaskMapperTest { val task = TaskMapper.task(reader) - assertThat(task.id).isEqualTo(42L) assertThat(task.taskId).isEqualTo(7L) assertThat(task.listId).isEqualTo(3L) assertThat(task.title).isEqualTo("Buy milk") @@ -50,6 +49,40 @@ class TaskMapperTest { assertThat(task.isSubtask).isTrue() } + @Test + fun `an occurrence is identified by its recurrence-id anchor`() { + fun occurrence(columns: Map) = + 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 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; diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt index 3af991d..d1853d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt @@ -17,7 +17,7 @@ class TaskSortingTest { 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 @@ -27,6 +27,6 @@ class TaskSortingTest { 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() } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index 11f58c6..6967c97 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -11,7 +11,6 @@ fun testTask( priority: Priority = Priority.NONE, due: Instant? = null, ): Task = Task( - id = id, taskId = id, listId = listId, title = title, @@ -32,6 +31,7 @@ fun testTask( accountName = null, parentId = null, isRecurring = false, + occurrenceStart = null, distanceFromCurrent = 0, created = null, lastModified = null, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9f5f9b..1b40b5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ composeBom = "2026.05.01" # Re-evaluate when 1.5.0 stable lands. material3 = "1.5.0-alpha21" 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). documentfile = "1.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-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 androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } From 96a2995df4d411fa91c996f4e6c23af06d53faa0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 15:58:52 +0200 Subject: [PATCH 08/21] build: add lib-recur to :app, and a dmfs v23 fixture for the import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The own store expands recurrences itself, so lib-recur is a direct dependency now rather than something :provider drags in. Still pinned at 0.12.2 — 0.16.0 removed RecurrenceSet. scripts/make_import_fixture.py writes the tasks.db the one-shot import will be tested against: the provider's DATABASE_VERSION 23 schema, seeded with the cases the import has to get right (a task with no UID, a deleted row, a recurring series, an all-day task, a subtask, an alarm property, and a list under a real CalDAV account). The provider is being deleted, so a fixture is the only way to keep testing against the schema it wrote. --- app/build.gradle.kts | 6 + app/src/androidTest/assets/tasks-v23.db | Bin 0 -> 20480 bytes scripts/make_import_fixture.py | 142 ++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 app/src/androidTest/assets/tasks-v23.db create mode 100644 scripts/make_import_fixture.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 488e8ca..6f0249f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,6 +169,12 @@ dependencies { implementation(libs.androidx.navigation.compose) ksp(libs.hilt.compiler) + // RFC 5545 recurrence expansion, in-process. Pulled in directly rather than + // through :provider: the own store expands occurrences itself. Pinned at + // 0.12.2 — 0.16.0 removed RecurrenceSet. rfc5545-datetime comes with it and + // is part of its API surface, so it isn't declared separately. + implementation(libs.dmfs.lib.recur) + implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db new file mode 100644 index 0000000000000000000000000000000000000000..e305e90abadb448158ee637c3791555232f6f13d GIT binary patch literal 20480 zcmeI4&1)M+6u@UCEB3ar)f9|qgJIGT>JUYiV&}sR36X8Z4v|0N$W09evDqElgIBxq z?8=E<3JxtLmqJgy_|zWTOMB@hP!cFTwEsa4J@nX9q4eC&tafK-W!cD=Ldjdu>g~+z zdvAX8=863n-M?3*KEZ1)YZyKrM{giaL$@$S2xY<#{|dG=-}i;G_|o_%ryO?^GpMk; zHOOoF`uEV#)Zm}`w_Jb?2mk>f00e-*D^B3k(Z2rNnKRnc)4uW0CQHn166RCl#ZqQ^ zxl*oGaIHL5t>CyEkG_V(t=T?pr%o+=XQ5V^tt{iEFD2EPQl~dT-@P5jjFicONTDok3oF$s4=C!}BpeglH4R@xOFJyqiQASW3X6CqqfMnv zi`ijom#rHP-6bqMZZhgJ>hBy3y9*LIn#!=`O`o^t3mnl|x?oD*?Z%&4kPy}DuB zmJyr4d;(_88K9EG4GCF zZ@3m+qumsAaoyhy!C5V35?d{%4hiE^H5@J3Bl0*d#EiA%a5fDlSj34Xk3E-XC}+K6 zST_to(qIkAC67g!2;+82i+kFnj?=A2e5}{0W7u`-a5j!f>SBfnz*qZA|L zFuO=iqAbc>LXL8ZkfU@WNX*7-vt%8n5a;@Ui&okZE^*^l`bY(M}A00AHX1b_e#00KY&2mk>f00e-5N?dIB+F1D{D73p<+b1(W8nb zely?Y_t^OPyB}`0um4}7?BCfxvM=L)fCL1901yBIKmZ5;0U!VbfB+Bx0zd!={2vGm zrPF7$CHeAAT2G~OTJUn1u4`PZiWfD~;`(2I{Y#YnJbQWQ#n9Z~*MsLyet+@`FM6RRn_vB?58J)`vSk~wZoTrBeK z;)K{;6FYGjZj(C$!o91~G76qO3ipzmK7eBPQsUwRQwJM(BYun-c_%#W!SprO&rRzW z6gv?arHG6Cd1!2UTC63o#mn3N24A^wOq*9fIw5SHQw(sMnM_7MFdB-@8ZjE!U0d_` zEB4y{kAHoKRI}RtPj8<@J#_fWld~CNy(1R5b@Z;_eO-E$fc=0k@nAtLG;PE2y`*gc zyV4TZhGSEN+0@bYU%aKE+sl=EHy%_fcdM({r&edmt2gEs^R>AiyuAFVFM%i{mXUKj zM@Pgm5o={Ew_ykmo;~|v&q}F|_F#B|rH_mUJ;IZqGi&f!!uT{X315hmfYXD`@%K|5 zBccg2-DYQGoqtdBMJB<3@CW!lzxEzgd#dgKTKHveOA~rWl#`;^PI652WvaD<8`R!R zs{47s>18^V+Ry)s;{E^aY*DccA`k!qKmZ5;0U!VbfB+Bx0zd!=0D)JSz)X4s>GSCH zAqch|t6(_6i~a?(#b2~%1%CUdF~iuA+O1plv5 enwTh-f?IvmFWuC*KeOmm7IoEhq@wX+sq`;1!{ None: + lists = db.executemany( + "INSERT INTO Lists (_id, account_name, account_type, list_name, list_color," + " visible, sync_enabled, list_owner) VALUES (?,?,?,?,?,?,?,?)", + [ + (1, *LOCAL, "Personal", 0xFF7A5C6B, 1, 1, None), + (2, *LOCAL, "Hidden list", 0xFF445566, 0, 1, None), + # An external account inside our own authority: only reachable if the + # user pointed DAVx5 at us. Imported as a local list, UID preserved. + (3, *CALDAV, "Work", 0xFF2244AA, 1, 1, "Me"), + ], + ) + del lists + + def task(**kw): + cols = ", ".join(kw) + marks = ", ".join("?" * len(kw)) + db.execute(f"INSERT INTO Tasks ({cols}) VALUES ({marks})", tuple(kw.values())) + + task(_id=1, list_id=1, title="Buy milk", due=T0 + DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000001", created=T0, last_modified=T0) + # No UID: the import mints one. + task(_id=2, list_id=1, title="Call the dentist", due=T0 + 2 * DAY, status=1, + percent_complete=40, created=T0, last_modified=T0) + task(_id=3, list_id=1, title="Gather receipts", parent_id=1, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000003", created=T0, last_modified=T0) + task(_id=4, list_id=1, title="Renew domain", status=2, percent_complete=100, + completed=T0 - DAY, is_closed=1, + _uid="a1b2c3d4-0000-4000-8000-000000000004", created=T0, last_modified=T0) + task(_id=5, list_id=1, title="Water the plants", dtstart=T0, due=T0 + 3600_000, + rrule="FREQ=WEEKLY;BYDAY=MO,TH", tz="Europe/Berlin", + _uid="a1b2c3d4-0000-4000-8000-000000000005", created=T0, last_modified=T0) + task(_id=6, list_id=1, title="Team offsite", dtstart=T0 - T0 % DAY, + due=T0 - T0 % DAY + DAY, is_allday=1, + _uid="a1b2c3d4-0000-4000-8000-000000000006", created=T0, last_modified=T0) + # Deleted-but-unsynced: gone as far as the user is concerned, so not imported. + task(_id=7, list_id=1, title="Cancelled thing", _deleted=1, + _uid="a1b2c3d4-0000-4000-8000-000000000007", created=T0, last_modified=T0) + task(_id=8, list_id=2, title="Task in a hidden list", status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000008", created=T0, last_modified=T0) + task(_id=9, list_id=3, title="Ship the release", due=T0 + 5 * DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000009", created=T0, last_modified=T0, + _sync_id="https://dav.example.org/tasks/9.ics") + + db.executemany( + "INSERT INTO Properties (property_id, task_id, mimetype, data0, data1, data2, data3)" + " VALUES (?,?,?,?,?,?,?)", + [ + # data0 minutes before, data1 reference (1 = DUE), data3 alarm type. + (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), + (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), + # A non-alarm property the import must skip. + (3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), + ], + ) + + +def main() -> int: + os.makedirs(os.path.dirname(OUT), exist_ok=True) + if os.path.exists(OUT): + os.remove(OUT) + db = sqlite3.connect(OUT) + try: + for statement in DDL: + db.execute(statement) + db.execute("PRAGMA user_version = 23") + seed(db) + db.commit() + finally: + db.close() + print(f"wrote {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From fd8363e35669c2d942250bc37d2f8aa461c1be87 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:09:11 +0200 Subject: [PATCH 09/21] feat(store): add the Room schema, DAOs and exported schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/OWN-STORE.md. Four tables — task_lists, tasks, task_alarms, accounts — with the indices, cascades and converters the plan specifies, plus a DAO per table and the v1 schema JSON committed for migration testing. Masters and RECURRENCE-ID overrides share the tasks table, so the unique index is on (list_id, uid, recurrence_id): an override shares its master's UID, and a key without recurrence_id would reject exactly the rows recurrence depends on. SQLite treats NULLs as distinct, so that index only enforces the override half; the master half is intent, noted where the index is declared. PRIORITY is stored as the raw iCalendar integer rather than through the Priority enum. Priority buckets 1..4 into HIGH, so a converter would rewrite a server's PRIORITY:3 as 1 before it ever reached disk — the bucketing belongs in the mapper. Status keeps its converter: that mapping is total. Two cascades the plan left unstated: deleting a list takes its tasks, deleting an account only detaches its lists. Instrumented tests cover read-back, the cascades and the unique index — app/src/androidTest is new. --- .../1.json | 522 ++++++++++++++++++ .../data/tasks/room/TasksDatabaseTest.kt | 274 +++++++++ .../agendula/data/tasks/room/AccountDao.kt | 30 + .../agendula/data/tasks/room/Converters.kt | 38 ++ .../agendula/data/tasks/room/Entities.kt | 213 +++++++ .../agendula/data/tasks/room/Projections.kt | 26 + .../agendula/data/tasks/room/TaskAlarmDao.kt | 31 ++ .../agendula/data/tasks/room/TaskDao.kt | 124 +++++ .../agendula/data/tasks/room/TaskListDao.kt | 51 ++ .../agendula/data/tasks/room/TasksDatabase.kt | 34 ++ .../data/tasks/room/ConvertersTest.kt | 53 ++ 11 files changed, 1396 insertions(+) create mode 100644 app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json new file mode 100644 index 0000000..485362a --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json @@ -0,0 +1,522 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c94852274d874fe255ee76e1e46a3003", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')" + ] + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..17053f9 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt @@ -0,0 +1,274 @@ +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.domain.TaskStatus +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Instant + +/** + * The schema, exercised through the DAOs. Instrumented rather than JVM because + * the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a + * real SQLite. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseTest { + + private lateinit var db: TasksDatabase + private lateinit var lists: TaskListDao + private lateinit var tasks: TaskDao + private lateinit var alarms: TaskAlarmDao + private lateinit var accounts: AccountDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + TasksDatabase::class.java, + ).allowMainThreadQueries().build() + lists = db.taskLists() + tasks = db.tasks() + alarms = db.alarms() + accounts = db.accounts() + } + + @After + fun tearDown() = db.close() + + private fun newList(name: String = "Groceries", accountId: Long? = null): Long = + lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId)) + + private fun newTask( + listId: Long, + uid: String = "uid-${counter++}", + title: String? = "Buy milk", + status: TaskStatus = TaskStatus.NEEDS_ACTION, + parentId: Long? = null, + masterId: Long? = null, + recurrenceId: Instant? = null, + ): Long = tasks.insert( + TaskEntity( + listId = listId, + uid = uid, + title = title, + status = status, + parentId = parentId, + masterId = masterId, + recurrenceId = recurrenceId, + ), + ) + + @Test + fun writesAndReadsAListWithItsTasks() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + val due = Instant.fromEpochMilliseconds(1_700_000_000_000) + val taskId = tasks.insert( + TaskEntity( + listId = listId, + uid = "uid-1", + title = "Buy milk", + description = "2%", + due = due, + priority = 3, + status = TaskStatus.IN_PROCESS, + percentComplete = 40, + ), + ) + + val list = lists.lists().single() + assertThat(list.list.id).isEqualTo(listId) + assertThat(list.list.name).isEqualTo("Groceries") + assertThat(list.accountDisplayName).isEqualTo("Fastmail") + + val row = tasks.task(taskId)!! + assertThat(row.task.title).isEqualTo("Buy milk") + assertThat(row.task.due).isEqualTo(due) + // Stored raw: an off-bucket PRIORITY must come back as it went in. + assertThat(row.task.priority).isEqualTo(3) + assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(row.task.percentComplete).isEqualTo(40) + assertThat(row.listName).isEqualTo("Groceries") + assertThat(row.accountDisplayName).isEqualTo("Fastmail") + } + + @Test + fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() { + val a = newList("A") + val b = newList("B") + newTask(a, title = "open") + newTask(a, title = "done", status = TaskStatus.COMPLETED) + newTask(a, title = "cancelled", status = TaskStatus.CANCELLED) + newTask(b, title = "elsewhere") + + assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title }) + .containsExactly("open") + assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3) + assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4) + } + + @Test + fun readsSubtasksByParent() { + val listId = newList() + val parent = newTask(listId, title = "parent") + newTask(listId, title = "child", parentId = parent) + + assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child") + } + + @Test + fun hidesTombstonesFromReadsAndExports() { + val listId = newList() + val taskId = newTask(listId) + tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1)) + + assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty() + assertThat(tasks.task(taskId)).isNull() + assertThat(tasks.exportTasks(listId)).isEmpty() + assertThat(tasks.entity(taskId)).isNotNull() + } + + @Test + fun keepsOverridesOutOfTheMasterReads() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + 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.override(master, Instant.fromEpochMilliseconds(5_000))?.id) + .isEqualTo(override) + assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master) + } + + // --- cascades ------------------------------------------------------------- + + @Test + fun deletingAListDeletesItsTasks() { + val listId = newList() + val taskId = newTask(listId) + + lists.delete(listId) + + assertThat(tasks.entity(taskId)).isNull() + } + + @Test + fun deletingASeriesDeletesItsOverrides() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + tasks.delete(master) + + assertThat(tasks.entity(override)).isNull() + } + + @Test + fun deletingAParentPromotesItsSubtasks() { + val listId = newList() + val parent = newTask(listId, title = "parent") + val child = newTask(listId, title = "child", parentId = parent) + + tasks.delete(parent) + + val promoted = tasks.entity(child) + assertThat(promoted).isNotNull() + assertThat(promoted!!.parentId).isNull() + } + + @Test + fun deletingATaskDeletesItsAlarms() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + assertThat(alarms.all()).hasSize(1) + + tasks.delete(taskId) + + assertThat(alarms.all()).isEmpty() + } + + @Test + fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + + accounts.delete(accountId) + + assertThat(lists.entity(listId)!!.accountId).isNull() + } + + @Test + fun replacingAnAlarmLeavesOnlyTheNewOne() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30)) + + assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30) + assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE) + + alarms.replaceForTask(taskId, null) + assertThat(alarms.forTask(taskId)).isEmpty() + } + + // --- the unique index ----------------------------------------------------- + + @Test + fun anOverrideMayShareItsMastersUid() { + val listId = newList() + val master = newTask(listId, uid = "series") + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1)) + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2)) + + assertThat(tasks.overrides(master)).hasSize(2) + } + + @Test + fun rejectsTwoOverridesOfTheSameOccurrence() { + val listId = newList() + val master = newTask(listId, uid = "series") + val at = Instant.fromEpochMilliseconds(1) + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + + val failure = runCatching { + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + }.exceptionOrNull() + + assertThat(failure).isNotNull() + assertThat(failure!!.message).contains("UNIQUE") + } + + @Test + fun theSameUidMayExistInAnotherList() { + val a = newList("A") + val b = newList("B") + newTask(a, uid = "shared") + newTask(b, uid = "shared") + + assertThat(tasks.byUid(a, "shared")).isNotNull() + assertThat(tasks.byUid(b, "shared")).isNotNull() + } + + private companion object { + var counter = 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt new file mode 100644 index 0000000..e21f14a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlin.time.Instant + +/** Reads and writes over `accounts`. Unused until sync lands. */ +@Dao +interface AccountDao { + + @Query("SELECT * FROM accounts ORDER BY display_name") + fun all(): List + + @Query("SELECT * FROM accounts WHERE id = :accountId") + fun account(accountId: Long): AccountEntity? + + @Insert + fun insert(account: AccountEntity): Long + + @Update + fun update(account: AccountEntity) + + @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") + fun recordSync(accountId: Long, at: Instant?, error: String?) + + @Query("DELETE FROM accounts WHERE id = :accountId") + fun delete(accountId: Long): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt new file mode 100644 index 0000000..f4eae17 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.TypeConverter +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.statusFromInt +import de.jeanlucmakiola.agendula.domain.toInt +import kotlin.time.Instant + +/** + * Storage encodings for the entity types SQLite has no column type for. Time is + * epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering + * keeps its single home. + * + * `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar + * integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy + * bucketing and a converter would apply it before the value reaches disk. + */ +object Converters { + + @TypeConverter + fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds() + + @TypeConverter + fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds) + + @TypeConverter + fun statusToInt(value: TaskStatus): Int = value.toInt() + + @TypeConverter + fun statusFrom(value: Int): TaskStatus = statusFromInt(value) + + @TypeConverter + fun alarmReferenceToString(value: AlarmReference): String = value.name + + @TypeConverter + fun alarmReferenceFrom(value: String): AlarmReference = + runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt new file mode 100644 index 0000000..ba58e5b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -0,0 +1,213 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK + * from [TaskListEntity] exists from v1 so turning sync on never needs a + * migration. The app password is never stored here — Keystore only. + */ +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "principal_url") val principalUrl: String? = null, + @ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null, + @ColumnInfo(name = "username") val username: String? = null, + @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, + @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, +) + +/** + * A task list. [accountId] is nullable: `NULL` is a device-only list, and + * attaching one to an account later is a plain `UPDATE` rather than a data + * migration. + * + * Deleting an account detaches its lists (`SET NULL`) instead of deleting them, + * for the same reason [TaskEntity.parentId] does — removing an account is not + * an instruction to destroy the tasks it held. + */ +@Entity( + tableName = "task_lists", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["account_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index(value = ["account_id"])], +) +data class TaskListEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + /** ARGB. */ + @ColumnInfo(name = "color") val color: Int, + @ColumnInfo(name = "account_id") val accountId: Long? = null, + @ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true, + @ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true, + /** CalDAV owner display name. */ + @ColumnInfo(name = "owner") val owner: String? = null, + @ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false, + /** User ordering. */ + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + /** Collection URL, relative to the account root. */ + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "ctag") val ctag: String? = null, + /** RFC 6578 sync token, per collection. */ + @ColumnInfo(name = "sync_token") val syncToken: String? = null, + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, +) + +/** + * A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an + * override is a row with [recurrenceId] set and [masterId] pointing at its + * master, sharing the master's [uid]. + * + * [masterId] and [parentId] are different things: [parentId] is task hierarchy + * (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both. + */ +@Entity( + tableName = "tasks", + foreignKeys = [ + ForeignKey( + entity = TaskListEntity::class, + parentColumns = ["id"], + childColumns = ["list_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a series takes its overrides with it — they would otherwise be + // unreachable rows that still sync. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["master_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a parent promotes its subtasks to top level rather than + // destroying work the user did not ask to lose. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index(value = ["list_id", "is_deleted"]), + Index(value = ["parent_id"]), + Index(value = ["master_id", "recurrence_id"]), + Index(value = ["is_dirty"]), + // An override shares its master's UID, so (list_id, uid) alone would + // reject the very rows recurrence depends on. With recurrence_id NULL on + // the master and set on each override this reads as: one master and at + // most one override per occurrence, per UID, per list. Note SQLite treats + // NULLs as distinct in a unique index, so the master half is a statement + // of intent, not an enforced constraint. + Index(value = ["list_id", "uid", "recurrence_id"], unique = true), + ], +) +data class TaskEntity( + // identity + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "list_id") val listId: Long, + /** RFC 4122 UUID, minted at creation in every mode, synced or not. */ + @ColumnInfo(name = "uid") val uid: String, + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "etag") val etag: String? = null, + + // content + @ColumnInfo(name = "title") val title: String? = null, + @ColumnInfo(name = "description") val description: String? = null, + @ColumnInfo(name = "location") val location: String? = null, + @ColumnInfo(name = "url") val url: String? = null, + /** ARGB override for the list colour. */ + @ColumnInfo(name = "color") val color: Int? = null, + + // state + @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, + @ColumnInfo(name = "percent_complete") val percentComplete: Int? = null, + @ColumnInfo(name = "completed_at") val completedAt: Instant? = null, + /** + * Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed — + * [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so + * converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and + * lose it on the next round-trip. The bucketing belongs to the mapper, which + * is where the UI needs it. + */ + @ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE, + /** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */ + @ColumnInfo(name = "classification") val classification: Int? = null, + + // time + @ColumnInfo(name = "dtstart") val dtstart: Instant? = null, + @ColumnInfo(name = "due") val due: Instant? = null, + /** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */ + @ColumnInfo(name = "duration") val duration: String? = null, + @ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false, + @ColumnInfo(name = "timezone") val timezone: String? = null, + + // recurrence + @ColumnInfo(name = "rrule") val rrule: String? = null, + @ColumnInfo(name = "rdate") val rdate: String? = null, + @ColumnInfo(name = "exdate") val exdate: String? = null, + /** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */ + @ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null, + /** The series this row overrides; `NULL` on a master. */ + @ColumnInfo(name = "master_id") val masterId: Long? = null, + + // hierarchy + @ColumnInfo(name = "parent_id") val parentId: Long? = null, + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + + // audit + @ColumnInfo(name = "created_at") val createdAt: Instant? = null, + @ColumnInfo(name = "last_modified") val lastModified: Instant? = null, + @ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0, + + // sync + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, + /** Tombstone: deleted locally, still owed to a server. */ + @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * Raw unfolded iCalendar lines of every property we do not model, re-emitted + * verbatim on write so a round-trip cannot silently lose a field. + */ + @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, +) + +/** What [TaskAlarmEntity.minutesBefore] counts back from. */ +enum class AlarmReference { DUE, START } + +/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ +@Entity( + tableName = "task_alarms", + foreignKeys = [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["task_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["task_id"])], +) +data class TaskAlarmEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "task_id") val taskId: Long, + @ColumnInfo(name = "minutes_before") val minutesBefore: Int, + @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, + @ColumnInfo(name = "message") val message: String? = null, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt new file mode 100644 index 0000000..619b0fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A list plus its account's display name, which the domain + * [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by. + * `null` means a device-only list. + */ +data class TaskListRow( + @Embedded val list: TaskListEntity, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) + +/** + * A task plus the three columns of its list the domain + * [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is + * one query rather than one per list. + */ +data class TaskRow( + @Embedded val task: TaskEntity, + @ColumnInfo(name = "list_name") val listName: String, + @ColumnInfo(name = "list_color") val listColor: Int, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt new file mode 100644 index 0000000..d77d15f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** Reads and writes over `task_alarms`. */ +@Dao +interface TaskAlarmDao { + + /** Every reminder in the store, for one scheduler pass. */ + @Query("SELECT * FROM task_alarms") + fun all(): List + + @Query("SELECT * FROM task_alarms WHERE task_id = :taskId") + fun forTask(taskId: Long): List + + @Insert + fun insert(alarm: TaskAlarmEntity): Long + + @Query("DELETE FROM task_alarms WHERE task_id = :taskId") + fun deleteForTask(taskId: Long): Int + + /** Set the task's only reminder, or clear it with `null`. */ + @Transaction + fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) { + deleteForTask(taskId) + alarm?.let { insert(it.copy(taskId = taskId)) } + } +} 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 new file mode 100644 index 0000000..82bce92 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * Reads and writes over `tasks`. + * + * 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 + * `RECURRENCE-ID` rows that replace individual occurrences. Nothing here + * expands anything — that is phase 2's job, in Kotlin. + */ +@Dao +interface TaskDao { + + // --- reads ---------------------------------------------------------------- + + /** + * Master (and non-recurring) rows, optionally narrowed to one list. Closed + * tasks — `COMPLETED` and `CANCELLED` — are excluded unless + * [includeCompleted]; tombstones always are. + */ + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.is_deleted = 0 + AND t.master_id IS NULL + AND (:listId IS NULL OR t.list_id = :listId) + AND (:includeCompleted = 1 OR t.status NOT IN (2, 3)) + """ + ) + fun tasks(listId: Long?, includeCompleted: Boolean): List + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.id = :taskId AND t.is_deleted = 0 + """ + ) + fun task(taskId: Long): TaskRow? + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL + """ + ) + fun subtasks(parentTaskId: Long): List + + @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 + + @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") + fun overrides(masterId: Long): List + + @Query( + "SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0" + ) + fun override(masterId: Long, recurrenceId: Instant?): TaskEntity? + + @Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId") + fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity? + + /** Masters only, tombstones excluded — what an `.ics` export writes. */ + @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") + fun exportTasks(listId: Long): List + + @Query("SELECT * FROM tasks WHERE is_dirty = 1") + fun dirty(): List + + // --- writes --------------------------------------------------------------- + + @Insert + fun insert(task: TaskEntity): Long + + @Update + fun update(task: TaskEntity): Int + + @Query( + """ + UPDATE tasks SET status = :status, percent_complete = :percentComplete, + completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty + WHERE id = :taskId + """ + ) + fun setCompletion( + taskId: Long, + status: TaskStatus, + percentComplete: Int?, + completedAt: Instant?, + lastModified: Instant?, + dirty: Boolean, + ): Int + + /** Hard delete. Used when the row was never on a server. */ + @Query("DELETE FROM tasks WHERE id = :taskId") + fun delete(taskId: Long): Int + + /** Tombstone, for a row a server still knows about. */ + @Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId") + fun markDeleted(taskId: Long, at: Instant?): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt new file mode 100644 index 0000000..f6017a6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */ +@Dao +interface TaskListDao { + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + ORDER BY a.display_name, l.sort_order, l.name + """ + ) + fun lists(): List + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + WHERE l.id = :listId + """ + ) + fun list(listId: Long): TaskListRow? + + @Query("SELECT * FROM task_lists WHERE id = :listId") + fun entity(listId: Long): TaskListEntity? + + @Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId") + fun exists(listId: Long): Int + + @Insert + fun insert(list: TaskListEntity): Long + + @Update + fun update(list: TaskListEntity) + + @Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId") + fun setVisible(listId: Long, visible: Boolean) + + /** Attach a list to an account, or detach it with `null`. */ + @Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId") + fun setAccount(listId: Long, accountId: Long?) + + @Query("DELETE FROM task_lists WHERE id = :listId") + fun delete(listId: Long) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt new file mode 100644 index 0000000..9b8aabf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -0,0 +1,34 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +/** + * Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from + * what the app actually reads and writes plus RFC 5545's `VTODO`. + * + * Schemas are exported to `app/schemas/` and committed, so a future version can + * be migration-tested against this one. + */ +@Database( + entities = [ + AccountEntity::class, + TaskListEntity::class, + TaskEntity::class, + TaskAlarmEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class TasksDatabase : RoomDatabase() { + abstract fun taskLists(): TaskListDao + abstract fun tasks(): TaskDao + abstract fun alarms(): TaskAlarmDao + abstract fun accounts(): AccountDao + + companion object { + const val NAME = "agendula-tasks.db" + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt new file mode 100644 index 0000000..2ef8067 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -0,0 +1,53 @@ +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.priorityFromICal +import de.jeanlucmakiola.agendula.domain.toICal +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ConvertersTest { + + @Test + fun `round-trips an instant through epoch millis`() { + val value = Instant.fromEpochMilliseconds(1_700_000_000_123) + + val stored = Converters.instantToMillis(value) + + assertThat(stored).isEqualTo(1_700_000_000_123) + assertThat(Converters.instantFromMillis(stored)).isEqualTo(value) + } + + @Test + fun `maps null time both ways`() { + assertThat(Converters.instantToMillis(null)).isNull() + assertThat(Converters.instantFromMillis(null)).isNull() + } + + @Test + fun `round-trips every status through the domain encoding`() { + TaskStatus.entries.forEach { status -> + assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status) + } + } + + @Test + fun `priority is stored raw, so an off-bucket value survives`() { + // There is no priority converter on purpose: PRIORITY:3 is a legitimate + // value a server can send, and Priority buckets 1..4 into HIGH. Bucketing + // on the way in would rewrite it as 1 and lose it on the next round-trip. + assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH) + assertThat(Priority.HIGH.toICal()).isEqualTo(1) + } + + @Test + fun `round-trips an alarm reference and falls back on an unknown one`() { + AlarmReference.entries.forEach { reference -> + assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference))) + .isEqualTo(reference) + } + assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) + } +} From 829a27da82f58f9b717830b08d66c6c45b719c70 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:09:30 +0200 Subject: [PATCH 10/21] feat(recurrence): expand a series in memory over lib-recur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a stored rule set into its occurrences at read time — no materialised instances table, so none of its staleness bugs exist. Each occurrence is returned as its RECURRENCE-ID anchor, which is what the seam now addresses occurrences by. Expansion is bounded two ways: the window end, and a hard occurrence ceiling. The iterator is fast-forwarded to the window start first, so a FREQ=MINUTELY series anchored years back doesn't scan millions of instances to emit one. Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the start itself, so DTSTART is in the set for free and EXDATE can remove it (RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL against a zoned start throws, so the UNTIL's local fields are re-read in the series zone — the vendored provider worked around the same thing via TimeZone.getDefault(), which isn't deterministic. Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with an unparseable stored rule still has to appear. 38 tests. Multi-occurrence expansion has no provider behaviour to compare against, so the reference is RFC 5545 directly — daily/weekly/monthly/ yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds. --- .../domain/recurrence/RecurrenceExpander.kt | 150 +++++++ .../recurrence/DistanceFromCurrentTest.kt | 59 +++ .../recurrence/RecurrenceExpanderTest.kt | 402 ++++++++++++++++++ 3 files changed, 611 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt new file mode 100644 index 0000000..1fa97e9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -0,0 +1,150 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import org.dmfs.rfc5545.DateTime +import org.dmfs.rfc5545.recur.RecurrenceRule +import org.dmfs.rfc5545.recurrenceset.RecurrenceList +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet +import java.time.ZoneId +import java.util.TimeZone +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** The rule set of one task series, as stored. All strings are raw iCalendar values. */ +data class RecurrenceSpec( + val rrule: String?, + val rdate: String?, + val exdate: String?, + /** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */ + val anchor: Instant, + val isAllDay: Boolean, + /** IANA zone id the anchor is expressed in; null means floating/local. */ + val timeZone: String?, +) + +/** + * The window expansion is bounded to: [from] inclusive, [until] exclusive, and + * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + */ +data class ExpansionWindow( + val from: Instant, + val until: Instant, + val maxOccurrences: Int = 500, +) + +/** + * Expands a task series into its occurrences in memory, over `lib-recur`. + * + * There is no materialised instances table behind this: the repository already + * filters and sorts in Kotlin, so occurrences are computed at read time and the + * whole class of staleness bugs a cached table brings never exists. + */ +object RecurrenceExpander { + + /** + * Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor — + * the instant identifying that occurrence within the series. Ascending, + * deduplicated, `EXDATE` applied. + * + * The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART` + * is the first instance), so a spec with no rule and no `RDATE` expands to + * exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped + * rather than thrown — a task whose stored rule cannot be parsed still has + * to appear. + * + * [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a + * parameter rather than a `TimeZone.getDefault()` lookup so expansion is + * deterministic under test. + */ + fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow, + floatingZone: ZoneId = ZoneId.systemDefault(), + ): List { + val zone = zoneOf(spec, floatingZone) + val anchorMillis = anchorMillis(spec) + + val set = RecurrenceSet() + spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } } + spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) } + spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) } + + val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) + iterator.fastForward(window.from.toEpochMilliseconds()) + + val occurrences = ArrayList() + var previous = Long.MIN_VALUE + while (occurrences.size < window.maxOccurrences && iterator.hasNext()) { + val millis = iterator.next() + if (millis == previous) continue + previous = millis + occurrences += Instant.fromEpochMilliseconds(millis) + } + return occurrences + } + + /** + * Index of the current occurrence in an ascending [occurrences] list: the + * first one at or after [now], or the last one when the whole series is in + * the past. `-1` when there are no occurrences at all. + */ + fun currentOccurrenceIndex(occurrences: List, now: Instant): Int { + if (occurrences.isEmpty()) return -1 + val next = occurrences.indexOfFirst { it >= now } + return if (next >= 0) next else occurrences.lastIndex + } + + /** + * Each occurrence's distance from the current one, index-aligned with + * [occurrences]. `0` is the current occurrence, negative counts back into the + * past and positive counts forward — the convention `Task.distanceFromCurrent` + * carries and the data sources pick the current occurrence by. + * + * Purely positional: unlike the dmfs provider, which drove the same number off + * each instance's closed state, this knows only times. Completion-aware + * refinement belongs where overrides carry their status. + */ + fun distancesFromCurrent(occurrences: List, now: Instant): List { + val current = currentOccurrenceIndex(occurrences, now) + if (current < 0) return emptyList() + return occurrences.indices.map { it - current } + } + + private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone { + if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC")) + val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } + return TimeZone.getTimeZone(stored ?: floatingZone) + } + + /** All-day series are date-anchored: pin the anchor to UTC midnight, as it is stored. */ + private fun anchorMillis(spec: RecurrenceSpec): Long { + val millis = spec.anchor.toEpochMilliseconds() + return if (!spec.isAllDay) millis else Math.floorDiv(millis, MILLIS_PER_DAY) * MILLIS_PER_DAY + } + + private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching { + RecurrenceRule(value).also { rule -> + // lib-recur refuses to iterate a floating UNTIL against a zoned start, + // and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it + // anyway. Re-read the UNTIL's local fields in the series zone. + val until = rule.until + if (until != null && until.isFloating) { + rule.until = DateTime( + zone, + until.year, + until.month, + until.dayOfMonth, + until.hours, + until.minutes, + until.seconds, + ) + } + } + }.getOrNull() + + private fun datesOf(value: String, zone: TimeZone): RecurrenceList? = + runCatching { RecurrenceList(value, zone) }.getOrNull() + + private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt new file mode 100644 index 0000000..2e0e9b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class DistanceFromCurrentTest { + + private fun at(text: String) = Instant.parse(text) + + private val occurrences = listOf( + at("2025-01-05T09:00:00Z"), + at("2025-01-06T09:00:00Z"), + at("2025-01-07T09:00:00Z"), + at("2025-01-08T09:00:00Z"), + ) + + @Test + fun `the current occurrence is the first one at or after now`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(index).isEqualTo(2) + } + + @Test + fun `an occurrence exactly at now is the current one`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z")) + assertThat(index).isEqualTo(1) + } + + @Test + fun `past occurrences count down and later ones count up`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder() + } + + @Test + fun `a series entirely in the future is current at its first occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z")) + assertThat(distances).containsExactly(0, 1, 2, 3).inOrder() + } + + @Test + fun `a series entirely in the past is current at its last occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z")) + assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder() + } + + @Test + fun `exactly one occurrence is ever the current one`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z")) + assertThat(distances.count { it == 0 }).isEqualTo(1) + } + + @Test + fun `an empty series has no distances`() { + assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty() + assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt new file mode 100644 index 0000000..98baa1e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -0,0 +1,402 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Instant + +/** + * The provider only ever materialised the single next occurrence, so there is no + * provider behaviour to compare multi-occurrence expansion against. These cases + * assert against RFC 5545 §3.8.5 directly. + */ +class RecurrenceExpanderTest { + + private val berlin = "Europe/Berlin" + private val newYork = ZoneId.of("America/New_York") + + private fun at(text: String) = Instant.parse(text) + + private fun spec( + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + anchor: String, + isAllDay: Boolean = false, + timeZone: String? = berlin, + ) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone) + + private fun window( + from: String = "2000-01-01T00:00:00Z", + until: String = "2100-01-01T00:00:00Z", + max: Int = 500, + ) = ExpansionWindow(at(from), at(until), max) + + private fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow = window(), + floatingZone: ZoneId = newYork, + ) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() } + + // --- frequencies --------------------------------------------------------- + + @Test + fun `daily rule yields consecutive days at the same local time`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `interval skips the intervening days`() { + val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + "2025-01-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `weekly by day expands to the named weekdays`() { + // The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is + // the first instance of the set regardless (RFC 5545 §3.8.5.3), so the + // Tuesday leads and the pattern takes over from there. + val result = expand( + spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", // Tue, the anchor + "2025-01-08T08:00:00Z", // Wed + "2025-01-10T08:00:00Z", // Fri + "2025-01-13T08:00:00Z", // Mon + "2025-01-15T08:00:00Z", // Wed + ).inOrder() + } + + @Test + fun `monthly by day expands to the nth weekday of the month`() { + // 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the + // anchor still leads. + val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-14T08:00:00Z", + "2025-02-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `monthly by month day skips months that lack the day`() { + val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-31T08:00:00Z", + "2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST + "2025-05-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule repeats on the anniversary`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2026-01-07T08:00:00Z", + "2027-01-07T08:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule on a leap day only recurs in leap years`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z")) + assertThat(result).containsExactly( + "2024-02-29T08:00:00Z", + "2028-02-29T08:00:00Z", + "2032-02-29T08:00:00Z", + ).inOrder() + } + + // --- limits -------------------------------------------------------------- + + @Test + fun `COUNT limits the series`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).hasSize(2) + } + + @Test + fun `UNTIL includes an occurrence falling exactly on it`() { + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a floating UNTIL is read in the series zone`() { + // RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and + // lib-recur throws outright on the mismatch. Stored rules break the rule + // anyway, so 09:00 floating has to mean 09:00 in Berlin. + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unbounded rule stops at the occurrence ceiling`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(max = 4), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `an unbounded rule stops at the window end`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-10T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `the window end is exclusive`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-09T08:00:00Z"), + ) + assertThat(result).doesNotContain("2025-01-09T08:00:00Z") + } + + @Test + fun `occurrences before the window start are skipped`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-06-01T07:00:00Z", + "2025-06-02T07:00:00Z", + "2025-06-03T07:00:00Z", + ).inOrder() + } + + // --- DST and all-day ----------------------------------------------------- + + @Test + fun `a daily series keeps its local time across a DST boundary`() { + // Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from + // 08:00Z to 07:00Z while the wall-clock time the user set stays put. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z")) + assertThat(result).containsExactly( + "2025-03-28T08:00:00Z", + "2025-03-29T08:00:00Z", + "2025-03-30T07:00:00Z", + "2025-03-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day series pins every occurrence to UTC midnight`() { + // Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and + // the device zone are both irrelevant, and no DST shift reaches it. + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + anchor = "2025-03-29T22:45:00Z", + isAllDay = true, + timeZone = berlin, + ), + ) + assertThat(result).containsExactly( + "2025-03-29T00:00:00Z", + "2025-03-30T00:00:00Z", + "2025-03-31T00:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day weekly series stays on the same weekday`() { + val result = expand( + spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-22T00:00:00Z", + "2025-01-29T00:00:00Z", + ).inOrder() + } + + @Test + fun `a series with no zone expands in the floating zone`() { + val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null) + // 09:00 in New York, over the 2025-03-09 US DST switch. + assertThat(expand(spec, floatingZone = newYork)).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + // --- RDATE / EXDATE ------------------------------------------------------ + + @Test + fun `RDATE adds occurrences the rule does not produce`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=2", + rdate = "20250115T140000", + anchor = "2025-01-07T08:00:00Z", + ), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-15T13:00:00Z", // 14:00 Berlin + ).inOrder() + } + + @Test + fun `an RDATE before the anchor is part of the set`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z") + } + + @Test + fun `an RDATE repeating a rule instance is not emitted twice`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `multiple RDATEs are comma-separated`() { + val result = expand( + spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-10T08:00:00Z", + "2025-01-12T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE removes an occurrence`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE can remove the anchor itself`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a UTC EXDATE matches a zoned occurrence at the same instant`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day EXDATE removes the matching date`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + exdate = "20250116", + anchor = "2025-01-15T00:00:00Z", + isAllDay = true, + ), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-17T00:00:00Z", + ).inOrder() + } + + // --- degenerate input ---------------------------------------------------- + + @Test + fun `a spec with no rule expands to just its anchor`() { + assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed rule degrades to the anchor instead of throwing`() { + assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed RDATE is dropped and the rule still expands`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unknown zone id falls back to the floating zone`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"), + floatingZone = newYork, + ) + assertThat(result).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + @Test + fun `a window that ends before the anchor yields nothing`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2024-01-01T00:00:00Z"), + ) + assertThat(result).isEmpty() + } +} From 2e915da588dfc5992a3cf2251343766da41ed6e9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:16:17 +0200 Subject: [PATCH 11/21] feat(store): implement TasksDataSource over Room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tasks/room/RoomTasksDataSourceTest.kt | 286 ++++++++++++++++++ .../data/tasks/room/TasksDatabaseTest.kt | 2 +- .../agendula/data/di/DataModule.kt | 38 ++- .../data/tasks/ModeRoutingTasksDataSource.kt | 51 ++++ .../data/tasks/room/RoomTaskMapper.kt | 114 +++++++ .../data/tasks/room/RoomTasksDataSource.kt | 215 +++++++++++++ .../agendula/data/tasks/room/TaskDao.kt | 18 +- .../data/tasks/room/TaskFormWriter.kt | 91 ++++++ .../data/tasks/room/TaskFormWriterTest.kt | 152 ++++++++++ 9 files changed, 958 insertions(+), 9 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt 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) + } +} From 76f9ae6780d8b10cb4d8973b9c1d4a360acb5035 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:24:19 +0200 Subject: [PATCH 12/21] feat(store): import the dmfs database and make Room the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of docs/OWN-STORE.md. OneShotImport reads databases/tasks.db directly — read-only, no provider, no ContentResolver — and writes it into Room in one verified transaction. dmfs row ids are remapped in two passes, because a parent can carry a higher _id than its child. The archive happens before the import, not after, and the import always replaces. That is what actually closes the crash window the plan's "flag *and* rename" is meant to cover: renaming last leaves the flag unset with tasks.db still in place, so the next launch imports a second copy. In this order every kill point re-enters correctly. Recurrence overrides are carried across as master_id/recurrence_id rather than ignored. dmfs stores them as ordinary rows sharing their master's _uid, so importing one as a second master would collide on the unique index and abort the whole import. autoMode now answers OWN, and a stored LOCAL reads as OWN — after the import the dmfs file has been renamed away, so someone who chose local storage explicitly must land on the store their data is now in. StartupGate holds the first store read until the mode has landed and the import has run; showing an upgrading user an empty app is the worst thing this migration could do. The backup rules take the database with its WAL sidecars and exclude the archive, and the app checkpoints on ON_STOP. --- app/build.gradle.kts | 1 + .../data/tasks/legacy/OneShotImportTest.kt | 290 +++++++++++++ .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 24 +- .../agendula/data/prefs/SettingsPrefs.kt | 8 +- .../agendula/data/tasks/ProviderResolver.kt | 4 +- .../agendula/data/tasks/StartupGate.kt | 46 +++ .../data/tasks/TasksRepositoryImpl.kt | 7 +- .../data/tasks/legacy/OneShotImport.kt | 384 ++++++++++++++++++ .../data/tasks/room/DatabaseCheckpoint.kt | 35 ++ app/src/main/res/xml/backup_rules.xml | 19 +- .../main/res/xml/data_extraction_rules.xml | 10 +- .../data/tasks/ProviderResolverTest.kt | 10 +- gradle/libs.versions.toml | 2 + 13 files changed, 819 insertions(+), 21 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f0249f..7473816 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,6 +154,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt new file mode 100644 index 0000000..c040421 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt @@ -0,0 +1,290 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID +import kotlin.time.Instant + +/** + * The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture + * `scripts/make_import_fixture.py` seeds. Instrumented because both halves need + * a real SQLite: the source file and Room. + */ +@RunWith(AndroidJUnit4::class) +class OneShotImportTest { + + @get:Rule + val temp = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var scope: CoroutineScope + private lateinit var prefs: DataStore + private lateinit var db: TasksDatabase + private lateinit var importer: OneShotImport + + @Before + fun setUp() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + prefs = PreferenceDataStoreFactory.create(scope = scope) { + temp.newFile("import-${counter++}.preferences_pb").also(File::delete) + } + db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java) + .allowMainThreadQueries() + .build() + importer = OneShotImport(context, db, prefs) + legacyFile().delete() + archiveFile().delete() + } + + @After + fun tearDown() { + db.close() + scope.cancel() + legacyFile().delete() + archiveFile().delete() + } + + private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME) + private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME) + + /** The fixture, copied out of the test APK's assets. */ + private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File { + InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source -> + target.outputStream().use(source::copyTo) + } + return target + } + + private fun taskRows(): Map = + db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task } + + // --- what lands ----------------------------------------------------------- + + @Test + fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() { + val counts = importer.importFrom(fixture()) + + assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2)) + assertThat(taskRows().keys).containsExactly( + "Buy milk", + "Call the dentist", + "Gather receipts", + "Renew domain", + "Water the plants", + "Team offsite", + "Task in a hidden list", + "Ship the release", + ) + } + + @Test + fun importsEveryListAsADeviceOnlyListWithItsFlags() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work") + assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null) + assertThat(lists.getValue("Personal").list.isVisible).isTrue() + assertThat(lists.getValue("Hidden list").list.isVisible).isFalse() + // The list that sat under a real account: still imported, owner kept. + assertThat(lists.getValue("Work").list.owner).isEqualTo("Me") + assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt()) + } + + @Test + fun carriesTheTaskFieldsAcross() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val milk = tasks.getValue("Buy milk") + assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY)) + assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + val dentist = tasks.getValue("Call the dentist") + assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(dentist.percentComplete).isEqualTo(40) + + val domain = tasks.getValue("Renew domain") + assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY)) + + val plants = tasks.getValue("Water the plants") + assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH") + assertThat(plants.timezone).isEqualTo("Europe/Berlin") + assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0)) + + assertThat(tasks.getValue("Team offsite").isAllDay).isTrue() + } + + // --- uids ----------------------------------------------------------------- + + @Test + fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001") + // The external-account row's uid is what lets it be re-attached later. + assertThat(tasks.getValue("Ship the release").uid) + .isEqualTo("a1b2c3d4-0000-4000-8000-000000000009") + + val minted = tasks.getValue("Call the dentist").uid + assertThat(minted).isNotEmpty() + assertThat(UUID.fromString(minted).version()).isEqualTo(4) + assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size) + } + + // --- the id remap --------------------------------------------------------- + + @Test + fun remapsListIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + + val lists = db.taskLists().lists().associateBy { it.list.name } + val byList = db.tasks().tasks(null, includeCompleted = true) + .groupBy { it.task.listId } + .mapValues { (_, rows) -> rows.size } + + assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6) + assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1) + assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1) + // No task kept a dmfs row id that Room never handed out. + assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id }) + } + + @Test + fun remapsParentIdsOntoTheNewRowIds() { + importer.importFrom(fixture()) + val tasks = taskRows() + + val parent = tasks.getValue("Buy milk") + val child = tasks.getValue("Gather receipts") + assertThat(child.parentId).isEqualTo(parent.id) + assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts") + assertThat(tasks.values.filter { it.parentId != null }).hasSize(1) + } + + // --- alarms --------------------------------------------------------------- + + @Test + fun importsAlarmsAndSkipsEveryOtherProperty() { + importer.importFrom(fixture()) + val tasks = taskRows() + + assertThat(db.alarms().all()).hasSize(2) + + val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single() + assertThat(milk.minutesBefore).isEqualTo(30) + assertThat(milk.reference).isEqualTo(AlarmReference.DUE) + assertThat(milk.message).isNull() + + val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single() + assertThat(release.minutesBefore).isEqualTo(1440) + assertThat(release.reference).isEqualTo(AlarmReference.DUE) + assertThat(release.message).isEqualTo("Ship it") + + // The category property on task 1 is not an alarm. + assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands") + } + + // --- running it ----------------------------------------------------------- + + @Test + fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking { + fixture(legacyFile()) + + val first = importer.runIfNeeded() + + assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(legacyFile().exists()).isFalse() + assertThat(archiveFile().exists()).isTrue() + assertThat(importer.isDone.first()).isTrue() + + val second = importer.runIfNeeded() + + assertThat(second).isEqualTo(ImportResult.AlreadyDone) + assertThat(taskRows()).hasSize(8) + } + + @Test + fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking { + // The process dying between the commit and the flag write is the one gap + // the DataStore flag cannot cover on its own. Because the rename happens + // first and the import always replaces, the next run finds the archive and + // redoes the same work rather than importing a second copy. + fixture(legacyFile()) + importer.runIfNeeded() + importer.clearCompletion() + + val resumed = importer.runIfNeeded() + + assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + @Test + fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking { + assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport) + assertThat(importer.isDone.first()).isTrue() + assertThat(taskRows()).isEmpty() + } + + @Test + fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking { + fixture(legacyFile()) + importer.runIfNeeded() + + val again = importer.reimportFromArchive() + + assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2))) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(taskRows()).hasSize(8) + assertThat(db.alarms().all()).hasSize(2) + assertThat(archiveFile().exists()).isTrue() + } + + @Test + fun replacingTwiceFromTheSameFileLeavesOneCopy() { + importer.importFrom(fixture()) + val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true) + + assertThat(counts).isEqualTo(ImportCounts(3, 8, 2)) + assertThat(taskRows()).hasSize(8) + assertThat(db.taskLists().lists()).hasSize(3) + assertThat(db.alarms().all()).hasSize(2) + } + + private companion object { + const val FIXTURE = "tasks-v23.db" + const val T0 = 1_768_467_600_000L + const val DAY = 86_400_000L + var counter = 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 0adb920..1b8c87f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -1,13 +1,15 @@ package de.jeanlucmakiola.agendula import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler -import de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder +import de.jeanlucmakiola.agendula.data.tasks.StartupGate +import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -38,16 +40,17 @@ class AgendulaApp : Application() { ) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) val scheduler = entryPoint.reminderScheduler() - // Start mirroring the stored storage mode into ProviderResolver before - // anything reads a provider. - val storageModeHolder = entryPoint.storageModeHolder() - storageModeHolder.start() + // Mirror the stored storage mode into ProviderResolver and import a + // v0.3.x install's tasks, both before anything reads a store. + val startupGate = entryPoint.startupGate() + startupGate.start() + ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - // Wait for the stored mode to land first. Rescheduling alarms against - // whichever provider autoMode happens to pick would arm them off the - // wrong store for a user who chose the other one. + // Wait for the stored mode and the import to land first. Rescheduling + // alarms against whichever store autoMode happens to pick would arm + // them off the wrong one — or off an empty one, mid-import. runCatching { - storageModeHolder.awaitReady() + startupGate.awaitReady() scheduler.sync() } } @@ -57,6 +60,7 @@ class AgendulaApp : Application() { @InstallIn(SingletonComponent::class) interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler - fun storageModeHolder(): StorageModeHolder + fun startupGate(): StartupGate + fun databaseCheckpoint(): DatabaseCheckpoint } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 1644fc5..48c9401 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -95,7 +95,13 @@ class SettingsPrefs @Inject constructor( * upgrading Posture A user pointed at the provider that holds their data. */ val storageMode: Flow = dataStore.data.map { p -> - p[STORAGE_MODE]?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + p[STORAGE_MODE] + ?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } + // A stored LOCAL means "the bundled dmfs provider", and after the + // one-shot import that store no longer holds the user's tasks — its + // file has been renamed away. Read it as OWN so someone who chose + // local storage explicitly lands on the store their data is now in. + ?.let { if (it == StorageMode.LOCAL) StorageMode.OWN else it } } suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index 26678ba..10e6f86 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -86,14 +86,14 @@ class ProviderResolver @Inject constructor( * permission**. That is a dangerous permission — it can only be there because * a previous version asked and the user agreed, which is precisely the * definition of "this person is an existing Posture A user". A fresh install - * never holds it, and gets local-first storage. + * never holds it, and gets our own store. * * Deliberately cheap and synchronous: a PackageManager lookup and a permission * check, no database probe. Settings overrides it either way. */ fun autoMode(): StorageMode { val external = resolveExternal() - return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.LOCAL + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN } /** The first installed external candidate, or `null` when none is present. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt new file mode 100644 index 0000000..fe160f6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work that has to finish before anything reads a task store: the stored + * [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks + * have to be imported out of the dmfs provider's file into Room. + * + * Both are startup races with the same shape. Reading before the mode lands + * answers from `autoMode()` instead of the user's choice; reading before the + * import lands shows an upgrading user an empty app, which is the single worst + * thing this migration could do. + */ +@Singleton +class StartupGate @Inject constructor( + private val storageModeHolder: StorageModeHolder, + private val oneShotImport: OneShotImport, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val ready = CompletableDeferred() + + /** Call once, from `Application.onCreate`. */ + fun start() { + storageModeHolder.start() + scope.launch { + // Opens the gate even on failure: a store that cannot be imported is + // still better shown empty than not shown at all, and the source file + // is left where it was either way. + runCatching { + storageModeHolder.awaitReady() + oneShotImport.runIfNeeded() + } + ready.complete(Unit) + } + } + + suspend fun awaitReady() = ready.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index e1fc33b..8bdd073 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -28,6 +28,7 @@ import kotlin.time.Instant class TasksRepositoryImpl @Inject constructor( private val dataSource: TasksDataSource, private val providerResolver: ProviderResolver, + private val startupGate: StartupGate, @IoDispatcher private val io: CoroutineDispatcher, ) : TasksRepository { @@ -132,11 +133,15 @@ class TasksRepositoryImpl @Inject constructor( } /** - * Emits an initial load, then re-loads on every provider change. The observer + * Emits an initial load, then re-loads on every store change. The observer * callback (main thread) only pokes a conflated channel; the actual blocking * query runs on [io]. */ private fun observing(load: () -> T): Flow = callbackFlow { + // Nothing reads a store before the stored mode has landed and a v0.3.x + // install has been imported — otherwise the first emission comes from the + // wrong store, or from an empty one. + startupGate.awaitReady() val ticks = Channel(Channel.CONFLATED) val handle = dataSource.registerObserver { ticks.trySend(Unit) } ticks.trySend(Unit) // prime the initial emission diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt new file mode 100644 index 0000000..0bf99ee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -0,0 +1,384 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.statusFromInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** How much one import moved. */ +data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int) + +/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */ +sealed interface ImportResult { + /** The DataStore flag was already set; nothing was read. */ + data object AlreadyDone : ImportResult + + /** No legacy database on disk — a fresh install, or one already archived. */ + data object NothingToImport : ImportResult + + data class Imported(val counts: ImportCounts) : ImportResult + + /** Nothing landed: the transaction rolled back and the source is untouched. */ + data class Failed(val cause: Throwable) : ImportResult +} + +/** + * Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file + * and into Room, once (`docs/OWN-STORE.md`, "Migrating existing users"). + * + * The file is opened read-only and directly — no provider, no ContentResolver — + * so this keeps working after `:provider` is deleted. Everything lands in one + * Room transaction with verified counts, so a failure leaves Room exactly as it + * was and the source file exactly where it was. + * + * dmfs accounts are not carried over: every list is imported as a device-only + * list (`account_id IS NULL`), including one that sat under a real account — + * only reachable if the user had pointed DAVx5 at our authority. Their task + * `uid`s are preserved, which is what lets those rows be re-attached to an + * account once sync lands. + */ +@Singleton +class OneShotImport @Inject constructor( + @ApplicationContext private val context: Context, + private val database: TasksDatabase, + private val dataStore: DataStore, +) { + + /** Whether the import has run. Set before the rename, so both guards hold. */ + val isDone: Flow = dataStore.data.map { it[IMPORT_DONE] ?: false } + + /** + * Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record + * completion. Safe to call on every launch. + * + * The archive happens *before* the import, and the import always replaces, so + * that every point this can be killed at re-enters correctly: + * + * - killed after the rename, before the import — the next run finds the + * archive, imports it, and nothing is lost; + * - killed after the import commits, before the flag is written — the next + * run truncates and re-imports the same archive, so the result is the same + * rather than doubled. + * + * Renaming last would leave that second window open: the flag would be unset + * and `tasks.db` still in place, and the next launch would import it a second + * time on top of the first. That is the window the plan's "guarded by a + * DataStore flag *and* by the rename" is meant to close, and only this order + * actually closes it. + */ + suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) { + if (isDone.first()) return@withContext ImportResult.AlreadyDone + val source = archivedSource() ?: run { + markDone() + return@withContext ImportResult.NothingToImport + } + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext ImportResult.Failed(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The rollback path: re-run against the archived `tasks.db.imported`, + * truncating the Room tables first so a second attempt replaces rather than + * merges. Reached by a targeted fix release, not by the app on its own. + */ + suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) { + val source = archivedSource() ?: return@withContext ImportResult.NothingToImport + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext ImportResult.Failed(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The legacy database as `tasks.db.imported`, archiving it first if it is + * still under its live name. `null` when there is nothing to import. + */ + private fun archivedSource(): File? { + val archive = context.getDatabasePath(ARCHIVE_NAME) + if (archive.exists()) return archive + val live = context.getDatabasePath(LEGACY_NAME) + if (!live.exists()) return null + return if (archive(live)) archive else live + } + + /** Clears the completion flag so [runIfNeeded] will import again. */ + suspend fun clearCompletion() { + dataStore.edit { it.remove(IMPORT_DONE) } + } + + /** + * Steps 2–6 against an arbitrary dmfs database: read it read-only, then write + * everything in one Room transaction whose counts are verified before it + * commits. Blocking — call it off the main thread. + */ + fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts { + val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY) + .use(::read) + return database.runInTransaction { + if (replaceExisting) truncate() + val baseline = tableCounts() + val written = write(snapshot) + verify(written, baseline) + written + } + } + + // --- reading the dmfs file ------------------------------------------------ + + private fun read(db: SQLiteDatabase): LegacySnapshot { + val lists = mutableListOf() + db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + lists += LegacyList( + id = id, + entity = TaskListEntity( + name = r.getString("list_name").orEmpty(), + color = r.getInt("list_color") ?: 0, + accountId = null, + isVisible = r.getBoolean("visible"), + isSynced = r.getBoolean("sync_enabled"), + owner = r.getString("list_owner"), + ), + ) + } + } + + val rows = mutableListOf() + db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + rows += LegacyTaskRow( + id = id, + listId = r.getLong("list_id") ?: continue, + parentId = r.getLong("parent_id"), + masterId = r.getLong("original_instance_id"), + recurrenceId = r.instant("original_instance_time"), + entity = TaskEntity( + listId = 0, + uid = r.getString("_uid") ?: UUID.randomUUID().toString(), + title = r.getString("title"), + description = r.getString("description"), + location = r.getString("location"), + url = r.getString("url"), + color = r.getInt("task_color"), + status = statusFromInt(r.getInt("status")), + percentComplete = r.getInt("percent_complete"), + completedAt = r.instant("completed"), + priority = r.getInt("priority") ?: PRIORITY_NONE, + classification = r.getInt("class"), + dtstart = r.instant("dtstart"), + due = r.instant("due"), + duration = r.getString("duration"), + isAllDay = r.getBoolean("is_allday"), + timezone = r.getString("tz"), + rrule = r.getString("rrule"), + rdate = r.getString("rdate"), + exdate = r.getString("exdate"), + createdAt = r.instant("created"), + lastModified = r.instant("last_modified"), + ), + ) + } + } + + val alarms = mutableListOf() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + if (r.getString("mimetype") != ALARM_MIMETYPE) continue + val taskId = r.getLong("task_id") ?: continue + val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue + alarms += LegacyAlarm( + taskId = taskId, + minutesBefore = minutes, + reference = if (r.getString("data1")?.trim() == REFERENCE_START) { + AlarmReference.START + } else { + AlarmReference.DUE + }, + message = r.getString("data2"), + ) + } + } + + return LegacySnapshot(lists, rows, alarms) + } + + // --- writing into Room ---------------------------------------------------- + + /** + * dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and + * Room mints its own on insert, so every one of them is remapped through the + * ids the inserts hand back. Tasks are inserted with their links cleared and + * a second pass sets them, because a parent may be a higher `_id` than its + * child. + */ + private fun write(snapshot: LegacySnapshot): ImportCounts { + val listDao = database.taskLists() + val taskDao = database.tasks() + val alarmDao = database.alarms() + + val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) } + + // A task whose list is missing is already invisible in dmfs — its tasks + // view inner-joins Lists — so dropping it loses nothing the user could see. + val importable = snapshot.tasks.filter { it.listId in listIds } + val importableIds = importable.mapTo(mutableSetOf()) { it.id } + val taskIds = mutableMapOf() + val inserted = mutableListOf>() + val seen = mutableSetOf>() + + for (row in importable) { + val listId = listIds.getValue(row.listId) + val overrides = row.masterId != null && row.masterId in importableIds + val recurrenceId = row.recurrenceId.takeIf { overrides } + // A duplicate (list, uid, recurrence) would abort the whole import on + // the unique index; a fresh uid costs the row nothing it still has. + val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } + ?: UUID.randomUUID().toString() + val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val newId = taskDao.insert(entity) + taskIds[row.id] = newId + inserted += row to entity.copy(id = newId) + } + + for ((row, entity) in inserted) { + val parentId = row.parentId?.let(taskIds::get) + val masterId = row.masterId?.let(taskIds::get) + if (parentId == null && masterId == null) continue + taskDao.update(entity.copy(parentId = parentId, masterId = masterId)) + } + + var alarmCount = 0 + for (alarm in snapshot.alarms) { + val taskId = taskIds[alarm.taskId] ?: continue + alarmDao.insert( + TaskAlarmEntity( + taskId = taskId, + minutesBefore = alarm.minutesBefore, + reference = alarm.reference, + message = alarm.message, + ), + ) + alarmCount++ + } + + return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount) + } + + private fun verify(written: ImportCounts, before: ImportCounts) { + val after = tableCounts() + check(after.lists - before.lists == written.lists) { + "list count mismatch: ${after.lists - before.lists} != ${written.lists}" + } + check(after.tasks - before.tasks == written.tasks) { + "task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}" + } + check(after.alarms - before.alarms == written.alarms) { + "alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}" + } + } + + /** Dropping the lists takes their tasks and alarms with them, by cascade. */ + private fun truncate() { + val listDao = database.taskLists() + listDao.lists().forEach { listDao.delete(it.list.id) } + } + + private fun tableCounts() = ImportCounts( + lists = count("task_lists"), + tasks = count("tasks"), + alarms = count("task_alarms"), + ) + + private fun count(table: String): Int = + database.query("SELECT COUNT(*) FROM $table", null).use { + if (it.moveToFirst()) it.getInt(0) else 0 + } + + // --- the source file ------------------------------------------------------ + + /** + * Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never + * deletes it: for one release it is the only way back if the import turns out + * to be wrong on someone's device. + */ + private fun archive(source: File): Boolean { + val target = File(source.parentFile, ARCHIVE_NAME) + if (!source.renameTo(target)) return false + for (suffix in SIDECARS) { + val sidecar = File(source.path + suffix) + if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix)) + } + return true + } + + private suspend fun markDone() { + dataStore.edit { it[IMPORT_DONE] = true } + } + + private fun CursorColumnReader.instant(name: String): Instant? = + getLong(name)?.let(Instant::fromEpochMilliseconds) + + companion object { + const val LEGACY_NAME = "tasks.db" + const val ARCHIVE_NAME = "tasks.db.imported" + + private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val REFERENCE_START = "2" + private val SIDECARS = listOf("-journal", "-wal", "-shm") + private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") + } +} + +private class LegacySnapshot( + val lists: List, + val tasks: List, + val alarms: List, +) + +private class LegacyList(val id: Long, val entity: TaskListEntity) + +private class LegacyTaskRow( + val id: Long, + val listId: Long, + val parentId: Long?, + val masterId: Long?, + val recurrenceId: Instant?, + val entity: TaskEntity, +) + +private class LegacyAlarm( + val taskId: Long, + val minutesBefore: Int, + val reference: AlarmReference, + val message: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt new file mode 100644 index 0000000..4c3487d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Folds the write-ahead log back into the database file when the app goes to the + * background. + * + * Room runs in WAL mode, and 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, which already makes a restore consistent; this narrows + * the window further by ensuring the `.db` alone is usually current, which is what + * a restore onto a device that drops the sidecars falls back to. + */ +@Singleton +class DatabaseCheckpoint @Inject constructor( + private val database: TasksDatabase, + @ApplicationScope private val scope: CoroutineScope, +) : DefaultLifecycleObserver { + + override fun onStop(owner: LifecycleOwner) { + scope.launch(Dispatchers.IO) { + runCatching { + database.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").close() + } + } + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 87d1f20..dbfacf5 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,21 @@ - + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c6ba7b9..81ba3d0 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,8 +1,16 @@ - + + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index edf4494..fd6a171 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -66,8 +66,8 @@ class ProviderResolverTest { inner class AutoMode { @Test - fun `a fresh install with nothing else present is local`() { - assertThat(resolver().autoMode()).isEqualTo(StorageMode.LOCAL) + fun `a fresh install with nothing else present gets our own store`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN) } @Test @@ -83,8 +83,8 @@ class ProviderResolverTest { @Test fun `OpenTasks merely installed is not enough`() { // Someone who has OpenTasks for unrelated reasons, and never granted us - // anything, has no data with us there. Local-first is right for them. - assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.LOCAL) + // anything, has no data with us there. Our own store is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN) } @Test @@ -93,7 +93,7 @@ class ProviderResolverTest { installed = openTasksInstalled, granted = setOf(openTasks.readPermission), ) - assertThat(resolver.autoMode()).isEqualTo(StorageMode.LOCAL) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b40b5e..cd55c5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -113,6 +113,8 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio # Lifecycle compose (for collectAsStateWithLifecycle) androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } +# ProcessLifecycleOwner — the WAL checkpoint hangs off ON_STOP. +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntime" } # Glance — Jetpack home-screen widgets (Compose-like RemoteViews) androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } From 1ed192f1501908d613f0413c8b353b7f16ebd117 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:33:19 +0200 Subject: [PATCH 13/21] feat(store)!: delete the vendored dmfs provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of docs/OWN-STORE.md. The :provider module goes — 84 Java files, 14,555 lines, its , its two custom permissions, its 13 translated strings and its three dmfs runtime dependencies. Room has been the default since the previous commit and every v0.3.x install has been imported, so nothing reads it any more. StorageMode.LOCAL is gone with it; OWN and EXTERNAL are what remain. ProviderResolver narrows to what it was always really for — discovering external providers — and answers null in OWN mode, where there is no authority to resolve. Callers that need to tell that apart from "External with nothing installed" ask mode(). ProviderStatus is unconditionally READY in OWN mode: the permission gate only ever applied to External, and that is now visibly true rather than a special case inside it. A stored LOCAL is read as OWN rather than as an unparseable value. Left to fall through to autoMode, someone who had explicitly chosen local storage while also having OpenTasks granted would have been sent to OpenTasks instead. ProviderChangeReceiver's manifest filter drops our own authority — safe now, because nothing of ours broadcasts ACTION_PROVIDER_CHANGED. In OWN mode Room's InvalidationTracker covers foreground changes and nothing outside the app can change our data. When SYNC.md phase 3 lands, the sync worker must call ReminderScheduler.sync() itself; that is the replacement for the broadcast and it belongs in the sync work. lib-recur stays as a direct dependency and is still Apache-2.0 dmfs, so the attribution is still owed — now as a normal third-party dependency. provider/PROVENANCE.md is replaced by a postscript in STORAGE-DECISION.md recording that the fork existed, why, and the one detail that still binds us: tasks.org is DB 22 and has no is_recurring, so TaskMapper must keep deriving recurrence from rrule/rdate. BREAKING: the de.jeanlucmakiola.agendula.tasks authority and both custom permissions are gone. Anyone who pointed DAVx5 or another app at that authority loses it; External mode is the answer. Needs calling out in the release notes. Verified: the APK declares no ContentProvider, no custom permission and no agendula.tasks authority, and carries no dmfs provider classes. --- README.md | 24 +- app/build.gradle.kts | 12 +- app/src/main/AndroidManifest.xml | 16 +- .../agendula/data/prefs/SettingsPrefs.kt | 16 +- .../data/tasks/ModeRoutingTasksDataSource.kt | 7 +- .../data/tasks/ProviderEnvironment.kt | 15 +- .../agendula/data/tasks/ProviderResolver.kt | 77 +- .../agendula/data/tasks/StorageMode.kt | 42 +- .../data/tasks/TasksRepositoryImpl.kt | 4 + .../ui/permission/PermissionViewModel.kt | 7 +- .../data/tasks/ProviderResolverTest.kt | 46 +- docs/STORAGE-DECISION.md | 32 + provider/LICENSE | 202 -- provider/NOTICE | 2 - provider/PROVENANCE.md | 192 -- provider/build.gradle.kts | 66 - provider/proguard-rules.pro | 25 - provider/src/main/AndroidManifest.xml | 72 - .../java/org/dmfs/ngrams/NGramGenerator.java | 168 -- .../dmfs/provider/tasks/AuthorityUtil.java | 43 - .../dmfs/provider/tasks/ContentOperation.java | 419 ---- .../provider/tasks/FTSDatabaseHelper.java | 630 ------ .../provider/tasks/ProviderOperation.java | 39 - .../provider/tasks/SQLiteContentProvider.java | 364 ---- .../provider/tasks/TaskDatabaseHelper.java | 895 --------- .../org/dmfs/provider/tasks/TaskProvider.java | 1408 -------------- .../tasks/TaskProviderBroadcastReceiver.java | 134 -- .../java/org/dmfs/provider/tasks/Utils.java | 214 -- .../provider/tasks/handler/AlarmHandler.java | 133 -- .../tasks/handler/CategoryHandler.java | 277 --- .../tasks/handler/DefaultPropertyHandler.java | 54 - .../tasks/handler/PropertyHandler.java | 155 -- .../tasks/handler/PropertyHandlerFactory.java | 61 - .../tasks/handler/RelationHandler.java | 276 --- .../tasks/model/AbstractInstanceAdapter.java | 37 - .../tasks/model/AbstractListAdapter.java | 56 - .../tasks/model/AbstractTaskAdapter.java | 71 - .../model/ContentValuesInstanceAdapter.java | 161 -- .../tasks/model/ContentValuesListAdapter.java | 130 -- .../tasks/model/ContentValuesTaskAdapter.java | 132 -- .../CursorContentValuesInstanceAdapter.java | 212 -- .../model/CursorContentValuesListAdapter.java | 139 -- .../model/CursorContentValuesTaskAdapter.java | 169 -- .../provider/tasks/model/EntityAdapter.java | 151 -- .../provider/tasks/model/InstanceAdapter.java | 109 -- .../provider/tasks/model/ListAdapter.java | 82 - .../provider/tasks/model/TaskAdapter.java | 362 ---- .../model/adapters/BinaryFieldAdapter.java | 95 - .../model/adapters/BooleanFieldAdapter.java | 94 - .../model/adapters/DateTimeFieldAdapter.java | 243 --- .../DateTimeIterableFieldAdapter.java | 198 -- .../model/adapters/DurationFieldAdapter.java | 106 - .../tasks/model/adapters/FieldAdapter.java | 148 -- .../model/adapters/FloatFieldAdapter.java | 95 - .../model/adapters/IntegerFieldAdapter.java | 96 - .../model/adapters/LongFieldAdapter.java | 94 - .../model/adapters/RRuleFieldAdapter.java | 122 -- .../model/adapters/SimpleFieldAdapter.java | 100 - .../model/adapters/StringFieldAdapter.java | 95 - .../tasks/model/adapters/UrlFieldAdapter.java | 95 - .../tasks/processors/EntityProcessor.java | 35 - .../provider/tasks/processors/Logging.java | 67 - .../tasks/processors/NoOpProcessor.java | 50 - .../tasks/processors/instances/Detaching.java | 337 ---- .../instances/TaskValueDelegate.java | 284 --- .../processors/instances/Validating.java | 186 -- .../processors/lists/ListCommitProcessor.java | 56 - .../tasks/processors/lists/Validating.java | 137 -- .../processors/tasks/AutoCompleting.java | 210 -- .../tasks/processors/tasks/Instantiating.java | 397 ---- .../tasks/processors/tasks/Moving.java | 205 -- .../tasks/processors/tasks/Originating.java | 77 - .../tasks/processors/tasks/Relating.java | 150 -- .../tasks/processors/tasks/Reparenting.java | 119 -- .../tasks/processors/tasks/Searchable.java | 66 - .../processors/tasks/TaskCommitProcessor.java | 67 - .../tasks/processors/tasks/Validating.java | 278 --- .../processors/tasks/instancedata/Dated.java | 51 - .../tasks/instancedata/Distant.java | 43 - .../tasks/instancedata/DueDated.java | 39 - .../tasks/instancedata/Enduring.java | 59 - .../tasks/instancedata/Overridden.java | 62 - .../tasks/instancedata/StartDated.java | 39 - .../tasks/instancedata/TaskRelated.java | 57 - .../instancedata/VanillaInstanceData.java | 46 - .../provider/tasks/utils/ContainsValues.java | 72 - .../tasks/utils/InstanceValuesIterable.java | 120 -- .../dmfs/provider/tasks/utils/Limited.java | 49 - .../provider/tasks/utils/LimitedIterator.java | 63 - .../tasks/utils/OverrideValuesFunction.java | 64 - .../dmfs/provider/tasks/utils/Profiled.java | 80 - .../org/dmfs/provider/tasks/utils/Range.java | 56 - .../provider/tasks/utils/ResourceArray.java | 49 - .../provider/tasks/utils/RowIterator.java | 57 - .../provider/tasks/utils/TableColumns.java | 61 - .../tasks/utils/TaskInstanceIterable.java | 80 - .../tasks/utils/TaskInstanceIterator.java | 78 - .../dmfs/provider/tasks/utils/Timestamps.java | 55 - .../org/dmfs/provider/tasks/utils/With.java | 64 - .../org/dmfs/provider/tasks/utils/Zipped.java | 43 - .../org/dmfs/tasks/contract/TaskContract.java | 1728 ----------------- .../org/dmfs/tasks/contract/UriFactory.java | 57 - .../res/drawable/ic_24_agendula_tasks.xml | 4 - provider/src/main/res/values-cs/strings.xml | 14 - provider/src/main/res/values-de/strings.xml | 14 - provider/src/main/res/values-es/strings.xml | 14 - provider/src/main/res/values-fr/strings.xml | 14 - provider/src/main/res/values-hu/strings.xml | 14 - provider/src/main/res/values-it/strings.xml | 14 - provider/src/main/res/values-ja/strings.xml | 14 - provider/src/main/res/values-nl/strings.xml | 14 - provider/src/main/res/values-pl/strings.xml | 14 - .../src/main/res/values-pt-rBR/strings.xml | 14 - .../src/main/res/values-pt-rPT/strings.xml | 14 - provider/src/main/res/values-ru/strings.xml | 16 - provider/src/main/res/values-sr/strings.xml | 14 - provider/src/main/res/values-uk/strings.xml | 14 - .../src/main/res/values/agendula_defaults.xml | 16 - .../agendula_provider_changed_receivers.xml | 15 - provider/src/main/res/values/strings.xml | 20 - .../tasks/ProviderAccountCleanupTest.java | 215 -- .../DateTimeIterableFieldAdapterTest.java | 223 --- .../tasks/instancedata/DatedTest.java | 62 - .../tasks/instancedata/DistantTest.java | 46 - .../tasks/instancedata/DueDatedTest.java | 84 - .../tasks/instancedata/EnduringTest.java | 80 - .../tasks/instancedata/OverriddenTest.java | 110 -- .../tasks/instancedata/StartDatedTest.java | 84 - .../tasks/instancedata/TaskRelatedTest.java | 44 - .../instancedata/VanillaInstanceDataTest.java | 53 - .../tasks/utils/ContainsValuesTest.java | 94 - .../tasks/utils/ContentValuesWithLong.java | 57 - .../tasks/utils/TaskInstanceIterableTest.java | 188 -- .../tasks/utils/TaskInstanceIteratorTest.java | 121 -- .../dmfs/provider/tasks/utils/ZippedTest.java | 60 - .../src/test/resources/robolectric.properties | 26 - settings.gradle.kts | 5 - 137 files changed, 129 insertions(+), 17088 deletions(-) delete mode 100644 provider/LICENSE delete mode 100644 provider/NOTICE delete mode 100644 provider/PROVENANCE.md delete mode 100644 provider/build.gradle.kts delete mode 100644 provider/proguard-rules.pro delete mode 100644 provider/src/main/AndroidManifest.xml delete mode 100644 provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/Utils.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/With.java delete mode 100644 provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java delete mode 100644 provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java delete mode 100644 provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java delete mode 100644 provider/src/main/res/drawable/ic_24_agendula_tasks.xml delete mode 100644 provider/src/main/res/values-cs/strings.xml delete mode 100644 provider/src/main/res/values-de/strings.xml delete mode 100644 provider/src/main/res/values-es/strings.xml delete mode 100644 provider/src/main/res/values-fr/strings.xml delete mode 100644 provider/src/main/res/values-hu/strings.xml delete mode 100644 provider/src/main/res/values-it/strings.xml delete mode 100644 provider/src/main/res/values-ja/strings.xml delete mode 100644 provider/src/main/res/values-nl/strings.xml delete mode 100644 provider/src/main/res/values-pl/strings.xml delete mode 100644 provider/src/main/res/values-pt-rBR/strings.xml delete mode 100644 provider/src/main/res/values-pt-rPT/strings.xml delete mode 100644 provider/src/main/res/values-ru/strings.xml delete mode 100644 provider/src/main/res/values-sr/strings.xml delete mode 100644 provider/src/main/res/values-uk/strings.xml delete mode 100644 provider/src/main/res/values/agendula_defaults.xml delete mode 100644 provider/src/main/res/values/agendula_provider_changed_receivers.xml delete mode 100644 provider/src/main/res/values/strings.xml delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java delete mode 100644 provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java delete mode 100644 provider/src/test/resources/robolectric.properties diff --git a/README.md b/README.md index bd88bbd..86bea13 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ Open standards, no account required.

Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula -speaks the **dmfs `TaskContract`** — the same shape DAVx5 (and SmoothSync, -DecSync, …) syncs your CalDAV `VTODO` tasks into. +keeps its own store, designed around RFC 5545's `VTODO` — the same tasks DAVx5 +(and SmoothSync, DecSync, …) sync out of your CalDAV server. It can also read and +write a tasks provider you already have, for anyone already syncing that way. The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days; @@ -28,18 +29,23 @@ many small *florets* — so the two apps are florets of one bloom.) | | Where | Sync | Needs | |---|---|---|---| -| **On your device** *(default)* | Agendula's own task store, bundled in the app | none yet — sync of our own is planned | nothing. No account, no permissions, no other app | +| **On your device** *(default)* | Agendula's own database | none yet — CalDAV sync of our own is planned | nothing. No account, no permissions, no other app | | **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission | -Agendula carries its own copy of the Apache-2.0 dmfs task provider, under its own -name — so it **coexists with OpenTasks rather than replacing it**, and installing -one never breaks the other. It is a fork of a proven schema, not a database -written from scratch, which is why every CalDAV engine already understands it. +Agendula's own store is an ordinary app database — nothing is published to other +apps, so there is no authority to clash over and no permission to grant. It +**coexists with OpenTasks rather than replacing it**: installing one never breaks +the other, and if you already sync through a provider, that keeps working exactly +as it did. + +Recurring tasks are expanded per RFC 5545, and everything the schema does not +model is round-tripped verbatim rather than dropped — so passing your tasks +through Agendula does not quietly lose fields a server sent. Your tasks are exportable as standard iCalendar `.ics` files at any time, because data you can't take with you isn't really yours. -> **Status: backend complete, UI catching up.** Storage, provider, reads and +> **Status: backend complete, UI catching up.** Storage, reads and > writes, smart-list filtering, a self-scheduled reminder engine, and export are > built and unit-tested. The Material 3 Expressive screens are being built on > top, one at a time — the storage-mode picker and export screen are not there @@ -50,7 +56,7 @@ data you can't take with you isn't really yours. ## Sync sources (by design) -In provider mode Agendula works with anything that writes to that provider — +In external-provider mode Agendula works with anything that writes to that provider — **DAVx5** (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync adapter — because it builds on the provider, not on any one sync app. Google Tasks / Microsoft To Do are out of scope by design (proprietary; they diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7473816..b846f2f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -145,11 +145,6 @@ ksp { } dependencies { - // Agendula's own task store — the dmfs provider vendored under our authority. - // Contributes a to the merged manifest; no app code imports from it - // except ProviderResolver, which reads the authority out of its resources. - implementation(project(":provider")) - implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -170,10 +165,9 @@ dependencies { implementation(libs.androidx.navigation.compose) ksp(libs.hilt.compiler) - // RFC 5545 recurrence expansion, in-process. Pulled in directly rather than - // through :provider: the own store expands occurrences itself. Pinned at - // 0.12.2 — 0.16.0 removed RecurrenceSet. rfc5545-datetime comes with it and - // is part of its API surface, so it isn't declared separately. + // RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0 + // removed RecurrenceSet. rfc5545-datetime comes with it and is part of its + // API surface, so it isn't declared separately. implementation(libs.dmfs.lib.recur) implementation(libs.androidx.room.runtime) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 130f919..a50a376 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,9 @@ runtime by the permission flow, and only once the user has actually selected External mode. Both are dangerous-level. - Agendula's own bundled provider needs NO entry here: it runs under our uid - and a same-uid caller bypasses a provider's permission checks outright. - Its permissions are declared by the :provider module, for other apps. --> + StorageMode.OWN needs nothing here: it is a Room database in our own data + directory. Agendula publishes no ContentProvider and declares no + permissions of its own. --> @@ -80,16 +80,16 @@ - + - diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 48c9401..2702a6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -95,13 +95,15 @@ class SettingsPrefs @Inject constructor( * upgrading Posture A user pointed at the provider that holds their data. */ val storageMode: Flow = dataStore.data.map { p -> - p[STORAGE_MODE] - ?.let { runCatching { StorageMode.valueOf(it) }.getOrNull() } - // A stored LOCAL means "the bundled dmfs provider", and after the - // one-shot import that store no longer holds the user's tasks — its - // file has been renamed away. Read it as OWN so someone who chose - // local storage explicitly lands on the store their data is now in. - ?.let { if (it == StorageMode.LOCAL) StorageMode.OWN else it } + when (val stored = p[STORAGE_MODE]) { + null -> null + // 0.3.x's value for the bundled dmfs provider. That store is gone and + // its data was imported into OWN, so read it as OWN rather than + // letting it fall through to autoMode — someone who chose local + // storage explicitly would otherwise be sent to an external provider. + "LOCAL" -> StorageMode.OWN + else -> runCatching { StorageMode.valueOf(stored) }.getOrNull() + } } suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name } 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 index 4220dab..22120ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -16,8 +16,7 @@ import kotlin.time.Instant * [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. + * Room versus a third-party ContentProvider — nothing else. */ class ModeRoutingTasksDataSource( private val resolver: ProviderResolver, @@ -26,9 +25,9 @@ class ModeRoutingTasksDataSource( ) : TasksDataSource { private fun active(): TasksDataSource = - when (resolver.storageMode ?: resolver.autoMode()) { + when (resolver.mode()) { StorageMode.OWN -> room.get() - StorageMode.LOCAL, StorageMode.EXTERNAL -> external.get() + StorageMode.EXTERNAL -> external.get() } override fun taskLists(): List = active().taskLists() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt index 4bf4c9e..4cfca57 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -8,7 +8,7 @@ import javax.inject.Inject import javax.inject.Singleton /** - * The three platform facts [ProviderResolver] needs, behind an interface. + * The two platform facts [ProviderResolver] needs, behind an interface. * * Same seam the data source uses, for the same reason: which store a returning * user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows @@ -18,12 +18,6 @@ import javax.inject.Singleton */ interface ProviderEnvironment { - /** Our own bundled provider's authority, from the `:provider` module's resources. */ - val ownAuthority: String - - /** Our own package name. */ - val ownPackage: String - /** The package declaring [authority], or `null` when nothing on the device does. */ fun packageDeclaring(authority: String): String? @@ -36,13 +30,6 @@ class AndroidProviderEnvironment @Inject constructor( @ApplicationContext private val context: Context, ) : ProviderEnvironment { - override val ownAuthority: String - // Read from the module that declares it, never written as a literal: the - // authority lives in exactly one place, its own string resource. - get() = context.getString(de.jeanlucmakiola.agendula.provider.R.string.agendula_tasks_authority) - - override val ownPackage: String get() = context.packageName - override fun packageDeclaring(authority: String): String? = context.packageManager.resolveContentProvider(authority, 0)?.packageName diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index 10e6f86..207755f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -4,39 +4,26 @@ import javax.inject.Inject import javax.inject.Singleton /** - * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every - * candidate — ours included, since `:provider` *is* that provider vendored — so - * the [TasksContract] columns apply regardless of which is active. + * An external tasks provider Agendula can talk to. Every candidate runs the same + * dmfs `TaskProvider`, so the [TasksContract] columns apply to either. */ data class TaskProvider( val authority: String, val readPermission: String, val writePermission: String, val packageName: String? = null, - /** - * True for Agendula's own bundled provider. It runs in our process under our - * uid, and a same-uid caller bypasses a provider's permission checks outright, - * so [ProviderResolver.hasPermission] must never gate on a grant for it. - */ - val isOwn: Boolean = false, ) /** - * The A/B seam: the only class in the app that knows an authority exists. + * Discovers the *external* tasks providers (OpenTasks, tasks.org) that + * [StorageMode.EXTERNAL] can be pointed at. * - * - **Posture A** — an *external* provider (OpenTasks, tasks.org). Still fully - * supported; it stopped being the default and became a user choice. - * - **Posture B** — our own bundled provider, under our **own** authority. It - * coexists with everything and replaces nothing. + * This used to be the A/B seam between an external provider and one Agendula + * bundled itself. That second half is gone: [StorageMode.OWN] is a Room database + * with no authority, no ContentResolver and nothing to permit, so there is + * nothing here for it to resolve. See `docs/OWN-STORE.md`. * - * Both terms were redefined by `docs/STORAGE-AND-SYNC.md`. Posture B used to mean - * "bundle OpenTasks and squat `org.dmfs.tasks`"; that is a dead end and is not - * coming back, because two apps cannot declare the same authority - * (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same permission name - * (`INSTALL_FAILED_DUPLICATE_PERMISSION`) — anyone with OpenTasks installed would - * simply have been unable to install Agendula. - * - * Which one is active comes from [storageMode]; how that gets decided when the + * Which store is active comes from [storageMode]; how that gets decided when the * user has not chosen is [autoMode]. */ @Singleton @@ -54,23 +41,17 @@ class ProviderResolver @Inject constructor( @Volatile var storageMode: StorageMode? = null - /** Agendula's own provider. Always present — it ships inside the APK. */ - val own: TaskProvider by lazy { - TaskProvider( - authority = environment.ownAuthority, - readPermission = OWN_READ_PERMISSION, - writePermission = OWN_WRITE_PERMISSION, - packageName = environment.ownPackage, - isOwn = true, - ) - } + /** The active store, resolving the undecided case through [autoMode]. */ + fun mode(): StorageMode = storageMode ?: autoMode() - /** The active provider, or `null` when [StorageMode.EXTERNAL] is chosen and none is installed. */ - fun resolve(): TaskProvider? = when (storageMode ?: autoMode()) { - 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 + /** + * The provider to query, or `null` — either because [StorageMode.OWN] is + * active and there is no provider involved at all, or because + * [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell + * those apart ask [mode]. + */ + fun resolve(): TaskProvider? = when (mode()) { + StorageMode.OWN -> null StorageMode.EXTERNAL -> resolveExternal() } @@ -78,9 +59,9 @@ class ProviderResolver @Inject constructor( * What to use when the user has not chosen — and the one piece of real * judgement in this class, because getting it wrong loses people their data. * - * Ranking our own provider first unconditionally would be wrong: someone who + * Ranking our own store first unconditionally would be wrong: someone who * has been using Agendula over OpenTasks since 0.3.x would update, land on an - * empty bundled store, and reasonably conclude their tasks were deleted. + * empty database, and reasonably conclude their tasks were deleted. * * So the tell is **whether we already hold an external provider's runtime * permission**. That is a dangerous permission — it can only be there because @@ -106,23 +87,9 @@ class ProviderResolver @Inject constructor( } fun hasPermission(provider: TaskProvider): Boolean = - // Same uid, same process: there is nothing to grant, and asking would put a - // permission dialog in front of a purely local app for no reason. This is - // the bypass docs/STORAGE-AND-SYNC.md calls for — without it - // ProviderStatus.NEEDS_PERMISSION fires in Local mode and the onboarding - // gate asks for a permission that can never be granted. - provider.isOwn || - (environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission)) + environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission) companion object { - /** - * Declared by the `:provider` module's manifest. Listed here so the app can - * name them; nothing ever requests them, since [hasPermission] short-circuits - * for our own provider. - */ - const val OWN_READ_PERMISSION = "de.jeanlucmakiola.agendula.permission.READ_TASKS" - const val OWN_WRITE_PERMISSION = "de.jeanlucmakiola.agendula.permission.WRITE_TASKS" - /** * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*` diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt index d60e45c..6ca9963 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -1,46 +1,26 @@ package de.jeanlucmakiola.agendula.data.tasks /** - * Which task store backs the app — the user's choice, per `docs/STORAGE-AND-SYNC.md`. + * Which task store backs the app — the user's choice, per `docs/OWN-STORE.md`. * - * Only two values, though the document describes three modes. **Synced is not a - * third store**: it is [LOCAL] with an account attached, so it is derived state - * (does an account of ours exist?) rather than something the user picks. Adding - * a `SYNCED` constant here would imply otherwise. - * - * ⚠️ This used to add "so switching sync on is never a migration". That is wrong. - * `TaskLists.ACCOUNT_TYPE` is write-once in the provider — `processors/lists/ - * Validating.java:68-76` throws `IllegalArgumentException` on any attempt to - * change it — so attaching an account to an existing local list means recreating - * every list and every task under the new account. See `docs/SYNC.md`. - * - * Nothing above the data layer reads this; it selects an authority for - * [ProviderResolver] and stops there. + * Only two values, though `docs/STORAGE-AND-SYNC.md` describes three modes. + * **Synced is not a third store**: it is [OWN] with an account attached to a + * list, which is derived state rather than something the user picks. Attaching + * one is a plain `UPDATE task_lists SET account_id = ?` — not the full data + * migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once. */ enum class StorageMode { /** - * Agendula's own bundled provider (the `:provider` module). Always available — - * 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. - * - * 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, - - /** - * 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. + * Agendula's own Room database. The default, and always available: there is + * no authority, no ContentResolver and no permission to grant. */ OWN, /** * 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 - * Posture A, still fully supported, but now a choice rather than the only way. - * Requires that provider's runtime read/write permissions. + * whatever that provider's engine is — DAVx5 and friends. Still fully + * supported, now a choice rather than the only way. Requires that provider's + * runtime read/write permissions. */ EXTERNAL, } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 8bdd073..03850ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -127,6 +127,10 @@ class TasksRepositoryImpl @Inject constructor( withContext(io) { dataSource.createLocalList(name, color) } override fun providerStatus(): ProviderStatus { + // Our own store is always ready: it ships with the app, needs no provider + // and no grant. The permission gate only ever applied to External mode — + // now that is visibly true rather than a special case inside it. + if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER return if (providerResolver.hasPermission(provider)) ProviderStatus.READY else ProviderStatus.NEEDS_PERMISSION diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 9186e16..4cdd0cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -41,13 +41,8 @@ class PermissionViewModel @Inject constructor( val provider = providerResolver.resolve() _state.value = PermissionUiState( status = repository.providerStatus(), - // Never our own provider's permissions. They are declared for *other* - // apps to hold; requesting them here would show a dialog for a - // permission that a same-uid caller does not need and the system will - // not meaningfully grant. In practice this branch is unreachable in - // Local mode, since the status is already READY — belt and braces. + // Null in OWN mode, where there is no provider and nothing to grant. permissionsToRequest = provider - ?.takeUnless { it.isOwn } ?.let { listOf(it.readPermission, it.writePermission) } .orEmpty(), ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index fd6a171..da2eab5 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -21,8 +21,6 @@ class ProviderResolverTest { val installed: Map = emptyMap(), val granted: Set = emptySet(), ) : ProviderEnvironment { - override val ownAuthority = "de.jeanlucmakiola.agendula.tasks" - override val ownPackage = "de.jeanlucmakiola.agendula" override fun packageDeclaring(authority: String): String? = installed[authority] override fun isGranted(permission: String): Boolean = permission in granted } @@ -39,26 +37,16 @@ class ProviderResolverTest { private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission) @Nested - inner class OwnProvider { + inner class OwnStore { @Test - fun `needs no permission grant`() { - // The bypass the whole Local mode rests on: our provider runs under our - // own uid, so there is nothing to grant and nothing to ask for. - val resolver = resolver() - assertThat(resolver.hasPermission(resolver.own)).isTrue() - } - - @Test - fun `is always resolvable because it ships in the APK`() { - assertThat(resolver(mode = StorageMode.LOCAL).resolve()).isNotNull() - } - - @Test - fun `does not use a dmfs authority`() { - // Squatting org.dmfs.tasks would make Agendula and OpenTasks mutually - // uninstallable. Guards against a careless resync of the vendored module. - assertThat(resolver().own.authority).isEqualTo("de.jeanlucmakiola.agendula.tasks") + fun `resolves to no provider at all`() { + // Room has no authority and no ContentResolver, so there is nothing + // here to resolve — which is the point. Callers that need to tell this + // apart from "External, none installed" ask mode(). + val resolver = resolver(mode = StorageMode.OWN) + assertThat(resolver.resolve()).isNull() + assertThat(resolver.mode()).isEqualTo(StorageMode.OWN) } } @@ -104,10 +92,12 @@ class ProviderResolverTest { fun `overrides the automatic answer in both directions`() { val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted) - val forcedLocal = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.LOCAL } - assertThat(forcedLocal.resolve()?.isOwn).isTrue() + val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN } + assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN) + assertThat(forcedOwn.resolve()).isNull() val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL } + assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL) assertThat(forcedExternal.resolve()).isNull() } @@ -152,10 +142,14 @@ class ProviderResolverTest { } @Test - fun `never include our own provider`() { - // EXTERNAL must mean "somebody else's store". If ours ever leaked into - // this list, choosing External would silently keep using it. - assertThat(ProviderResolver.EXTERNAL_CANDIDATES.none { it.isOwn }).isTrue() + fun `never name an authority of ours`() { + // EXTERNAL must mean "somebody else's store", and Agendula publishes no + // provider at all any more. + assertThat( + ProviderResolver.EXTERNAL_CANDIDATES.none { + it.authority.startsWith("de.jeanlucmakiola") + }, + ).isTrue() } } } diff --git a/docs/STORAGE-DECISION.md b/docs/STORAGE-DECISION.md index 13491e0..8bddcc0 100644 --- a/docs/STORAGE-DECISION.md +++ b/docs/STORAGE-DECISION.md @@ -250,3 +250,35 @@ point of sequencing it last. mode arguably already serves the second. Undecided. - **The Room estimate is mine, not measured.** Instance expansion is the item that could overrun; everything else is well-bounded. + +--- + +## Postscript: the fork existed, and how it ended + +`provider/PROVENANCE.md` recorded the vendored dmfs task provider in detail. +Both are gone; this is what is worth keeping. + +The module was `opentasks-provider` plus `opentasks-contract` from +[dmfs/opentasks](https://github.com/dmfs/opentasks) **1.4.2**, commit +`49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21), Apache-2.0, database +version 23. It was vendored in-tree rather than pulled as an artifact because the +permission names are hardcoded in the upstream AAR's manifest, and shipping under +dmfs's own names would have made Agendula and OpenTasks mutually uninstallable +(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfied F-Droid's +from-source requirement. + +It was deleted in the `feat/own-store` work (`docs/OWN-STORE.md` phase 5) once +Room was the default and every v0.3.x install had been imported. 14,555 lines of +Java left with it. + +**What survives, and why.** `lib-recur` (Apache-2.0, dmfs) is still a direct +dependency — it is what expands recurrences — so dmfs's attribution is still +owed, now through an ordinary third-party dependency rather than vendored source. +`TasksContract.kt` and the External-mode mappers also stay: they describe +*somebody else's* schema, which is exactly what they were always right for. + +**One detail the fork's provenance file carried that still binds us.** DB 23 is +the first to have `is_recurring`; tasks.org's fork is DB 22 and lacks it. That is +why `TaskMapper.task` derives recurrence from `rrule`/`rdate` rather than trusting +that column, and it must keep doing so for as long as External mode supports +tasks.org. diff --git a/provider/LICENSE b/provider/LICENSE deleted file mode 100644 index d645695..0000000 --- a/provider/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/provider/NOTICE b/provider/NOTICE deleted file mode 100644 index 6a0d99d..0000000 --- a/provider/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -OpenTasks - Open Source Task App for Android -Copyright 2012-2015 Marten Gajda \ No newline at end of file diff --git a/provider/PROVENANCE.md b/provider/PROVENANCE.md deleted file mode 100644 index 6cfcefe..0000000 --- a/provider/PROVENANCE.md +++ /dev/null @@ -1,192 +0,0 @@ -# `:provider` — provenance - -This module is **not our code**. It is the dmfs task provider, vendored, with -its namespace renamed to ours and a short list of changes recorded below. - -| | | -|---|---| -| Upstream | [dmfs/opentasks](https://github.com/dmfs/opentasks) | -| Module taken | `opentasks-provider`, plus `opentasks-contract` (see [Why the contract came along](#why-the-contract-came-along)) | -| Version | `1.4.2` | -| Commit | `49ebf80b1eeee52a611e5a22f24f849852a6255f` (2021-03-21) | -| License | Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE), both upstream's, unmodified | -| Database version | **23** | - -Agendula itself is MIT. Apache-2.0 into MIT is fine in that direction, but this -module keeps its own `LICENSE`, `NOTICE`, and per-file Apache headers, and those -must survive any future edit here. - -**1.4.2 specifically, for the database version.** DB 23 is the first to carry -`is_recurring`. tasks.org's fork is DB 22 and lacks it — which is why -`TaskMapper.task` on the app side derives recurrence from `rrule`/`rdate` -instead of trusting that column, and why it must keep doing so as long as -External mode supports tasks.org. - -## Why vendored at all - -Recorded properly in [`docs/STORAGE-AND-SYNC.md`](../docs/STORAGE-AND-SYNC.md); -in one line: **the permission names are hardcoded in the upstream AAR's -manifest.** No prebuilt artifact — Maven Central, JitPack, anything — can have -them renamed without `tools:` node surgery, and shipping under dmfs's own -permission names would make Agendula and OpenTasks mutually uninstallable -(`INSTALL_FAILED_DUPLICATE_PERMISSION`). In-tree also satisfies F-Droid's -from-source requirement, which a JitPack artifact would not. - -In-tree rather than a git submodule, unlike floret-kit: we co-develop the kit, -whereas this is a fork we expect to resync from upstream approximately never. - -## The namespace rename - -Everything in this table is a rename and nothing more. The **contract shape is -untouched** — same tables, same column names, same URI paths — because that -shape is what our data layer, and every CalDAV engine, already speaks. We own -the namespace it lives in, not the schema. - -| | Upstream | Ours | -|---|---|---| -| Authority | `org.dmfs.tasks` | `de.jeanlucmakiola.agendula.tasks` | -| Read permission | `org.dmfs.permission.READ_TASKS` | `de.jeanlucmakiola.agendula.permission.READ_TASKS` | -| Write permission | `org.dmfs.permission.WRITE_TASKS` | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` | -| Permission group | `org.dmfs.tasks.permissiongroup.Tasks` | `de.jeanlucmakiola.agendula.permissiongroup.Tasks` | -| Notification-alarm action | `org.dmfs.tasks.provider.NOTIFICATION_ALARM` | `de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM` | -| Resource prefix | `opentasks_*` | `agendula_*` | -| R class | `org.dmfs.tasks.provider.R` | `de.jeanlucmakiola.agendula.provider.R` | - -### What was deliberately *not* renamed - -- **Java package names** stay `org.dmfs.provider.tasks` / `org.dmfs.tasks.contract`. - They are not a registered namespace — two apps may share them freely — and - keeping them means the diff against upstream stays legible. Only the AGP - `namespace` (which decides where `R` lands) is ours. -- **`TaskContract.LOCAL_ACCOUNT_TYPE`** stays `"org.dmfs.account.LOCAL"`. It is a - value stored in the database and recognised by dmfs-contract providers - generally, so the *same* app code has to write it whether it is talking to our - provider or, in External mode, to OpenTasks. Renaming it would fork the write - path in two for no gain. -- **`ACTION_BROADCAST_TASK_DUE` / `…_TASK_STARTING` / `ACTION_DATABASE_INITIALIZED`.** - Every send site calls `setPackage()` on its own package first, so these never - cross app boundaries and cannot collide with an installed OpenTasks. - -## Changes to upstream source - -Four files under `src/main/java`, one under `src/test/java`. Each edit is marked -with an `AGENDULA CHANGE` comment at the site, so this list and the code cannot -drift apart. Keep that convention. - -### Behavioural - -1. **`Utils.cleanUpLists` — only prune account types we authenticate ourselves.** - *The one change here that is about correctness rather than mechanics.* - - Upstream holds `GET_ACCOUNTS` and enumerates every account on the device. We - dropped that permission (below), so `AccountManager` only ever reports - accounts of our own type. Upstream's cleanup deletes any task list whose - account is absent from that array — and an account we *cannot see* is - indistinguishable from one that has been *removed*. Left alone, the provider - would quietly delete synced lists. Not "sync stops": data disappears, with no - error anywhere. - - Now a list is only prunable when its account type belongs to an authenticator - in **this package**. Agendula ships no authenticator yet, so the set is empty - and nothing is ever pruned; our sync adapter's type will join it on its own - when it lands, no edit needed here. Local lists were already exempt upstream. - -2. **`TaskProvider.insert` — the same restriction for the stale-list signal.** - Upstream flags "list with unknown account" and broadcasts about it. With the - account cache holding only our own accounts, that fired on every insert into - any externally-synced list. Nothing listens today; the point is that whatever - listens tomorrow gets a signal that means something. - -3. **`TaskProviderBroadcastReceiver.onReceive` — fall-through written out.** - Upstream's `switch` has no `break` in any branch, so `TIMEZONE_CHANGED` runs - all three content operations and `NOTIFICATION_ALARM` runs the last two. The - comment on the first branch ("don't trigger the notifications update yet") - describes breaks that were never written, so the code and the stated intent - contradict each other. - - **Observed behaviour is preserved exactly**, just spelled out with `if`s - rather than reached by accident. A vendored fork is the wrong place to guess - at intent. ⚠️ **Open:** which of the two is the bug wants a device with a task - due across a timezone change to settle. - -4. **`TaskProviderBroadcastReceiver.planNotificationUpdate` — inexact-alarm fallback.** - `setExact` throws `SecurityException` on API 31–32 when the user revokes - `SCHEDULE_EXACT_ALARM` — inside a receiver handling a system broadcast, so the - app would die on every timezone change. Falls back to `set` when exact alarms - aren't permitted. This alarm drives only the provider's own bookkeeping; - Agendula's user-visible reminders come from `ReminderScheduler`, which asks - for the permission properly. - -### Required by modern Android (would not build or would crash otherwise) - -5. **`PendingIntent.FLAG_IMMUTABLE`** added in `planNotificationUpdate`. Mandatory - since Android 12; throws `IllegalArgumentException` without it at targetSdk ≥ 31. - Upstream targets 29. Nothing mutates the intent later, so immutable is also - correct on the merits. -6. **`android:exported`** stated explicitly on the receiver. AGP hard-errors on an - intent-filtered component without it at targetSdk ≥ 31. -7. **`package=` attribute** removed from the manifest; AGP 8+ takes it from the - `namespace` in the build file. -8. **``** removed - along with the androidTest sources it existed for (below). - -### Permissions - -9. **`android.permission.GET_ACCOUNTS` dropped.** We only ever need to see - accounts of our own type, and since API 26 an authenticator makes those - visible to its own package with no grant at all. Safe **only** in combination - with change 1 — the two must be read together. - -### Build and test - -10. **`build.gradle` → `build.gradle.kts`**, on the root version catalog. minSdk - 21 → 29 (matching `:app`; the merger rejects a lower floor), Java 8 → 17. -11. **Test stack modernised**, sources otherwise untouched: JUnit 4.12 → 4.13.2, - Robolectric 3.5.1 → 4.16, Mockito 2.27 → 5.20, Hamcrest 1.3 → 3.0. - `org.dmfs:jems`, `rfc5545-datetime` and `lib-recur` stay on the versions - upstream pinned — all three resolve from Maven Central, so no new repository - was added (`settings.gradle.kts` is still `google()` + `mavenCentral()` under - `FAIL_ON_PROJECT_REPOS`). -12. **`ZippedTest.testAbsent`** — diamond `new Zipped<>` given an explicit type - argument. `absent()` pins no type, and javac 17 will not infer what javac 8 - did. The assertion is unchanged. -13. **`src/test/resources/robolectric.properties` added** (`sdk=34`, - `conscryptMode=OFF`). A library module has no `targetSdk` for Robolectric to - read, and Robolectric installs Conscrypt unconditionally, whose uber jar has - no `linux-aarch_64` native — so without this the suite fails at setup on ARM64 - machines while passing on x86_64 CI. See the file for the reasoning. -14. **`src/androidTest` dropped entirely.** It depends on `contentpal` / - `contenttestpal`, which are JitPack-only; adding JitPack would widen the - dependency trust surface for test-only code. ⚠️ This is the one place - vendoring lost coverage — those were the provider's *integration* tests - (recurrence, reparenting, instances, observers). The 56 JVM tests in - `src/test` all pass and are retained. -15. **`agendula_provider_changed_receivers` emptied.** Upstream notifies - `org.andstatus.todoagenda`, which listens for changes to the *dmfs* authority - and has never heard of ours. Anything re-added here also needs a `` - entry in the app manifest or package-visibility rules drop the broadcast. -16. **Translated `agendula_provider_label` overrides removed** (the other - translated strings are kept as upstream shipped them). The base label became - "Agendula tasks" so it is distinguishable from OpenTasks' own "Tasks" entry in - the system permission dialog; the inherited translations still said plain - "Tasks" in their language, which would have contradicted it. - -## Resyncing from upstream - -Unlikely to ever be worth it — upstream 1.4.2 is from 2021 — but if it is: the -`AGENDULA CHANGE` markers are the complete list of what to reapply, `git log` on -this directory is the audit trail, and the 56 JVM tests are the safety net. -Re-read change 1 before touching anything account-related. - -## Known-unverified - -Everything here is verified by the JVM test suite and a clean build. What is -**not** yet verified on a device with real data: - -- The local-list path with **no account present at all** — the entirety of Local - mode. `cleanUpLists` exempts local lists explicitly and change 1 makes the - prunable set empty, so it should hold by construction; it is covered by - `ProviderAccountCleanupTest`, but that is Robolectric, not a device. -- The timezone-change behaviour in change 3. -- Any interaction with an external sync engine writing into our authority - (nothing does yet — that is the DAVx5 ask, step 4 of the sequencing). diff --git a/provider/build.gradle.kts b/provider/build.gradle.kts deleted file mode 100644 index 3206e9c..0000000 --- a/provider/build.gradle.kts +++ /dev/null @@ -1,66 +0,0 @@ -// Agendula's own task store: the dmfs task provider (Apache-2.0), vendored. -// -// This module is a fork, not a dependency. What we changed and why is recorded -// in PROVENANCE.md; the short version is that the authority and the permission -// names had to become ours, and the permission names are hardcoded in the -// upstream AAR's manifest, so no prebuilt artifact could have been used. -// -// It stays Java, on upstream's `org.dmfs.*` package names, formatted the way -// upstream formats it. That is deliberate: every deviation from upstream is a -// line we have to re-reason about if we ever resync, so the diff is kept -// legible rather than idiomatic. -plugins { - alias(libs.plugins.android.library) -} - -android { - namespace = "de.jeanlucmakiola.agendula.provider" - compileSdk = 37 - - defaultConfig { - // Matches :app. Upstream ships minSdk 21 / targetSdk 29; targetSdk is set - // by the application module anyway, but the SDK floor here has to agree - // with :app's or the manifest merger rejects it. - minSdk = 29 - - consumerProguardFiles("proguard-rules.pro") - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - buildFeatures { - // The provider reads its own authority out of resources, so it needs R. - // It has no BuildConfig use at all. - buildConfig = false - } - - testOptions { - unitTests { - isIncludeAndroidResources = true - } - } - - lint { - // Upstream's translations are inherited as-is and are partial — a missing - // string falls back to the English base at runtime. Same call as :app. - informational += listOf("MissingTranslation") - } -} - -dependencies { - implementation(libs.dmfs.jems) - implementation(libs.dmfs.rfc5545.datetime) - implementation(libs.dmfs.lib.recur) - - // Upstream's own JVM test suite, on current versions of its stack. Note this - // module is JUnit 4 while :app is JUnit 5 — deliberately, see the version - // catalog. Do not add `useJUnitPlatform()` here. - testImplementation(libs.junit4) - testImplementation(libs.robolectric) - testImplementation(libs.hamcrest) - testImplementation(libs.mockito.core) - testImplementation(libs.dmfs.jems.testing) -} diff --git a/provider/proguard-rules.pro b/provider/proguard-rules.pro deleted file mode 100644 index 6a525b0..0000000 --- a/provider/proguard-rules.pro +++ /dev/null @@ -1,25 +0,0 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /home/marten/.local/Android/Sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/provider/src/main/AndroidManifest.xml b/provider/src/main/AndroidManifest.xml deleted file mode 100644 index 8cd8305..0000000 --- a/provider/src/main/AndroidManifest.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java b/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java deleted file mode 100644 index 58e3064..0000000 --- a/provider/src/main/java/org/dmfs/ngrams/NGramGenerator.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.ngrams; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; - - -/** - * Generator for N-grams from a given String. - * - * @author Marten Gajda - */ -public final class NGramGenerator -{ - /** - * A {@link Pattern} that matches anything that doesn't belong to a word or number. - */ - private final static Pattern SEPARATOR_PATTERN = Pattern.compile("[^\\p{L}\\p{M}\\d]+"); - - /** - * A {@link Pattern} that matches anything that doesn't belong to a word. - */ - private final static Pattern SEPARATOR_PATTERN_NO_NUMBERS = Pattern.compile("[^\\p{L}\\p{M}]+"); - - private final int mN; - private final int mMinWordLen; - private boolean mAllLowercase = true; - private boolean mReturnNumbers = true; - private boolean mAddSpaceInFront = false; - private Locale mLocale = Locale.getDefault(); - - - public NGramGenerator(int n) - { - this(n, 1); - } - - - public NGramGenerator(int n, int minWordLen) - { - mN = n; - mMinWordLen = minWordLen; - } - - - /** - * Set whether to convert all words to lower-case first. - * - * @param lowercase - * true to convert the test to lower case first. - * - * @return This instance. - */ - public NGramGenerator setAllLowercase(boolean lowercase) - { - mAllLowercase = lowercase; - return this; - } - - - /** - * Set whether to index the beginning of a word with a space in front. This slightly raises the weight of word beginnings when searching. - * - * @param addSpace - * true to add a space in front of each word, false otherwise. - * - * @return This instance. - */ - public NGramGenerator setAddSpaceInFront(boolean addSpace) - { - mAddSpaceInFront = addSpace; - return this; - } - - - /** - * Sets the {@link Locale} to use when converting the input string to lower case. This has no effect when {@link #setAllLowercase(boolean)} is called with - * false. - * - * @param locale - * The {@link Locale} to user for the conversion to lower case. - * - * @return This instance. - */ - public NGramGenerator setLocale(Locale locale) - { - mLocale = locale; - return this; - } - - - /** - * Get all N-grams contained in the given String. - * - * @param data - * The String to analyze. - * - * @return The {@link Set} containing the N-grams. - */ - public Set getNgrams(String data) - { - if (data == null) - { - return Collections.emptySet(); - } - - if (mAllLowercase) - { - data = data.toLowerCase(mLocale); - } - - String[] words = mReturnNumbers ? SEPARATOR_PATTERN.split(data) : SEPARATOR_PATTERN_NO_NUMBERS.split(data); - - Set set = new HashSet(128); - - for (String word : words) - { - getNgrams(word, set); - } - - return set; - } - - - private void getNgrams(String word, Set ngrams) - { - final int len = word.length(); - - if (len < mMinWordLen) - { - return; - } - - final int n = mN; - final int last = Math.max(1, len - n + 1); - - for (int i = 0; i < last; ++i) - { - ngrams.add(word.substring(i, Math.min(i + n, len))); - } - - if (mAddSpaceInFront) - { - /* - * Add another String with a space and the first n-1 characters of the word. - */ - ngrams.add(" " + word.substring(0, Math.min(len, n - 1))); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java b/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java deleted file mode 100644 index 369cc57..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/AuthorityUtil.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.content.Context; - -import de.jeanlucmakiola.agendula.provider.R; - - -/** - * Access for the authority name of the tasks content provider. - * - * @author Gabor Keszthelyi - */ -// TODO Figure out better design or at least rename to TaskAuthority.get(context) (results in changes in many files) -public final class AuthorityUtil -{ - private static String sCachedValue; - - - public static String taskAuthority(Context context) - { - if (sCachedValue == null) - { - sCachedValue = context.getString(R.string.agendula_tasks_authority); - } - return sCachedValue; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java deleted file mode 100644 index 622533c..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/ContentOperation.java +++ /dev/null @@ -1,419 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.annotation.SuppressLint; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.SharedPreferences.Editor; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; -import android.os.Handler; -import android.util.Log; - -import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.InstanceAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.tasks.Instantiating; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Instances; -import org.dmfs.tasks.contract.TaskContract.Tasks; - -import java.util.TimeZone; - - -public enum ContentOperation -{ - /** - * When the local timezone has been changed we need to update the due and start sorting values. This handler will take care of running the appropriate - * update. In addition it fires an operation to update all notifications. - */ - UPDATE_TIMEZONE(new OperationHandler() - { - @Override - public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) - { - long start = System.currentTimeMillis(); - - // request an update of all instance values - ContentValues vals = new ContentValues(1); - Instantiating.addUpdateRequest(vals); - - // execute update that triggers a recalculation of all due and start sorting values - int count = context.getContentResolver().update( - TaskContract.Tasks.getContentUri(uri.getAuthority()).buildUpon().appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true").build(), - vals, null, null); - - Log.i("TaskProvider", "time to update " + count + " tasks: " + (System.currentTimeMillis() - start) + " ms"); - - // now update alarms as well - UPDATE_NOTIFICATION_ALARM.fire(context, null); - } - }), - - /** - * Takes care of everything we need to send task start and task due broadcasts. - */ - POST_NOTIFICATIONS(new OperationHandler() - { - - @Override - public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) - { - TimeZone localTimeZone = TimeZone.getDefault(); - - // the date-time of when the last notification was shown - DateTime lastAlarm = getLastAlarmTimestamp(context); - // the current time, we show all notifications between and now - DateTime now = DateTime.nowAndHere(); - - String lastAlarmString = Long.toString(lastAlarm.getInstance()); - String nowString = Long.toString(now.getInstance()); - - // load all tasks that have started or became due since the last time we've shown a notification. - Cursor instancesCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, "((" + TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " - + TaskContract.Instances.INSTANCE_DUE_SORTING + "<=?) or (" + TaskContract.Instances.INSTANCE_START_SORTING + ">? and " - + TaskContract.Instances.INSTANCE_START_SORTING + "<=?)) and " + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { - lastAlarmString, nowString, lastAlarmString, nowString }, null, null, null); - - try - { - while (instancesCursor.moveToNext()) - { - InstanceAdapter task = new CursorContentValuesInstanceAdapter(InstanceAdapter._ID.getFrom(instancesCursor), instancesCursor, null); - - DateTime instanceDue = task.valueOf(InstanceAdapter.INSTANCE_DUE); - if (instanceDue != null && !instanceDue.isFloating()) - { - // make sure we compare instances in local time - instanceDue = instanceDue.shiftTimeZone(localTimeZone); - } - - DateTime instanceStart = task.valueOf(InstanceAdapter.INSTANCE_START); - if (instanceStart != null && !instanceStart.isFloating()) - { - // make sure we compare instances in local time - instanceStart = instanceStart.shiftTimeZone(localTimeZone); - } - - if (instanceDue != null && lastAlarm.getInstance() < instanceDue.getInstance() && instanceDue.getInstance() <= now.getInstance()) - { - // this task became due since the last alarm, send a due broadcast - sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_DUE, task.uri(uri.getAuthority())); - } - else if (instanceStart != null && lastAlarm.getInstance() < instanceStart.getInstance() && instanceStart.getInstance() <= now.getInstance()) - { - // this task has started since the last alarm, send a start broadcast - sendBroadcast(context, TaskContract.ACTION_BROADCAST_TASK_STARTING, task.uri(uri.getAuthority())); - } - } - } - finally - { - instancesCursor.close(); - } - - // all notifications up to now have been triggered - saveLastAlarmTime(context, now); - - // set the alarm for the next notification - UPDATE_NOTIFICATION_ALARM.fire(context, null); - } - - - @SuppressLint("NewApi") - private void saveLastAlarmTime(Context context, DateTime time) - { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - Editor editor = prefs.edit(); - editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); - editor.apply(); - } - - - private DateTime getLastAlarmTimestamp(Context context) - { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); - } - - - /** - * Sends a notification broadcast for a task instance that has started or became due. - * - * @param context - * A {@link Context}. - * @param action - * The broadcast action. - * @param uri - * The task uri. - */ - private void sendBroadcast(Context context, String action, Uri uri) - { - Intent intent = new Intent(action); - intent.setData(uri); - // only notify our own package - intent.setPackage(context.getPackageName()); - context.sendBroadcast(intent); - } - }), - - /** - * Determines the date-time of when the next task becomes due or starts (whatever happens first) and sets an alarm to trigger a notification. - */ - UPDATE_NOTIFICATION_ALARM(new OperationHandler() - { - - @Override - public void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values) - { - TimeZone localTimeZone = TimeZone.getDefault(); - DateTime lastAlarm = getLastAlarmTimestamp(context); - DateTime now = DateTime.nowAndHere(); - - if (now.before(lastAlarm)) - { - // time went backwards, set last alarm time to now - lastAlarm = now; - saveLastAlarmTime(context, now); - } - - String lastAlarmString = Long.toString(lastAlarm.getInstance()); - - DateTime nextAlarm = null; - - // find the next task that starts - Cursor nextInstanceStartCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_START_SORTING + ">? and " - + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, - TaskContract.Instances.INSTANCE_START_SORTING, "1"); - - try - { - if (nextInstanceStartCursor.moveToNext()) - { - TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceStartCursor), - nextInstanceStartCursor, null); - nextAlarm = task.valueOf(TaskAdapter.INSTANCE_START); - if (!nextAlarm.isFloating()) - { - nextAlarm = nextAlarm.shiftTimeZone(localTimeZone); - } - } - } - finally - { - nextInstanceStartCursor.close(); - } - - // find the next task that's due - Cursor nextInstanceDueCursor = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, null, TaskContract.Instances.INSTANCE_DUE_SORTING + ">? and " - + Instances.IS_CLOSED + " = 0 and " + Tasks._DELETED + "=0", new String[] { lastAlarmString }, null, null, - TaskContract.Instances.INSTANCE_DUE_SORTING, "1"); - - try - { - if (nextInstanceDueCursor.moveToNext()) - { - TaskAdapter task = new CursorContentValuesTaskAdapter(TaskAdapter.INSTANCE_TASK_ID.getFrom(nextInstanceDueCursor), nextInstanceDueCursor, - null); - DateTime nextDue = task.valueOf(TaskAdapter.INSTANCE_DUE); - if (!nextDue.isFloating()) - { - nextDue = nextDue.shiftTimeZone(localTimeZone); - } - - if (nextAlarm == null || nextAlarm.getInstance() > nextDue.getInstance()) - { - nextAlarm = nextDue; - } - } - } - finally - { - nextInstanceDueCursor.close(); - } - - if (nextAlarm != null) - { - TaskProviderBroadcastReceiver.planNotificationUpdate(context, nextAlarm); - } - else - { - saveLastAlarmTime(context, now); - } - } - - - @SuppressLint("NewApi") - private void saveLastAlarmTime(Context context, DateTime time) - { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - Editor editor = prefs.edit(); - editor.putLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, time.getTimestamp()); - editor.apply(); - } - - - private DateTime getLastAlarmTimestamp(Context context) - { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - return new DateTime(TimeZone.getDefault(), prefs.getLong(PREFS_KEY_LAST_ALARM_TIMESTAMP, System.currentTimeMillis())); - } - - }); - - /** - * A lock object to serialize the execution of all incoming {@link ContentOperation}. - */ - private final static Object mLock = new Object(); - - /** - * The base path of the Uri to trigger content operations. - */ - private final static String BASE_PATH = "content_operation"; - - /** - * The {@link OperationHandler} that handles this {@link ContentOperation}. - */ - private final OperationHandler mHandler; - - private static final String PREFS_NAME = "org.dmfs.provider.tasks"; - private static final String PREFS_KEY_LAST_ALARM_TIMESTAMP = "org.dmfs.provider.tasks.prefs.LAST_ALARM_TIMESTAMP"; - - - ContentOperation(OperationHandler handler) - { - mHandler = handler; - } - - - /** - * Execute this {@link ContentOperation} with the given values. - * - * @param context - * A {@link Context}. - * @param values - * Optional {@link ContentValues}, may be null. - */ - public void fire(Context context, ContentValues values) - { - context.getContentResolver().update(uri(AuthorityUtil.taskAuthority(context)), values == null ? new ContentValues() : values, null, null); - } - - - /** - * Run the operation on the given handler. - * - * @param context - * A {@link Context}. - * @param handler - * A {@link Handler} to run the operation on. - * @param uri - * The {@link Uri} that triggered this operation. - * @param db - * The database. - * @param values - * The {@link ContentValues} that were supplied. - */ - void run(final Context context, Handler handler, final Uri uri, final SQLiteDatabase db, final ContentValues values) - { - handler.post(new Runnable() - { - @Override - public void run() - { - synchronized (mLock) - { - mHandler.handleOperation(context, uri, db, values); - } - } - }); - } - - - /** - * Returns the {@link Uri} that triggers this {@link ContentOperation}. - * - * @param authority - * The authority of this provide. - * - * @return A {@link Uri}. - */ - private Uri uri(String authority) - { - return new Uri.Builder().scheme("content").authority(authority).path(BASE_PATH).appendPath(this.toString()).build(); - } - - - /** - * Register the operations with the given {@link UriMatcher}. - * - * @param uriMatcher - * The {@link UriMatcher}. - * @param authority - * The authority of this TaskProvider. - * @param firstID - * Teh first Id to use for our Uris. - */ - public static void register(UriMatcher uriMatcher, String authority, int firstID) - { - for (ContentOperation op : values()) - { - Uri uri = op.uri(authority); - uriMatcher.addURI(authority, uri.getPath().substring(1) /* remove leading slash */, firstID + op.ordinal()); - } - } - - - /** - * Return a {@link ContentOperation} that belongs to the given id. - * - * @param id - * The id or the {@link ContentOperation}. - * @param firstId - * The first ID to use for Uris. - * - * @return The respective {@link ContentOperation} or null if none was found. - */ - public static ContentOperation get(int id, int firstId) - { - if (id < firstId) - { - return null; - } - - if (id - firstId >= values().length) - { - return null; - } - - return values()[id - firstId]; - } - - - public interface OperationHandler - { - void handleOperation(Context context, Uri uri, SQLiteDatabase db, ContentValues values); - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java deleted file mode 100644 index 1093a4f..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/FTSDatabaseHelper.java +++ /dev/null @@ -1,630 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.text.TextUtils; - -import org.dmfs.jems.iterable.decorators.Chunked; -import org.dmfs.ngrams.NGramGenerator; -import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Properties; -import org.dmfs.tasks.contract.TaskContract.TaskColumns; -import org.dmfs.tasks.contract.TaskContract.Tasks; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - - -/** - * Supports the {@link TaskDatabaseHelper} in the matter of full-text-search. - * - * @author Tobias Reinsch - * @author Marten Gajda - */ -public class FTSDatabaseHelper -{ - /** - * We search the ngram table in chunks of 500. This should be good enough for an average task but still well below - * the SQLITE expression length limit and the variable count limit. - */ - private final static int NGRAM_SEARCH_CHUNK_SIZE = 500; - - private final static float SEARCH_RESULTS_MIN_SCORE = 0.33f; - - /** - * A Generator for 3-grams. - */ - private final static NGramGenerator TRIGRAM_GENERATOR = new NGramGenerator(3, 1).setAddSpaceInFront(true); - - /** - * A Generator for 4-grams. - */ - private final static NGramGenerator TETRAGRAM_GENERATOR = new NGramGenerator(4, 3 /* shorter words are fully covered by trigrams */).setAddSpaceInFront( - true); - private static final String PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s = ?", FTSContentColumns.TASK_ID, FTSContentColumns.TYPE, - FTSContentColumns.PROPERTY_ID); - private static final String NON_PROPERTY_NGRAM_SELECTION = String.format("%s = ? AND %s = ? AND %s is null", FTSContentColumns.TASK_ID, - FTSContentColumns.TYPE, - FTSContentColumns.PROPERTY_ID); - private static final String[] NGRAM_SYNC_COLUMNS = { "_rowid_", FTSContentColumns.NGRAM_ID }; - - - /** - * Search content columns. Defines all the columns for the full text search - * - * @author Tobias Reinsch - */ - public interface FTSContentColumns - { - /** - * The row id of the belonging task. - */ - String TASK_ID = "fts_task_id"; - - /** - * The the property id of the searchable entry or null if the entry is not related to a property. - */ - String PROPERTY_ID = "fts_property_id"; - - /** - * The the type of the searchable entry - */ - String TYPE = "fts_type"; - - /** - * An n-gram for a task. - */ - String NGRAM_ID = "fts_ngram_id"; - - } - - - /** - * The columns of the N-gram table for the FTS search - * - * @author Tobias Reinsch - */ - public interface NGramColumns - { - /** - * The row id of the N-gram. - */ - String NGRAM_ID = "ngram_id"; - - /** - * The content of the N-gram - */ - String TEXT = "ngram_text"; - - } - - - public static final String FTS_CONTENT_TABLE = "FTS_Content"; - public static final String FTS_NGRAM_TABLE = "FTS_Ngram"; - public static final String FTS_TASK_VIEW = "FTS_Task_View"; - public static final String FTS_TASK_PROPERTY_VIEW = "FTS_Task_Property_View"; - - /** - * SQL command to create the table for full text search and contains relationships between ngrams and tasks - */ - private final static String SQL_CREATE_SEARCH_CONTENT_TABLE = "CREATE TABLE " + FTS_CONTENT_TABLE + "( " + FTSContentColumns.TASK_ID + " Integer, " - + FTSContentColumns.NGRAM_ID + " Integer, " + FTSContentColumns.PROPERTY_ID + " Integer, " + FTSContentColumns.TYPE + " Integer, " + "FOREIGN KEY(" - + FTSContentColumns.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ")," + "FOREIGN KEY(" + FTSContentColumns.TASK_ID - + ") REFERENCES " + Tables.TASKS + "(" + TaskColumns._ID + ") UNIQUE (" + FTSContentColumns.TASK_ID + ", " + FTSContentColumns.TYPE + ", " - + FTSContentColumns.PROPERTY_ID + ") ON CONFLICT IGNORE )"; - - /** - * SQL command to create the table that stores the NGRAMS - */ - private final static String SQL_CREATE_NGRAM_TABLE = "CREATE TABLE " + FTS_NGRAM_TABLE + "( " + NGramColumns.NGRAM_ID - + " Integer PRIMARY KEY AUTOINCREMENT, " + NGramColumns.TEXT + " Text)"; - - // FIXME: at present the minimum score is hard coded can we leave that decision to the caller? - private final static String SQL_RAW_QUERY_SEARCH_TASK = "SELECT %s " + ", (1.0*count(DISTINCT " + NGramColumns.NGRAM_ID + ")/?) as " + TaskContract.Tasks.SCORE + " from " - + FTS_NGRAM_TABLE + " join " + FTS_CONTENT_TABLE + " on (" + FTS_NGRAM_TABLE + "." + NGramColumns.NGRAM_ID + "=" + FTS_CONTENT_TABLE + "." - + FTSContentColumns.NGRAM_ID + ") join " + Tables.INSTANCE_VIEW + " on (" + Tables.INSTANCE_VIEW + "." + TaskContract.Instances.TASK_ID + " = " + FTS_CONTENT_TABLE + "." - + FTSContentColumns.TASK_ID + ") where %s group by " + TaskContract.Instances.TASK_ID + " having " + TaskContract.Tasks.SCORE + " >= " + SEARCH_RESULTS_MIN_SCORE - + " and " + Tasks.VISIBLE + " = 1 order by %s;"; - - private final static String SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION = Tables.INSTANCE_VIEW + ".* ," + FTS_NGRAM_TABLE + "." + NGramColumns.TEXT; - - private final static String SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER = "CREATE TRIGGER search_task_delete_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " - + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Tasks._ID + "; END"; - - private final static String SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER = "CREATE TRIGGER search_task_delete_property_trigger AFTER DELETE ON " - + Tables.PROPERTIES + " BEGIN " + " DELETE FROM " + FTS_CONTENT_TABLE + " WHERE " + FTSContentColumns.TASK_ID + " = old." + Properties.TASK_ID - + " AND " + FTSContentColumns.PROPERTY_ID + " = old." + Properties.PROPERTY_ID + "; END"; - - - /** - * The different types of searchable entries for tasks linked to the TYPE column. - * - * @author Tobias Reinsch - * @author Marten Gajda - */ - public interface SearchableTypes - { - /** - * This is an entry for the title of a task. - */ - int TITLE = 1; - - /** - * This is an entry for the description of a task. - */ - int DESCRIPTION = 2; - - /** - * This is an entry for the location of a task. - */ - int LOCATION = 3; - - /** - * This is an entry for a property of a task. - */ - int PROPERTY = 4; - - } - - - public static void onCreate(SQLiteDatabase db) - { - initializeFTS(db); - } - - - public static void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) - { - if (oldVersion < 8) - { - initializeFTS(db); - initializeFTSContent(db); - } - if (oldVersion < 16) - { - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, - FTSContentColumns.PROPERTY_ID)); - } - } - - - /** - * Creates the tables and triggers used in FTS. - * - * @param db - * The {@link SQLiteDatabase}. - */ - private static void initializeFTS(SQLiteDatabase db) - { - db.execSQL(SQL_CREATE_SEARCH_CONTENT_TABLE); - db.execSQL(SQL_CREATE_NGRAM_TABLE); - db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_TRIGGER); - db.execSQL(SQL_CREATE_SEARCH_TASK_DELETE_PROPERTY_TRIGGER); - - // create indices - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_NGRAM_TABLE, true, NGramColumns.TEXT)); - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.NGRAM_ID)); - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, false, FTSContentColumns.TASK_ID)); - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.PROPERTY_ID, FTSContentColumns.TASK_ID, - FTSContentColumns.NGRAM_ID)); - - db.execSQL(TaskDatabaseHelper.createIndexString(FTS_CONTENT_TABLE, true, FTSContentColumns.TYPE, FTSContentColumns.TASK_ID, - FTSContentColumns.PROPERTY_ID)); - - } - - - /** - * Creates the FTS entries for the existing tasks. - * - * @param db - * The writable {@link SQLiteDatabase}. - */ - private static void initializeFTSContent(SQLiteDatabase db) - { - String[] task_projection = new String[] { Tasks._ID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION }; - Cursor c = db.query(Tables.TASKS_PROPERTY_VIEW, task_projection, null, null, null, null, null); - while (c.moveToNext()) - { - insertTaskFTSEntries(db, c.getLong(0), c.getString(1), c.getString(2), c.getString(3)); - } - c.close(); - } - - - /** - * Inserts the searchable texts of the task in the database. - * - * @param db - * The writable {@link SQLiteDatabase}. - * @param taskId - * The row id of the task. - * @param title - * The title of the task. - * @param description - * The description of the task. - */ - private static void insertTaskFTSEntries(SQLiteDatabase db, long taskId, String title, String description, String location) - { - // title - if (title != null && title.length() > 0) - { - updateEntry(db, taskId, -1, SearchableTypes.TITLE, title); - } - - // location - if (location != null && location.length() > 0) - { - updateEntry(db, taskId, -1, SearchableTypes.LOCATION, location); - } - - // description - if (description != null && description.length() > 0) - { - updateEntry(db, taskId, -1, SearchableTypes.DESCRIPTION, description); - } - - } - - - /** - * Updates the existing searchables entries for the task. - * - * @param db - * The writable {@link SQLiteDatabase}. - * @param task - * The {@link TaskAdapter} containing the new values. - */ - public static void updateTaskFTSEntries(SQLiteDatabase db, TaskAdapter task) - { - // title - if (task.isUpdated(TaskAdapter.TITLE)) - { - updateEntry(db, task.id(), -1, SearchableTypes.TITLE, task.valueOf(TaskAdapter.TITLE)); - } - - // location - if (task.isUpdated(TaskAdapter.LOCATION)) - { - updateEntry(db, task.id(), -1, SearchableTypes.LOCATION, task.valueOf(TaskAdapter.LOCATION)); - } - - // description - if (task.isUpdated(TaskAdapter.DESCRIPTION)) - { - updateEntry(db, task.id(), -1, SearchableTypes.DESCRIPTION, task.valueOf(TaskAdapter.DESCRIPTION)); - } - - } - - - /** - * Updates or creates the searchable entries for a property. Passing null as searchable text will remove the entry. - * - * @param db - * The writable {@link SQLiteDatabase}. - * @param taskId - * the row id of the task this property belongs to. - * @param propertyId - * the id of the property - * @param searchableText - * the searchable text value of the property - */ - public static void updatePropertyFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String searchableText) - { - updateEntry(db, taskId, propertyId, SearchableTypes.PROPERTY, searchableText); - } - - - /** - * Returns the IDs of each of the provided ngrams, creating them in th database if necessary. - * - * @param db - * A writable {@link SQLiteDatabase}. - * @param ngrams - * The NGrams. - * - * @return The ids of the ngrams in the given set. - */ - private static Set ngramIds(SQLiteDatabase db, Set ngrams) - { - if (ngrams.size() == 0) - { - return Collections.emptySet(); - } - - Set missingNgrams = new HashSet<>(ngrams); - Set ngramIds = new HashSet<>(ngrams.size() * 2); - - for (Iterable chunk : new Chunked<>(NGRAM_SEARCH_CHUNK_SIZE, ngrams)) - { - // build selection and arguments for each chunk - // we can't do this in a single query because the length of sql statement and number of arguments is limited. - - StringBuilder selection = new StringBuilder(NGramColumns.TEXT); - selection.append(" in ("); - boolean first = true; - List arguments = new ArrayList<>(NGRAM_SEARCH_CHUNK_SIZE); - for (String ngram : chunk) - { - if (first) - { - first = false; - } - else - { - selection.append(","); - } - selection.append("?"); - arguments.add(ngram); - } - selection.append(" )"); - - try (Cursor c = db.query(FTS_NGRAM_TABLE, new String[] { NGramColumns.NGRAM_ID, NGramColumns.TEXT }, selection.toString(), - arguments.toArray(new String[0]), null, null, null)) - { - while (c.moveToNext()) - { - // remove the ngrams we already have in the table - missingNgrams.remove(c.getString(1)); - // remember its id - ngramIds.add(c.getLong(0)); - } - } - } - - ContentValues values = new ContentValues(1); - - // now insert the missing ngrams and store their ids - for (String ngram : missingNgrams) - { - values.put(NGramColumns.TEXT, ngram); - ngramIds.add(db.insert(FTS_NGRAM_TABLE, null, values)); - } - return ngramIds; - - } - - - private static void updateEntry(SQLiteDatabase db, long taskId, long propertyId, int type, String searchableText) - { - // generate nGrams - Set propertyNgrams = TRIGRAM_GENERATOR.getNgrams(searchableText); - propertyNgrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchableText)); - - // get an ID for each of the Ngrams. - Set ngramIds = ngramIds(db, propertyNgrams); - - // unlink unused ngrams from the task and get the missing ones we have to link to the tak - Set missing = syncNgrams(db, taskId, propertyId, type, ngramIds); - - // insert ngram relations for all new ngrams - addNgrams(db, missing, taskId, propertyId, type); - } - - - /** - * Inserts NGrams relations for a task entry. - * - * @param db - * A writable {@link SQLiteDatabase}. - * @param ngramIds - * The set of NGram ids. - * @param taskId - * The row id of the task. - * @param propertyId - * The row id of the property. - */ - private static void addNgrams(SQLiteDatabase db, Set ngramIds, long taskId, Long propertyId, int contentType) - { - ContentValues values = new ContentValues(4); - for (Long ngramId : ngramIds) - { - values.put(FTSContentColumns.TASK_ID, taskId); - values.put(FTSContentColumns.NGRAM_ID, ngramId); - values.put(FTSContentColumns.TYPE, contentType); - if (contentType == SearchableTypes.PROPERTY) - { - values.put(FTSContentColumns.PROPERTY_ID, propertyId); - } - else - { - values.putNull(FTSContentColumns.PROPERTY_ID); - } - db.insert(FTS_CONTENT_TABLE, null, values); - } - - } - - - /** - * Synchronizes the NGram relations of a task - * - * @param db - * The writable {@link SQLiteDatabase}. - * @param taskId - * The task row id. - * @param propertyId - * The property row id, ignored if contentType is not {@link SearchableTypes#PROPERTY}. - * @param contentType - * The {@link SearchableTypes} type. - * @param ngramsIds - * The set of ngrams ids which should be linked to the task - * - * @return The number of deleted relations. - */ - private static Set syncNgrams(SQLiteDatabase db, long taskId, long propertyId, int contentType, Set ngramsIds) - { - String selection; - String[] selectionArgs; - if (SearchableTypes.PROPERTY == contentType) - { - selection = PROPERTY_NGRAM_SELECTION; - selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType), String.valueOf(propertyId) }; - } - else - { - selection = NON_PROPERTY_NGRAM_SELECTION; - selectionArgs = new String[] { String.valueOf(taskId), String.valueOf(contentType) }; - } - - // In order to sync the ngrams, we go over each existing ngram and delete ngram relations not in the set of new ngrams - // Then we return the set of ngrams we didn't find - Set missing = new HashSet<>(ngramsIds); - try (Cursor c = db.query(FTS_CONTENT_TABLE, NGRAM_SYNC_COLUMNS, selection, selectionArgs, null, null, null)) - { - while (c.moveToNext()) - { - Long ngramId = c.getLong(1); - if (!ngramsIds.contains(ngramId)) - { - db.delete(FTS_CONTENT_TABLE, "_rowid_ = ?", new String[] { c.getString(0) }); - } - else - { - // this ngram wasn't missing - missing.remove(ngramId); - } - } - } - return missing; - } - - - /** - * Queries the task database to get a cursor with the search results. - * - * @param db - * The {@link SQLiteDatabase}. - * @param searchString - * The search query string. - * @param projection - * The database projection for the query. - * @param selection - * The selection for the query. - * @param selectionArgs - * The arguments for the query. - * @param sortOrder - * The sorting order of the query. - * - * @return A cursor of the task database with the search result. - */ - public static Cursor getTaskSearchCursor(SQLiteDatabase db, String searchString, String[] projection, String selection, String[] selectionArgs, - String sortOrder) - { - - StringBuilder selectionBuilder = new StringBuilder(1024); - - if (!TextUtils.isEmpty(selection)) - { - selectionBuilder.append(" ("); - selectionBuilder.append(selection); - selectionBuilder.append(") AND ("); - } - else - { - selectionBuilder.append(" ("); - } - - Set ngrams = TRIGRAM_GENERATOR.getNgrams(searchString); - ngrams.addAll(TETRAGRAM_GENERATOR.getNgrams(searchString)); - - String[] queryArgs; - - if (searchString != null && searchString.length() > 1) - { - - selectionBuilder.append(NGramColumns.TEXT); - selectionBuilder.append(" in ("); - - for (int i = 0, count = ngrams.size(); i < count; ++i) - { - if (i > 0) - { - selectionBuilder.append(","); - } - selectionBuilder.append("?"); - - } - - // selection arguments - if (selectionArgs != null && selectionArgs.length > 0) - { - queryArgs = new String[selectionArgs.length + ngrams.size() + 1]; - queryArgs[0] = String.valueOf(ngrams.size()); - System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); - String[] ngramArray = ngrams.toArray(new String[ngrams.size()]); - System.arraycopy(ngramArray, 0, queryArgs, selectionArgs.length + 1, ngramArray.length); - } - else - { - String[] temp = ngrams.toArray(new String[ngrams.size()]); - - queryArgs = new String[temp.length + 1]; - queryArgs[0] = String.valueOf(ngrams.size()); - System.arraycopy(temp, 0, queryArgs, 1, temp.length); - } - selectionBuilder.append(" ) "); - } - else - { - selectionBuilder.append(NGramColumns.TEXT); - selectionBuilder.append(" like ?"); - - // selection arguments - if (selectionArgs != null && selectionArgs.length > 0) - { - queryArgs = new String[selectionArgs.length + 2]; - queryArgs[0] = String.valueOf(ngrams.size()); - System.arraycopy(selectionArgs, 0, queryArgs, 1, selectionArgs.length); - queryArgs[queryArgs.length - 1] = " " + searchString + "%"; - } - else - { - queryArgs = new String[2]; - queryArgs[0] = String.valueOf(ngrams.size()); - queryArgs[1] = " " + searchString + "%"; - } - - } - - selectionBuilder.append(") AND "); - selectionBuilder.append(Tasks._DELETED); - selectionBuilder.append(" = 0"); - - if (sortOrder == null) - { - sortOrder = Tasks.SCORE + " desc"; - } - else - { - sortOrder = Tasks.SCORE + " desc, " + sortOrder; - } - Cursor c = db.rawQueryWithFactory(null, - String.format(SQL_RAW_QUERY_SEARCH_TASK, SQL_RAW_QUERY_SEARCH_TASK_DEFAULT_PROJECTION, selectionBuilder.toString(), sortOrder), queryArgs, - null); - return c; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java b/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java deleted file mode 100644 index 2d50bfd..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/ProviderOperation.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -/** - * @author Marten Gajda - */ -public enum ProviderOperation -{ - - /** - * Insert operations. - */ - INSERT, - - /** - * Update operations. - */ - UPDATE, - - /** - * Delete operations. - */ - DELETE -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java deleted file mode 100644 index 21c54ae..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/SQLiteContentProvider.java +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Copyright (C) 2009 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package org.dmfs.provider.tasks; - -import android.content.ContentProvider; -import android.content.ContentProviderOperation; -import android.content.ContentProviderResult; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.OperationApplicationException; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.net.Uri; - -import org.dmfs.iterables.SingletonIterable; -import org.dmfs.jems.fragile.Fragile; -import org.dmfs.jems.iterable.composite.Joined; -import org.dmfs.jems.single.Single; -import org.dmfs.provider.tasks.utils.Profiled; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; - - -/** - * General purpose {@link ContentProvider} base class that uses SQLiteDatabase for storage. - */ -/* - * Changed by marten@dmfs.org: - * - * removed protected mDb field and replaced it by local fields. There is no reason to store the database if we get a new one for every transaction. Instead we - * also pass the database to the *InTransaction methods. - * - * update visibility of class and methods - */ -abstract class SQLiteContentProvider extends ContentProvider -{ - - interface TransactionEndTask - { - void execute(SQLiteDatabase database); - } - - - @SuppressWarnings("unused") - private static final String TAG = "SQLiteContentProvider"; - - private SQLiteOpenHelper mOpenHelper; - private final Set mChangedUris = new HashSet<>(); - - private final ThreadLocal mApplyingBatch = new ThreadLocal(); - private static final int SLEEP_AFTER_YIELD_DELAY = 4000; - - /** - * Maximum number of operations allowed in a batch between yield points. - */ - private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500; - - private final Iterable mTransactionEndTasks; - - - protected SQLiteContentProvider(Iterable transactionEndTasks) - { - // append a task to set the transaction to successful - mTransactionEndTasks = new Joined<>(transactionEndTasks, new SingletonIterable<>(new SuccessfulTransactionEndTask())); - } - - - @Override - public boolean onCreate() - { - mOpenHelper = getDatabaseHelper(getContext()); - return true; - } - - - /** - * Returns a {@link SQLiteOpenHelper} that can open the database. - */ - protected abstract SQLiteOpenHelper getDatabaseHelper(Context context); - - /** - * The equivalent of the {@link #insert} method, but invoked within a transaction. - */ - public abstract Uri insertInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, boolean callerIsSyncAdapter); - - /** - * The equivalent of the {@link #update} method, but invoked within a transaction. - */ - public abstract int updateInTransaction(SQLiteDatabase db, Uri uri, ContentValues values, String selection, String[] selectionArgs, - boolean callerIsSyncAdapter); - - /** - * The equivalent of the {@link #delete} method, but invoked within a transaction. - */ - public abstract int deleteInTransaction(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, boolean callerIsSyncAdapter); - - - /** - * Call this to add a URI to the list of URIs to be notified when the transaction is committed. - */ - protected void postNotifyUri(Uri uri) - { - synchronized (mChangedUris) - { - mChangedUris.add(uri); - } - } - - - public boolean isCallerSyncAdapter(Uri uri) - { - return false; - } - - - public SQLiteOpenHelper getDatabaseHelper() - { - return mOpenHelper; - } - - - private boolean applyingBatch() - { - return mApplyingBatch.get() != null && mApplyingBatch.get(); - } - - - @Override - public Uri insert(Uri uri, ContentValues values) - { - return new Profiled("Insert").run((Single) () -> - { - Uri result; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) - { - db.beginTransaction(); - try - { - result = insertInTransaction(db, uri, values, callerIsSyncAdapter); - endTransaction(db); - } - finally - { - db.endTransaction(); - } - onEndTransaction(callerIsSyncAdapter); - } - else - { - result = insertInTransaction(db, uri, values, callerIsSyncAdapter); - } - return result; - }); - } - - - @Override - public int bulkInsert(Uri uri, ContentValues[] values) - { - return new Profiled("BulkInsert").run((Single) () -> - { - int numValues = values.length; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - db.beginTransaction(); - try - { - for (int i = 0; i < numValues; i++) - { - insertInTransaction(db, uri, values[i], callerIsSyncAdapter); - db.yieldIfContendedSafely(); - } - endTransaction(db); - } - finally - { - db.endTransaction(); - } - onEndTransaction(callerIsSyncAdapter); - return numValues; - }); - } - - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) - { - return new Profiled("Update").run((Single) () -> - { - int count; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) - { - db.beginTransaction(); - try - { - count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); - endTransaction(db); - } - finally - { - db.endTransaction(); - } - onEndTransaction(callerIsSyncAdapter); - } - else - { - count = updateInTransaction(db, uri, values, selection, selectionArgs, callerIsSyncAdapter); - } - return count; - }); - } - - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) - { - return new Profiled("Delete").run((Single) () -> - { - int count; - boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); - boolean applyingBatch = applyingBatch(); - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - if (!applyingBatch) - { - db.beginTransaction(); - try - { - count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); - endTransaction(db); - } - finally - { - db.endTransaction(); - } - onEndTransaction(callerIsSyncAdapter); - } - else - { - count = deleteInTransaction(db, uri, selection, selectionArgs, callerIsSyncAdapter); - } - return count; - }); - } - - - @Override - public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException - { - return new Profiled(String.format(Locale.ENGLISH, "Batch of %d operations", operations.size())).run( - (Fragile) () -> - { - int ypCount = 0; - int opCount = 0; - boolean callerIsSyncAdapter = false; - SQLiteDatabase db = mOpenHelper.getWritableDatabase(); - db.beginTransaction(); - try - { - mApplyingBatch.set(true); - final int numOperations = operations.size(); - final ContentProviderResult[] results = new ContentProviderResult[numOperations]; - for (int i = 0; i < numOperations; i++) - { - if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) - { - throw new OperationApplicationException("Too many content provider operations between yield points. " - + "The maximum number of operations per yield point is " + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); - } - final ContentProviderOperation operation = operations.get(i); - if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) - { - callerIsSyncAdapter = true; - } - if (i > 0 && operation.isYieldAllowed()) - { - opCount = 0; - if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) - { - ypCount++; - } - } - results[i] = operation.apply(this, results, i); - } - endTransaction(db); - return results; - } - finally - { - mApplyingBatch.set(false); - db.endTransaction(); - onEndTransaction(callerIsSyncAdapter); - } - }); - } - - - protected void onEndTransaction(boolean callerIsSyncAdapter) - { - Set changed; - synchronized (mChangedUris) - { - changed = new HashSet(mChangedUris); - mChangedUris.clear(); - } - ContentResolver resolver = getContext().getContentResolver(); - for (Uri uri : changed) - { - boolean syncToNetwork = !callerIsSyncAdapter && syncToNetwork(uri); - resolver.notifyChange(uri, null, syncToNetwork); - } - } - - - protected boolean syncToNetwork(Uri uri) - { - return false; - } - - - private void endTransaction(SQLiteDatabase database) - { - for (TransactionEndTask task : mTransactionEndTasks) - { - task.execute(database); - } - } - - - /** - * A {@link TransactionEndTask} which sets the transaction to be successful. - */ - private static class SuccessfulTransactionEndTask implements TransactionEndTask - { - @Override - public void execute(SQLiteDatabase database) - { - database.setTransactionSuccessful(); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java deleted file mode 100644 index a78d8c7..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/TaskDatabaseHelper.java +++ /dev/null @@ -1,895 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.util.Log; - -import org.dmfs.jems.optional.adapters.First; -import org.dmfs.jems.predicate.elementary.Equals; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.provider.tasks.processors.NoOpProcessor; -import org.dmfs.provider.tasks.processors.tasks.Instantiating; -import org.dmfs.provider.tasks.utils.TableColumns; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Properties; -import org.dmfs.tasks.contract.TaskContract.Property.Alarm; -import org.dmfs.tasks.contract.TaskContract.Property.Category; -import org.dmfs.tasks.contract.TaskContract.TaskLists; -import org.dmfs.tasks.contract.TaskContract.Tasks; - -import java.util.Locale; - - -/** - * Task database helper takes care of creating and updating the task database, including tables, indices and triggers. - * - * @author Marten Gajda - * @author Tobias Reinsch - */ -public class TaskDatabaseHelper extends SQLiteOpenHelper -{ - - /** - * Interface of a listener that's called when the database has been created or migrated. - */ - public interface OnDatabaseOperationListener - { - void onDatabaseCreated(SQLiteDatabase db); - - void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion); - } - - - private static final String TAG = "TaskDatabaseHelper"; - - /** - * The name of our database file. - */ - private static final String DATABASE_NAME = "tasks.db"; - - /** - * The database version. - */ - private static final int DATABASE_VERSION = 23; - - - /** - * List of all tables we provide. - */ - public interface Tables - { - String LISTS = "Lists"; - - String WRITEABLE_LISTS = "Writeable_Lists"; - - String TASKS = "Tasks"; - - String TASKS_VIEW = "Task_View"; - - String TASKS_PROPERTY_VIEW = "Task_Property_View"; - - String INSTANCES = "Instances"; - - String INSTANCE_VIEW = "Instance_View"; - - String INSTANCE_CLIENT_VIEW = "Instance_Client_View"; - - String INSTANCE_PROPERTY_VIEW = "Instance_Property_View"; - - String INSTANCE_CATEGORY_VIEW = "Instance_Cagetory_View"; - - String CATEGORIES = "Categories"; - - String CATEGORIES_MAPPING = "Categories_Mapping"; - - String PROPERTIES = "Properties"; - - String ALARMS = "Alarms"; - - String SYNCSTATE = "SyncState"; - } - - - /** - * Columns of internal table for the category mapping. - */ - public interface CategoriesMapping - { - String TASK_ID = "task_id"; - - String CATEGORY_ID = "category_id"; - - String PROPERTY_ID = "property_id"; - - } - - - /** - * SQL command to create a view that combines tasks with some data from the list they belong to. - */ - private final static String SQL_CREATE_TASK_VIEW = "create view " + Tables.TASKS_VIEW + " as select " + - Tables.TASKS + ".*, " + - Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + - Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + - Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + - Tables.LISTS + "." + Tasks.LIST_NAME + ", " + - Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + - Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + - Tables.LISTS + "." + Tasks.VISIBLE + - " from " + Tables.TASKS + " join " + Tables.LISTS + - " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ");"; - - /** - * SQL command to create a view that combines tasks with some data from the list they belong to. - */ - private final static String SQL_CREATE_TASK_PROPERTY_VIEW = "create view " + Tables.TASKS_PROPERTY_VIEW + " as select " + - Tables.TASKS + ".*, " + - Tables.PROPERTIES + ".*, " + - Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " + - Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " + - Tables.LISTS + "." + Tasks.LIST_OWNER + ", " + - Tables.LISTS + "." + Tasks.LIST_NAME + ", " + - Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " + - Tables.LISTS + "." + Tasks.LIST_COLOR + ", " + - Tables.LISTS + "." + Tasks.VISIBLE + - " from " + Tables.TASKS + " join " + Tables.LISTS + - " on (" + Tables.TASKS + "." + Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskLists._ID + ") " + - "left join " + Tables.PROPERTIES + " on (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; - - /** - * SQL command to drop the task view. - */ - private final static String SQL_DROP_TASK_VIEW = "DROP VIEW " + Tables.TASKS_VIEW + ";"; - - /** - * SQL command to create a view that combines task instances with some data from the list they belong to. - */ - private final static String SQL_CREATE_INSTANCE_VIEW = "CREATE VIEW " + Tables.INSTANCE_VIEW + " AS SELECT " - + Tables.INSTANCES + ".*, " - + Tables.TASKS + ".*, " - + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " - + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " - + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " - + Tables.LISTS + "." + Tasks.LIST_NAME + ", " - + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " - + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " - + Tables.LISTS + "." + Tasks.VISIBLE - + " FROM " + Tables.TASKS - + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" - + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; - - /** - * SQL command to create a view that combines task instances with some data from the list they belong to. This replaces the task DTSTART, DUE and - * ORIGINAL_INSTANCE_TIME values with respective values of the instance. - *

- * This is the instances view as seen by the content provider clients. - */ - private final static String SQL_CREATE_INSTANCE_CLIENT_VIEW = "CREATE VIEW " + Tables.INSTANCE_CLIENT_VIEW + " AS SELECT " - + Tables.INSTANCES + ".*, " - // override task due, start and original times with the instance values - + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_START + " as " + Tasks.DTSTART + ", " - + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_DUE + " as " + Tasks.DUE + ", " - + Tables.INSTANCES + "." + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " as " + Tasks.ORIGINAL_INSTANCE_TIME + ", " - // override task duration with null, we already have a due - + "null as " + Tasks.DURATION + ", " - // override recurrence values with null, instances themselves are not recurring - + "null as " + Tasks.RRULE + ", " - + "null as " + Tasks.RDATE + ", " - + "null as " + Tasks.EXDATE + ", " - // this instance is part of a recurring task if either it has recurrence values or overrides an instance - + "not (" + Tasks.RRULE + " is null and " + Tasks.RDATE + " is null and " + Tasks.ORIGINAL_INSTANCE_ID + " is null and " + Tasks.ORIGINAL_INSTANCE_SYNC_ID + " is null) as " + TaskContract.Instances.IS_RECURRING + ", " - + Tables.TASKS + ".*, " - + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " - + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " - + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " - + Tables.LISTS + "." + Tasks.LIST_NAME + ", " - + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " - + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " - + Tables.LISTS + "." + Tasks.VISIBLE - + " FROM " + Tables.TASKS - + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.TaskLists._ID + ")" - + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; - - /** - * SQL command to create a view that combines task instances view with the belonging properties. - */ - private final static String SQL_CREATE_INSTANCE_PROPERTY_VIEW = "CREATE VIEW " + Tables.INSTANCE_PROPERTY_VIEW + " AS SELECT " - + Tables.INSTANCES + ".*, " - + Tables.PROPERTIES + ".*, " - + Tables.TASKS + ".*, " - + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " - + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " - + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " - + Tables.LISTS + "." + Tasks.LIST_NAME + ", " - + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " - + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " - + Tables.LISTS + "." + Tasks.VISIBLE - + " FROM " + Tables.TASKS - + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" - + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" - + " LEFT JOIN " + Tables.PROPERTIES + " ON (" + Tables.TASKS + "." + Tasks._ID + "=" + Tables.PROPERTIES + "." + Properties.TASK_ID + ");"; - - /** - * SQL command to create a view that combines task instances with some data from the list they belong to. - */ - private final static String SQL_CREATE_INSTANCE_CATEGORY_VIEW = "CREATE VIEW " + Tables.INSTANCE_CATEGORY_VIEW + " AS SELECT " - + Tables.INSTANCES + ".*, " - + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.CATEGORY_ID + ", " - + Tables.TASKS + ".*, " - + Tables.LISTS + "." + Tasks.ACCOUNT_NAME + ", " - + Tables.LISTS + "." + Tasks.ACCOUNT_TYPE + ", " - + Tables.LISTS + "." + Tasks.LIST_OWNER + ", " - + Tables.LISTS + "." + Tasks.LIST_NAME + ", " - + Tables.LISTS + "." + Tasks.LIST_ACCESS_LEVEL + ", " - + Tables.LISTS + "." + Tasks.LIST_COLOR + ", " - + Tables.LISTS + "." + Tasks.VISIBLE - + " FROM " + Tables.TASKS - + " JOIN " + Tables.LISTS + " ON (" + Tables.TASKS + "." + TaskContract.Tasks.LIST_ID + "=" + Tables.LISTS + "." + TaskContract.Tasks._ID + ")" - + " JOIN " + Tables.INSTANCES + " ON (" + Tables.TASKS + "." + TaskContract.Tasks._ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ")" - + " LEFT JOIN " + Tables.CATEGORIES_MAPPING + " ON (" + Tables.CATEGORIES_MAPPING + "." + CategoriesMapping.TASK_ID + "=" + Tables.INSTANCES + "." + TaskContract.Instances.TASK_ID + ");"; - - /** - * SQL command to drop the instance view. - */ - private final static String SQL_DROP_INSTANCE_VIEW = "DROP VIEW " + Tables.INSTANCE_VIEW + ";"; - - /** - * SQL command to drop the instance property view. - */ - //private final static String SQL_DROP_INSTANCE_PROPERTY_VIEW = "DROP VIEW " + Tables.INSTANCE_PROPERTY_VIEW + ";"; - - /** - * SQL command to create the instances table. - */ - private final static String SQL_CREATE_SYNCSTATE_TABLE = - "CREATE TABLE " + Tables.SYNCSTATE + " ( " + - TaskContract.SyncState._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " - + TaskContract.SyncState.ACCOUNT_NAME + " TEXT, " - + TaskContract.SyncState.ACCOUNT_TYPE + " TEXT, " - + TaskContract.SyncState.DATA + " TEXT " - + ");"; - - /** - * SQL command to create the instances table. - */ - private final static String SQL_CREATE_INSTANCES_TABLE = - "CREATE TABLE " + Tables.INSTANCES + " ( " + - TaskContract.Instances._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " - + TaskContract.Instances.TASK_ID + " INTEGER NOT NULL, " // NOT NULL - + TaskContract.Instances.INSTANCE_START + " INTEGER, " - + TaskContract.Instances.INSTANCE_DUE + " INTEGER, " - + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER, " - + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER, " - + TaskContract.Instances.INSTANCE_DURATION + " INTEGER, " - + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " INTEGER DEFAULT 0, " - + TaskContract.Instances.DISTANCE_FROM_CURRENT + " INTEGER DEFAULT 0);"; - - /** - * SQL command to create a trigger to clean up data of removed tasks. - */ - private final static String SQL_CREATE_TASKS_CLEANUP_TRIGGER = - "CREATE TRIGGER task_cleanup_trigger AFTER DELETE ON " + Tables.TASKS - + " BEGIN " - + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + TaskContract.Properties.TASK_ID + "= old." + TaskContract.Tasks._ID + ";" - + " DELETE FROM " + Tables.INSTANCES + " WHERE " + TaskContract.Instances.TASK_ID + "=old." + TaskContract.Tasks._ID + ";" - + " END;"; - - /** - * SQL command to create a trigger to clean up data of removed lists. - */ - private final static String SQL_CREATE_LISTS_CLEANUP_TRIGGER = - "CREATE TRIGGER list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS - + " BEGIN " - + " DELETE FROM " + Tables.TASKS + " WHERE " + Tasks.LIST_ID + "= old." + TaskLists._ID + ";" - + " END;"; - - /** - * SQL command to drop the clean up trigger. - */ - private final static String SQL_DROP_TASKS_CLEANUP_TRIGGER = - "DROP TRIGGER task_cleanup_trigger;"; - - /** - * SQL command that counts and sets the alarm on deletion - */ - private final static String SQL_COUNT_ALARMS_ON_DELETE = - " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS - + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES - + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = OLD." + Properties.TASK_ID - + ") WHERE " + Tasks._ID + " = OLD." + Properties.TASK_ID - + "; END;"; - - /** - * SQL command that counts and sets the alarm on insert and update - */ - private final static String SQL_COUNT_ALARMS = - " BEGIN UPDATE " + Tables.TASKS + " SET " + Tasks.HAS_ALARMS - + " = (SELECT COUNT (*) FROM " + Tables.PROPERTIES - + " WHERE " + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "' AND " + Alarm.ALARM_TYPE + " <> " + Alarm.ALARM_TYPE_NOTHING + " AND " + Properties.TASK_ID + " = NEW." + Properties.TASK_ID - + ") WHERE " + Tasks._ID + " = NEW." + Properties.TASK_ID - + "; END;"; - - /** - * SQL command to create a trigger that counts the alarms for a task on create - */ - private final static String SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER = - "CREATE TRIGGER alarm_count_create_trigger AFTER INSERT ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" - + SQL_COUNT_ALARMS; - - /** - * SQL command to create a trigger that counts the alarms for a task on update - */ - private final static String SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER = - "CREATE TRIGGER alarm_count_update_trigger AFTER UPDATE ON " + Tables.PROPERTIES + " WHEN NEW." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" - + SQL_COUNT_ALARMS; - - /** - * SQL command to create a trigger that counts the alarms for a task on delete - */ - private final static String SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER = - "CREATE TRIGGER alarm_count_delete_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" - + SQL_COUNT_ALARMS_ON_DELETE; - - /** - * SQL command to create a trigger to clean up data of removed property. - */ - private final static String SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER = - "CREATE TRIGGER alarm_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Alarm.CONTENT_ITEM_TYPE + "'" - + " BEGIN " - + " DELETE FROM " + Tables.ALARMS + " WHERE " + TaskContract.Alarms.ALARM_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" - + " END;"; - - /** - * SQL command to create a trigger to clean up data of removed property. - */ - private final static String SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER = - "CREATE TRIGGER category_property_cleanup_trigger AFTER DELETE ON " + Tables.PROPERTIES + " WHEN OLD." + Properties.MIMETYPE + " = '" + Category.CONTENT_ITEM_TYPE + "'" - + " BEGIN " - + " DELETE FROM " + Tables.CATEGORIES_MAPPING + " WHERE " + CategoriesMapping.PROPERTY_ID + "= OLD." + TaskContract.Properties.PROPERTY_ID + ";" - + " END;"; - - /** - * SQL command to create a trigger to clean up property data of removed task. - */ - private final static String SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER = - "CREATE TRIGGER task_property_cleanup_trigger AFTER DELETE ON " + Tables.TASKS + " BEGIN " - + " DELETE FROM " + Tables.PROPERTIES + " WHERE " + Properties.TASK_ID + "= OLD." + Tasks._ID + ";" - + " END;"; - - /** - * SQL command to create a trigger to increment task version number on every update. - */ - private final static String SQL_CREATE_TASK_VERSION_TRIGGER = - "CREATE TRIGGER task_version_trigger BEFORE UPDATE ON " + Tables.TASKS + " BEGIN " - + " UPDATE " + Tables.TASKS + " SET " + Tasks.VERSION + " = OLD." + Tasks.VERSION + " + 1 where " + Tasks._ID + " = NEW." + Tasks._ID + ";" - + " END;"; - - /** - * SQL command to create the task list table. - */ - private final static String SQL_CREATE_LISTS_TABLE = - "CREATE TABLE " + Tables.LISTS + " ( " - + TaskContract.TaskLists._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," - + TaskContract.TaskLists.ACCOUNT_NAME + " TEXT," - + TaskContract.TaskLists.ACCOUNT_TYPE + " TEXT," - + TaskContract.TaskLists.LIST_NAME + " TEXT," - + TaskContract.TaskLists.LIST_COLOR + " INTEGER," - + TaskContract.TaskLists.ACCESS_LEVEL + " INTEGER," - + TaskContract.TaskLists.VISIBLE + " INTEGER," - + TaskContract.TaskLists.SYNC_ENABLED + " INTEGER," - + TaskContract.TaskLists.OWNER + " TEXT," - + TaskContract.TaskLists._DIRTY + " INTEGER DEFAULT 0," - + TaskContract.TaskLists._SYNC_ID + " TEXT," - + TaskContract.TaskLists.SYNC_VERSION + " TEXT," - + TaskContract.TaskLists.SYNC1 + " TEXT," - + TaskContract.TaskLists.SYNC2 + " TEXT," - + TaskContract.TaskLists.SYNC3 + " TEXT," - + TaskContract.TaskLists.SYNC4 + " TEXT," - + TaskContract.TaskLists.SYNC5 + " TEXT," - + TaskContract.TaskLists.SYNC6 + " TEXT," - + TaskContract.TaskLists.SYNC7 + " TEXT," - + TaskContract.TaskLists.SYNC8 + " TEXT);"; - - /** - * SQL command to create the task table. - */ - private final static String SQL_CREATE_TASKS_TABLE = - "CREATE TABLE " + Tables.TASKS + " ( " - + TaskContract.Tasks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," - + TaskContract.Tasks.VERSION + " INTEGER DEFAULT 0," - + TaskContract.Tasks.LIST_ID + " INTEGER NOT NULL, " - + TaskContract.Tasks.TITLE + " TEXT," - + TaskContract.Tasks.LOCATION + " TEXT," - + TaskContract.Tasks.GEO + " TEXT," - + TaskContract.Tasks.DESCRIPTION + " TEXT," - + TaskContract.Tasks.URL + " TEXT," - + TaskContract.Tasks.ORGANIZER + " TEXT," - + TaskContract.Tasks.PRIORITY + " INTEGER, " - + TaskContract.Tasks.TASK_COLOR + " INTEGER," - + TaskContract.Tasks.CLASSIFICATION + " INTEGER," - + TaskContract.Tasks.COMPLETED + " INTEGER," - + TaskContract.Tasks.COMPLETED_IS_ALLDAY + " INTEGER," - + TaskContract.Tasks.PERCENT_COMPLETE + " INTEGER," - + TaskContract.Tasks.STATUS + " INTEGER DEFAULT " + TaskContract.Tasks.STATUS_DEFAULT + "," - + TaskContract.Tasks.IS_NEW + " INTEGER," - + TaskContract.Tasks.IS_CLOSED + " INTEGER," - + TaskContract.Tasks.DTSTART + " INTEGER," - + TaskContract.Tasks.CREATED + " INTEGER," - + TaskContract.Tasks.LAST_MODIFIED + " INTEGER," - + TaskContract.Tasks.IS_ALLDAY + " INTEGER," - + TaskContract.Tasks.TZ + " TEXT," - + TaskContract.Tasks.DUE + " INTEGER," - + TaskContract.Tasks.DURATION + " TEXT," - + TaskContract.Tasks.RDATE + " TEXT," - + TaskContract.Tasks.EXDATE + " TEXT," - + TaskContract.Tasks.RRULE + " TEXT," - + TaskContract.Tasks.PARENT_ID + " INTEGER," - + TaskContract.Tasks.SORTING + " TEXT," - + TaskContract.Tasks.HAS_ALARMS + " INTEGER," - + TaskContract.Tasks.HAS_PROPERTIES + " INTEGER," - + TaskContract.Tasks.PINNED + " INTEGER," - + TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + " TEXT," - + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " INTEGER," - + TaskContract.Tasks.ORIGINAL_INSTANCE_TIME + " INTEGER," - + TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY + " INTEGER," - + TaskContract.Tasks._DIRTY + " INTEGER DEFAULT 1," // a new task is always dirty - + TaskContract.Tasks._DELETED + " INTEGER DEFAULT 0," // new tasks are not deleted by default - + TaskContract.Tasks._SYNC_ID + " TEXT," - + TaskContract.Tasks._UID + " TEXT," - + TaskContract.Tasks.SYNC_VERSION + " TEXT," - + TaskContract.Tasks.SYNC1 + " TEXT," - + TaskContract.Tasks.SYNC2 + " TEXT," - + TaskContract.Tasks.SYNC3 + " TEXT," - + TaskContract.Tasks.SYNC4 + " TEXT," - + TaskContract.Tasks.SYNC5 + " TEXT," - + TaskContract.Tasks.SYNC6 + " TEXT," - + TaskContract.Tasks.SYNC7 + " TEXT," - + TaskContract.Tasks.SYNC8 + " TEXT);"; - - /** - * SQL command to create the categories table. - */ - private final static String SQL_CREATE_CATEGORIES_TABLE = - "CREATE TABLE " + Tables.CATEGORIES - + " ( " + TaskContract.Categories._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," - + TaskContract.Categories.ACCOUNT_NAME + " TEXT," - + TaskContract.Categories.ACCOUNT_TYPE + " TEXT," - + TaskContract.Categories.NAME + " TEXT," - + TaskContract.Categories.COLOR + " INTEGER);"; - - /** - * SQL command to create the categories table. - */ - private final static String SQL_CREATE_CATEGORIES_MAPPING_TABLE = - "CREATE TABLE " + Tables.CATEGORIES_MAPPING - + " ( " + CategoriesMapping.TASK_ID + " INTEGER," - + CategoriesMapping.CATEGORY_ID + " INTEGER," - + CategoriesMapping.PROPERTY_ID + " INTEGER," - + "FOREIGN KEY (" + CategoriesMapping.TASK_ID + ") REFERENCES " + Tables.TASKS + "(" + TaskContract.Tasks._ID + ")," - + "FOREIGN KEY (" + CategoriesMapping.PROPERTY_ID + ") REFERENCES " + Tables.PROPERTIES + "(" + TaskContract.Properties.PROPERTY_ID + ")," - + "FOREIGN KEY (" + CategoriesMapping.CATEGORY_ID + ") REFERENCES " + Tables.CATEGORIES + "(" + TaskContract.Categories._ID + "));"; - - /** - * SQL command to create the alarms table the stores the already triggered alarms. - */ - private final static String SQL_CREATE_ALARMS_TABLE = - "CREATE TABLE " + Tables.ALARMS - + " ( " + TaskContract.Alarms.ALARM_ID + " INTEGER," - + TaskContract.Alarms.LAST_TRIGGER + " TEXT," - + TaskContract.Alarms.NEXT_TRIGGER + " TEXT);"; - - /** - * SQL command to create the table for extended properties. - */ - private final static String SQL_CREATE_PROPERTIES_TABLE = - "CREATE TABLE " + Tables.PROPERTIES + " ( " - + TaskContract.Properties.PROPERTY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," - + TaskContract.Properties.TASK_ID + " INTEGER," - + TaskContract.Properties.MIMETYPE + " INTEGER," - + TaskContract.Properties.VERSION + " INTEGER," - + TaskContract.Properties.DATA0 + " TEXT," - + TaskContract.Properties.DATA1 + " TEXT," - + TaskContract.Properties.DATA2 + " TEXT," - + TaskContract.Properties.DATA3 + " TEXT," - + TaskContract.Properties.DATA4 + " TEXT," - + TaskContract.Properties.DATA5 + " TEXT," - + TaskContract.Properties.DATA6 + " TEXT," - + TaskContract.Properties.DATA7 + " TEXT," - + TaskContract.Properties.DATA8 + " TEXT," - + TaskContract.Properties.DATA9 + " TEXT," - + TaskContract.Properties.DATA10 + " TEXT," - + TaskContract.Properties.DATA11 + " TEXT," - + TaskContract.Properties.DATA12 + " TEXT," - + TaskContract.Properties.DATA13 + " TEXT," - + TaskContract.Properties.DATA14 + " TEXT," - + TaskContract.Properties.DATA15 + " TEXT," - + TaskContract.Properties.SYNC1 + " TEXT," - + TaskContract.Properties.SYNC2 + " TEXT," - + TaskContract.Properties.SYNC3 + " TEXT," - + TaskContract.Properties.SYNC4 + " TEXT," - + TaskContract.Properties.SYNC5 + " TEXT," - + TaskContract.Properties.SYNC6 + " TEXT," - + TaskContract.Properties.SYNC7 + " TEXT," - + TaskContract.Properties.SYNC8 + " TEXT);"; - - /** - * SQL command to drop the task view. - */ - private final static String SQL_DROP_PROPERTIES_TABLE = "DROP TABLE " + Tables.PROPERTIES + ";"; - - - /** - * Builds a string that creates an index on the given table for the given columns. - * - * @param table - * The table to create the index on. - * @param fields - * The fields to index. - * - * @return An SQL command string. - */ - public static String createIndexString(String table, boolean unique, String... fields) - { - if (fields == null || fields.length < 1) - { - throw new IllegalArgumentException("need at least one field to build an index!"); - } - - StringBuffer buffer = new StringBuffer(); - - // Index name is constructed like this: tablename_fields[0]_idx - buffer.append("CREATE "); - if (unique) - { - buffer.append(" UNIQUE "); - } - buffer.append("INDEX IF NOT EXISTS "); - buffer.append(table).append("_").append(fields[0]).append("_idx ON "); - buffer.append(table).append(" ("); - buffer.append(fields[0]); - for (int i = 1; i < fields.length; i++) - { - buffer.append(", ").append(fields[i]); - } - buffer.append(");"); - - return buffer.toString(); - - } - - - private final OnDatabaseOperationListener mListener; - - - TaskDatabaseHelper(Context context, OnDatabaseOperationListener listener) - { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - mListener = listener; - } - - - /** - * Creates the tables, views, triggers and indices. - *

- * TODO: move all strings to separate final static variables. - */ - @Override - public void onCreate(SQLiteDatabase db) - { - - // create task list table - db.execSQL(SQL_CREATE_LISTS_TABLE); - - // trigger that removes tasks of a list that has been removed - db.execSQL("CREATE TRIGGER task_list_cleanup_trigger AFTER DELETE ON " + Tables.LISTS + " BEGIN DELETE FROM " + Tables.TASKS + " WHERE " - + TaskContract.Tasks.LIST_ID + "= old." + TaskContract.TaskLists._ID + "; END"); - - // create task table - db.execSQL(SQL_CREATE_TASKS_TABLE); - - // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted - db.execSQL("CREATE TRIGGER task_list_make_dirty_on_update AFTER UPDATE ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " - + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." - + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); - - // trigger that marks a list as dirty if a task in that list gets marked as dirty or deleted - db.execSQL("CREATE TRIGGER task_list_make_dirty_on_insert AFTER INSERT ON " + Tables.TASKS + " BEGIN UPDATE " + Tables.LISTS + " SET " - + TaskContract.TaskLists._DIRTY + "=" + TaskContract.TaskLists._DIRTY + " + " + "new." + TaskContract.Tasks._DIRTY + " + " + "new." - + TaskContract.Tasks._DELETED + " WHERE " + TaskContract.TaskLists._ID + "= new." + TaskContract.Tasks.LIST_ID + "; END"); - - // create task version update trigger - db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); - - // create instances table and view - db.execSQL(SQL_CREATE_INSTANCES_TABLE); - - // create categories table - db.execSQL(SQL_CREATE_CATEGORIES_TABLE); - - // create categories mapping table - db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); - - // create alarms table - db.execSQL(SQL_CREATE_ALARMS_TABLE); - - // create properties table - db.execSQL(SQL_CREATE_PROPERTIES_TABLE); - - // create syncstate table - db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); - - // create views - db.execSQL(SQL_CREATE_TASK_VIEW); - db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); - - // create indices - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.TASK_ID, TaskContract.Instances.INSTANCE_START, - TaskContract.Instances.INSTANCE_DUE)); - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); - db.execSQL(createIndexString(Tables.LISTS, false, TaskContract.TaskLists.ACCOUNT_NAME, // not sure if necessary - TaskContract.TaskLists.ACCOUNT_TYPE)); - db.execSQL(createIndexString(Tables.TASKS, false, TaskContract.Tasks.STATUS, TaskContract.Tasks.LIST_ID, TaskContract.Tasks._SYNC_ID)); - db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); - db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); - db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, - TaskContract.Categories.NAME)); - db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); - db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); - - // trigger that removes properties of a task that has been removed - db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); - - // trigger that removes alarms when an alarm property was deleted - db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); - - // trigger that removes tasks when a list was removed - db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); - - // trigger that counts the alarms for tasks - db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); - db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); - db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); - - // add cleanup trigger for orphaned properties - db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); - - // initialize FTS - FTSDatabaseHelper.onCreate(db); - - if (mListener != null) - { - mListener.onDatabaseCreated(db); - } - } - - - /** - * Manages the database schema migration. - */ - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) - { - Log.i(TAG, "updgrading db from " + oldVersion + " to " + newVersion); - if (oldVersion < 2) - { - // add IS_NEW and IS_CLOSED columns and update their values - db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_NEW + " INTEGER"); - db.execSQL("ALTER TABLE " + Tables.TASKS + " ADD COLUMN " + TaskContract.Tasks.IS_CLOSED + " INTEGER"); - db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 1 WHERE " + TaskContract.Tasks.STATUS + " = " - + TaskContract.Tasks.STATUS_NEEDS_ACTION); - db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_NEW + " = 0 WHERE " + TaskContract.Tasks.STATUS + " != " - + TaskContract.Tasks.STATUS_NEEDS_ACTION); - db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 1 WHERE " + TaskContract.Tasks.STATUS + " > " - + TaskContract.Tasks.STATUS_IN_PROCESS); - db.execSQL("UPDATE " + Tables.TASKS + " SET " + TaskContract.Tasks.IS_CLOSED + " = 0 WHERE " + TaskContract.Tasks.STATUS + " <= " - + TaskContract.Tasks.STATUS_IN_PROCESS); - } - - if (oldVersion < 3) - { - // add instance sortings - db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_START_SORTING + " INTEGER"); - db.execSQL("ALTER TABLE " + Tables.INSTANCES + " ADD COLUMN " + TaskContract.Instances.INSTANCE_DUE_SORTING + " INTEGER"); - db.execSQL("UPDATE " + Tables.INSTANCES + " SET " + TaskContract.Instances.INSTANCE_START_SORTING + " = " + TaskContract.Instances.INSTANCE_START - + ", " + TaskContract.Instances.INSTANCE_DUE_SORTING + " = " + TaskContract.Instances.INSTANCE_DUE); - } - if (oldVersion < 4) - { - // drop old view before altering the schema - db.execSQL(SQL_DROP_TASK_VIEW); - db.execSQL(SQL_DROP_INSTANCE_VIEW); - - // change property id column name to work with the left join in task view - db.execSQL(SQL_DROP_TASKS_CLEANUP_TRIGGER); - db.execSQL(SQL_DROP_PROPERTIES_TABLE); - db.execSQL(SQL_CREATE_PROPERTIES_TABLE); - db.execSQL(SQL_CREATE_TASKS_CLEANUP_TRIGGER); - - // create categories mapping table - db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); - - // create alarms table - db.execSQL(SQL_CREATE_ALARMS_TABLE); - - // update views - db.execSQL(SQL_CREATE_TASK_VIEW); - db.execSQL(SQL_CREATE_TASK_PROPERTY_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_PROPERTY_VIEW); - db.execSQL(SQL_CREATE_INSTANCE_CATEGORY_VIEW); - - // create Indices - db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.MIMETYPE, TaskContract.Properties.TASK_ID)); - db.execSQL(createIndexString(Tables.PROPERTIES, false, TaskContract.Properties.TASK_ID)); - db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.ACCOUNT_NAME, TaskContract.Categories.ACCOUNT_TYPE, - TaskContract.Categories.NAME)); - db.execSQL(createIndexString(Tables.CATEGORIES, false, TaskContract.Categories.NAME)); - - // add new triggers - db.execSQL(SQL_CREATE_ALARM_PROPERTY_CLEANUP_TRIGGER); - db.execSQL(SQL_CREATE_ALARM_COUNT_CREATE_TRIGGER); - db.execSQL(SQL_CREATE_ALARM_COUNT_UPDATE_TRIGGER); - db.execSQL(SQL_CREATE_ALARM_COUNT_DELETE_TRIGGER); - - } - if (oldVersion < 6) - { - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PARENT_ID + " integer;"); - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_ALARMS + " integer;"); - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.SORTING + " text;"); - } - if (oldVersion < 7) - { - db.execSQL(SQL_CREATE_LISTS_CLEANUP_TRIGGER); - } - if (oldVersion < 8) - { - // replace priority 0 by null. We need this to sort the widget properly. Since 0 is the default this is no problem when syncing. - db.execSQL("update " + Tables.TASKS + " set " + Tasks.PRIORITY + "=null where " + Tasks.PRIORITY + "=0;"); - } - if (oldVersion < 9) - { - // add missing column _UID - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks._UID + " integer;"); - // add cleanup trigger for orphaned properties - db.execSQL(SQL_CREATE_TASK_PROPERTY_CLEANUP_TRIGGER); - } - if (oldVersion < 10) - { - // add property column to categories_mapping table. Since adding a constraint is not supported by SQLite we have to remove and recreate the entire - // table - db.execSQL("drop table " + Tables.CATEGORIES_MAPPING); - db.execSQL(SQL_CREATE_CATEGORIES_MAPPING_TABLE); - db.execSQL(SQL_CREATE_CATEGORY_PROPERTY_CLEANUP_TRIGGER); - } - if (oldVersion < 11) - { - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.PINNED + " integer;"); - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.HAS_PROPERTIES + " integer;"); - } - - if (oldVersion < 12) - { - // rename the local account type - ContentValues values = new ContentValues(1); - values.put(TaskLists.ACCOUNT_TYPE, TaskContract.LOCAL_ACCOUNT_TYPE); - db.update(Tables.LISTS, values, TaskLists.ACCOUNT_TYPE + "=?", new String[] { "LOCAL" }); - } - - if (oldVersion < 13) - { - db.execSQL(SQL_CREATE_SYNCSTATE_TABLE); - } - - if (oldVersion < 14) - { - // create a unique index for account name and account type on the sync state table - db.execSQL(createIndexString(Tables.SYNCSTATE, true, TaskContract.SyncState.ACCOUNT_NAME, TaskContract.SyncState.ACCOUNT_TYPE)); - } - - if (oldVersion < 16) - { - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_START_SORTING)); - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_DUE_SORTING)); - } - - if (oldVersion < 17) - { - db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.INSTANCE_ORIGINAL_TIME + " integer default 0;"); - db.execSQL(createIndexString(Tables.INSTANCES, false, TaskContract.Instances.INSTANCE_ORIGINAL_TIME)); - } - - if (oldVersion < 18) - { - db.execSQL("alter table " + Tables.INSTANCES + " add column " + TaskContract.Instances.DISTANCE_FROM_CURRENT + " integer default 0;"); - } - - if (oldVersion < 19) - { - db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); - } - - if (oldVersion < 22) - { - // create version column, unless it already exists - if (!new First<>(new TableColumns(Tables.TASKS).value(db), new Equals<>(Tasks.VERSION)).isPresent()) - { - // create task version column and update trigger - db.execSQL("alter table " + Tables.TASKS + " add column " + Tasks.VERSION + " Integer default 0;"); - db.execSQL(SQL_CREATE_TASK_VERSION_TRIGGER); - } - } - - if (oldVersion < 22) - { - db.beginTransaction(); - try - { - // make sure we upgrade the instances of every recurring task - EntityProcessor processor = new Instantiating(new NoOpProcessor<>()); - try (Cursor c = db.query(Tables.TASKS, - new String[] { - TaskContract.Tasks._ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_CLOSED, Tasks.TZ, - Tasks.IS_ALLDAY, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.ORIGINAL_INSTANCE_TIME, Tasks.ORIGINAL_INSTANCE_ALLDAY }, - String.format(Locale.ENGLISH, "%s is null", TaskContract.Tasks.ORIGINAL_INSTANCE_ID), - null, null, null, null)) - { - while (c.moveToNext()) - { - ContentValues values = new ContentValues(); - Instantiating.addUpdateRequest(values); - TaskAdapter adapter = new CursorContentValuesTaskAdapter(c, values); - processor.update(db, adapter, false); - } - } - db.setTransactionSuccessful(); - } - finally - { - db.endTransaction(); - } - } - - if (oldVersion < 23) - { - db.execSQL("drop view " + Tables.INSTANCE_CLIENT_VIEW + ";"); - db.execSQL(SQL_CREATE_INSTANCE_CLIENT_VIEW); - } - - // upgrade FTS - FTSDatabaseHelper.onUpgrade(db, oldVersion, newVersion); - - if (mListener != null) - { - mListener.onDatabaseUpdate(db, oldVersion, newVersion); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java deleted file mode 100644 index 6bddccd..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java +++ /dev/null @@ -1,1408 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.accounts.OnAccountsUpdateListener; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.SQLException; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.database.sqlite.SQLiteQueryBuilder; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.HandlerThread; -import android.text.TextUtils; -import android.util.Log; - -import org.dmfs.iterables.EmptyIterable; -import org.dmfs.provider.tasks.TaskDatabaseHelper.OnDatabaseOperationListener; -import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; -import org.dmfs.provider.tasks.handler.PropertyHandler; -import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; -import org.dmfs.provider.tasks.model.ContentValuesListAdapter; -import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesListAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.InstanceAdapter; -import org.dmfs.provider.tasks.model.ListAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.provider.tasks.processors.instances.Detaching; -import org.dmfs.provider.tasks.processors.instances.TaskValueDelegate; -import org.dmfs.provider.tasks.processors.lists.ListCommitProcessor; -import org.dmfs.provider.tasks.processors.tasks.AutoCompleting; -import org.dmfs.provider.tasks.processors.tasks.Instantiating; -import org.dmfs.provider.tasks.processors.tasks.Moving; -import org.dmfs.provider.tasks.processors.tasks.Originating; -import org.dmfs.provider.tasks.processors.tasks.Relating; -import org.dmfs.provider.tasks.processors.tasks.Reparenting; -import org.dmfs.provider.tasks.processors.tasks.Searchable; -import org.dmfs.provider.tasks.processors.tasks.TaskCommitProcessor; -import org.dmfs.provider.tasks.processors.tasks.Validating; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Alarms; -import org.dmfs.tasks.contract.TaskContract.Categories; -import org.dmfs.tasks.contract.TaskContract.CategoriesColumns; -import org.dmfs.tasks.contract.TaskContract.Instances; -import org.dmfs.tasks.contract.TaskContract.Properties; -import org.dmfs.tasks.contract.TaskContract.PropertyColumns; -import org.dmfs.tasks.contract.TaskContract.SyncState; -import org.dmfs.tasks.contract.TaskContract.TaskColumns; -import org.dmfs.tasks.contract.TaskContract.TaskListColumns; -import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; -import org.dmfs.tasks.contract.TaskContract.TaskLists; -import org.dmfs.tasks.contract.TaskContract.Tasks; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - - -/** - * The provider for tasks. - *

- * TODO: add support for recurring tasks - *

- * TODO: add support for reminders - *

- * TODO: add support for attendees - *

- * TODO: refactor the selection stuff - * - * @author Marten Gajda - * @author Tobias Reinsch - */ -public final class TaskProvider extends SQLiteContentProvider implements OnAccountsUpdateListener, OnDatabaseOperationListener -{ - - private static final int LISTS = 1; - private static final int LIST_ID = 2; - private static final int TASKS = 101; - private static final int TASK_ID = 102; - private static final int INSTANCES = 103; - private static final int INSTANCE_ID = 104; - private static final int CATEGORIES = 1001; - private static final int CATEGORY_ID = 1002; - private static final int PROPERTIES = 1003; - private static final int PROPERTY_ID = 1004; - private static final int ALARMS = 1005; - private static final int ALARM_ID = 1006; - private static final int SEARCH = 1007; - private static final int SYNCSTATE = 1008; - private static final int SYNCSTATE_ID = 1009; - - private static final int OPERATIONS = 100000; - - private final static Set TASK_LIST_SYNC_COLUMNS = new HashSet(Arrays.asList(TaskLists.SYNC_ADAPTER_COLUMNS)); - private static final String TAG = "TaskProvider"; - - /** - * A list of {@link EntityProcessor}s to execute when doing operations on the instances table. - */ - private EntityProcessor mInstanceProcessorChain; - - /** - * A list of {@link EntityProcessor}s to execute when doing operations on the tasks table. - */ - private EntityProcessor mTaskProcessorChain; - - /** - * A list of {@link EntityProcessor}s to execute when doing operations on the task lists table. - */ - private EntityProcessor mListProcessorChain; - - /** - * Our authority. - */ - String mAuthority; - - /** - * The {@link UriMatcher} we use. - */ - private UriMatcher mUriMatcher; - - /** - * A handler to execute asynchronous jobs. - */ - Handler mAsyncHandler; - - /** - * Boolean to track if there are changes within a transaction. - *

- * This can be shared by multiple threads, hence the {@link AtomicBoolean}. - */ - private AtomicBoolean mChanged = new AtomicBoolean(false); - - /** - * This is a per transaction/thread flag which indicates whether new lists with an unknown account have been added. - * If this holds true at the end of a transaction a window should be shown to ask the user for access to that account. - */ - private ThreadLocal mStaleListCreated = new ThreadLocal<>(); - - /** - * The currently known accounts. This may be accessed from various threads, hence the AtomicReference. - * By statring with an empty set, we can always guarantee a non-null reference. - */ - private AtomicReference> mAccountCache = new AtomicReference<>(Collections.emptySet()); - - - public TaskProvider() - { - // for now we don't have anything specific to execute before the transaction ends. - super(EmptyIterable.instance()); - } - - - @Override - public boolean onCreate() - { - mAuthority = AuthorityUtil.taskAuthority(getContext()); - - mTaskProcessorChain = new Validating( - new AutoCompleting(new Relating(new Reparenting(new Instantiating(new Searchable(new Moving(new Originating(new TaskCommitProcessor())))))))); - - mListProcessorChain = new org.dmfs.provider.tasks.processors.lists.Validating(new ListCommitProcessor()); - - mInstanceProcessorChain = new org.dmfs.provider.tasks.processors.instances.Validating( - new Detaching(new TaskValueDelegate(mTaskProcessorChain), mTaskProcessorChain)); - - mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH, LISTS); - - mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH + "/#", LIST_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH, TASKS); - mUriMatcher.addURI(mAuthority, TaskContract.Tasks.CONTENT_URI_PATH + "/#", TASK_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH, INSTANCES); - mUriMatcher.addURI(mAuthority, TaskContract.Instances.CONTENT_URI_PATH + "/#", INSTANCE_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH, PROPERTIES); - mUriMatcher.addURI(mAuthority, TaskContract.Properties.CONTENT_URI_PATH + "/#", PROPERTY_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH, CATEGORIES); - mUriMatcher.addURI(mAuthority, TaskContract.Categories.CONTENT_URI_PATH + "/#", CATEGORY_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH, ALARMS); - mUriMatcher.addURI(mAuthority, TaskContract.Alarms.CONTENT_URI_PATH + "/#", ALARM_ID); - - mUriMatcher.addURI(mAuthority, TaskContract.Tasks.SEARCH_URI_PATH, SEARCH); - - mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH, SYNCSTATE); - mUriMatcher.addURI(mAuthority, TaskContract.SyncState.CONTENT_URI_PATH + "/#", SYNCSTATE_ID); - - ContentOperation.register(mUriMatcher, mAuthority, OPERATIONS); - - boolean result = super.onCreate(); - - // create a HandlerThread to perform async operations - HandlerThread thread = new HandlerThread("backgroundHandler"); - thread.start(); - mAsyncHandler = new Handler(thread.getLooper()); - - AccountManager accountManager = AccountManager.get(getContext()); - accountManager.addOnAccountsUpdatedListener(this, mAsyncHandler, true); - - updateNotifications(); - - return result; - } - - - /** - * Return true if the caller is a sync adapter (i.e. if the Uri contains the query parameter {@link TaskContract#CALLER_IS_SYNCADAPTER} and its value is - * true). - * - * @param uri - * The {@link Uri} to check. - * - * @return true if the caller pretends to be a sync adapter, false otherwise. - */ - @Override - public boolean isCallerSyncAdapter(Uri uri) - { - String param = uri.getQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER); - return param != null && !"false".equals(param); - } - - - /** - * Return true if the URI indicates to a load extended properties with {@link TaskContract#LOAD_PROPERTIES}. - * - * @param uri - * The {@link Uri} to check. - * - * @return true if the URI requests to load extended properties, false otherwise. - */ - public boolean shouldLoadProperties(Uri uri) - { - String param = uri.getQueryParameter(TaskContract.LOAD_PROPERTIES); - return param != null && !"false".equals(param); - } - - - /** - * Get the account name from the given {@link Uri}. - * - * @param uri - * The Uri to check. - * - * @return The account name or null if no account name has been specified. - */ - protected String getAccountName(Uri uri) - { - return uri.getQueryParameter(TaskContract.ACCOUNT_NAME); - } - - - /** - * Get the account type from the given {@link Uri}. - * - * @param uri - * The Uri to check. - * - * @return The account type or null if no account type has been specified. - */ - protected String getAccountType(Uri uri) - { - return uri.getQueryParameter(TaskContract.ACCOUNT_TYPE); - } - - - /** - * Get any id from the given {@link Uri}. - * - * @param uri - * The Uri. - * - * @return The last path segment (which should contain the id). - */ - private long getId(Uri uri) - { - return Long.parseLong(uri.getPathSegments().get(1)); - } - - - /** - * Build a selection string that selects the account specified in uri. - * - * @param uri - * A {@link Uri} that specifies an account. - * - * @return A {@link StringBuilder} with a selection string for the account. - */ - protected StringBuilder selectAccount(Uri uri) - { - StringBuilder sb = new StringBuilder(256); - return selectAccount(sb, uri); - } - - - /** - * Append the selection of the account specified in uri to the {@link StringBuilder} sb. - * - * @param sb - * A {@link StringBuilder} that the selection is appended to. - * @param uri - * A {@link Uri} that specifies an account. - * - * @return sb. - */ - protected StringBuilder selectAccount(StringBuilder sb, Uri uri) - { - String accountName = getAccountName(uri); - String accountType = getAccountType(uri); - - if (accountName != null || accountType != null) - { - - if (accountName != null) - { - if (sb.length() > 0) - { - sb.append(" AND "); - } - - sb.append(TaskListSyncColumns.ACCOUNT_NAME); - sb.append("="); - DatabaseUtils.appendEscapedSQLString(sb, accountName); - } - if (accountType != null) - { - - if (sb.length() > 0) - { - sb.append(" AND "); - } - - sb.append(TaskListSyncColumns.ACCOUNT_TYPE); - sb.append("="); - DatabaseUtils.appendEscapedSQLString(sb, accountType); - } - } - return sb; - } - - - /** - * Append the selection of the account specified in uri to the an {@link SQLiteQueryBuilder}. - * - * @param sqlBuilder - * A {@link SQLiteQueryBuilder} that the selection is appended to. - * @param uri - * A {@link Uri} that specifies an account. - */ - protected void selectAccount(SQLiteQueryBuilder sqlBuilder, Uri uri) - { - String accountName = getAccountName(uri); - String accountType = getAccountType(uri); - - if (accountName != null) - { - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_NAME); - sqlBuilder.appendWhere("="); - sqlBuilder.appendWhereEscapeString(accountName); - } - if (accountType != null) - { - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(TaskListSyncColumns.ACCOUNT_TYPE); - sqlBuilder.appendWhere("="); - sqlBuilder.appendWhereEscapeString(accountType); - } - } - - - private StringBuilder _selectId(StringBuilder sb, long id, String key) - { - if (sb.length() > 0) - { - sb.append(" AND "); - } - sb.append(key); - sb.append("="); - sb.append(id); - return sb; - } - - - protected StringBuilder selectId(Uri uri) - { - StringBuilder sb = new StringBuilder(128); - return selectId(sb, uri); - } - - - protected StringBuilder selectId(StringBuilder sb, Uri uri) - { - return _selectId(sb, getId(uri), TaskListColumns._ID); - } - - - protected StringBuilder selectTaskId(Uri uri) - { - StringBuilder sb = new StringBuilder(128); - return selectTaskId(sb, uri); - } - - - protected StringBuilder selectTaskId(long id) - { - StringBuilder sb = new StringBuilder(128); - return selectTaskId(sb, id); - } - - - protected StringBuilder selectTaskId(StringBuilder sb, Uri uri) - { - return selectTaskId(sb, getId(uri)); - } - - - protected StringBuilder selectTaskId(StringBuilder sb, long id) - { - return _selectId(sb, id, Instances.TASK_ID); - - } - - - protected StringBuilder selectPropertyId(Uri uri) - { - StringBuilder sb = new StringBuilder(128); - return selectPropertyId(sb, uri); - } - - - protected StringBuilder selectPropertyId(StringBuilder sb, Uri uri) - { - return selectPropertyId(sb, getId(uri)); - } - - - protected StringBuilder selectPropertyId(long id) - { - StringBuilder sb = new StringBuilder(128); - return selectPropertyId(sb, id); - } - - - protected StringBuilder selectPropertyId(StringBuilder sb, long id) - { - return _selectId(sb, id, PropertyColumns.PROPERTY_ID); - } - - - /** - * Add a selection by ID to the given {@link SQLiteQueryBuilder}. The id is taken from the given Uri. - * - * @param sqlBuilder - * The {@link SQLiteQueryBuilder} to append the selection to. - * @param idColumn - * The column that must match the id. - * @param uri - * An {@link Uri} that contains the id. - */ - protected void selectId(SQLiteQueryBuilder sqlBuilder, String idColumn, Uri uri) - { - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(idColumn); - sqlBuilder.appendWhere("="); - sqlBuilder.appendWhere(String.valueOf(getId(uri))); - } - - - /** - * Append any arbitrary selection string to the selection in sb - * - * @param sb - * A {@link StringBuilder} that already contains a selection string. - * @param selection - * A valid SQL selection string. - * - * @return A string with the final selection. - */ - protected String updateSelection(StringBuilder sb, String selection) - { - if (selection != null) - { - if (sb.length() > 0) - { - sb.append(" AND ( ").append(selection).append(" ) "); - } - else - { - sb.append(" ( ").append(selection).append(" ) "); - } - } - return sb.toString(); - } - - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - { - final SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); - SQLiteQueryBuilder sqlBuilder = new SQLiteQueryBuilder(); - // initialize appendWhere, this allows us to append all other selections with a preceding "AND" - sqlBuilder.appendWhere(" 1=1 "); - boolean isSyncAdapter = isCallerSyncAdapter(uri); - - switch (mUriMatcher.match(uri)) - { - case SYNCSTATE_ID: - // the id is ignored, we only match by account type and name given in the Uri - case SYNCSTATE: - { - if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) - { - throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); - } - selectAccount(sqlBuilder, uri); - sqlBuilder.setTables(Tables.SYNCSTATE); - break; - } - case LISTS: - // add account to selection if any - selectAccount(sqlBuilder, uri); - sqlBuilder.setTables(Tables.LISTS); - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; - } - break; - - case LIST_ID: - // add account to selection if any - selectAccount(sqlBuilder, uri); - sqlBuilder.setTables(Tables.LISTS); - selectId(sqlBuilder, TaskListColumns._ID, uri); - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.TaskLists.DEFAULT_SORT_ORDER; - } - break; - - case TASKS: - if (shouldLoadProperties(uri)) - { - // extended properties were requested, therefore change to task view that includes these properties - sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); - } - else - { - sqlBuilder.setTables(Tables.TASKS_VIEW); - } - if (!isSyncAdapter) - { - // do not return deleted rows if caller is not a sync adapter - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(Tasks._DELETED); - sqlBuilder.appendWhere("=0"); - } - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; - } - break; - - case TASK_ID: - if (shouldLoadProperties(uri)) - { - // extended properties were requested, therefore change to task view that includes these properties - sqlBuilder.setTables(Tables.TASKS_PROPERTY_VIEW); - } - else - { - sqlBuilder.setTables(Tables.TASKS_VIEW); - } - selectId(sqlBuilder, TaskColumns._ID, uri); - if (!isSyncAdapter) - { - // do not return deleted rows if caller is not a sync adapter - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(Tasks._DELETED); - sqlBuilder.appendWhere("=0"); - } - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Tasks.DEFAULT_SORT_ORDER; - } - break; - - case INSTANCES: - if (shouldLoadProperties(uri)) - { - // extended properties were requested, therefore change to instance view that includes these properties - sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); - } - else - { - sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); - } - if (!isSyncAdapter) - { - // do not return deleted rows if caller is not a sync adapter - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(Tasks._DELETED); - sqlBuilder.appendWhere("=0"); - } - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; - } - break; - - case INSTANCE_ID: - if (shouldLoadProperties(uri)) - { - // extended properties were requested, therefore change to instance view that includes these properties - sqlBuilder.setTables(Tables.INSTANCE_PROPERTY_VIEW); - } - else - { - sqlBuilder.setTables(Tables.INSTANCE_CLIENT_VIEW); - } - selectId(sqlBuilder, Instances._ID, uri); - if (!isSyncAdapter) - { - // do not return deleted rows if caller is not a sync adapter - sqlBuilder.appendWhere(" AND "); - sqlBuilder.appendWhere(Tasks._DELETED); - sqlBuilder.appendWhere("=0"); - } - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Instances.DEFAULT_SORT_ORDER; - } - break; - - case CATEGORIES: - selectAccount(sqlBuilder, uri); - sqlBuilder.setTables(Tables.CATEGORIES); - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; - } - break; - - case CATEGORY_ID: - selectAccount(sqlBuilder, uri); - sqlBuilder.setTables(Tables.CATEGORIES); - selectId(sqlBuilder, CategoriesColumns._ID, uri); - if (sortOrder == null || sortOrder.length() == 0) - { - sortOrder = TaskContract.Categories.DEFAULT_SORT_ORDER; - } - break; - - case PROPERTIES: - sqlBuilder.setTables(Tables.PROPERTIES); - break; - - case PROPERTY_ID: - sqlBuilder.setTables(Tables.PROPERTIES); - selectId(sqlBuilder, PropertyColumns.PROPERTY_ID, uri); - break; - - case SEARCH: - String searchString = uri.getQueryParameter(Tasks.SEARCH_QUERY_PARAMETER); - searchString = Uri.decode(searchString); - Cursor searchCursor = FTSDatabaseHelper.getTaskSearchCursor(db, searchString, projection, selection, selectionArgs, sortOrder); - if (searchCursor != null) - { - // attach tasks uri for notifications, that way the search results are updated when a task changes - searchCursor.setNotificationUri(getContext().getContentResolver(), Tasks.getContentUri(mAuthority)); - } - return searchCursor; - - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - - Cursor c = sqlBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); - - if (c != null) - { - c.setNotificationUri(getContext().getContentResolver(), uri); - } - return c; - } - - - @Override - public int deleteInTransaction(final SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs, final boolean isSyncAdapter) - { - int count = 0; - String accountName = getAccountName(uri); - String accountType = getAccountType(uri); - - switch (mUriMatcher.match(uri)) - { - case SYNCSTATE_ID: - // the id is ignored, we only match by account type and name given in the Uri - case SYNCSTATE: - { - if (!isSyncAdapter) - { - throw new IllegalAccessError("only sync adapters may access syncstate"); - } - if (TextUtils.isEmpty(getAccountName(uri)) || TextUtils.isEmpty(getAccountType(uri))) - { - throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); - } - selection = updateSelection(selectAccount(uri), selection); - count = db.delete(Tables.SYNCSTATE, selection, selectionArgs); - break; - } - /* - * Deleting task lists is only allowed to sync adapters. They must provide ACCOUNT_NAME and ACCOUNT_TYPE. - */ - case LIST_ID: - // add _id to selection and fall through - selection = updateSelection(selectId(uri), selection); - case LISTS: - { - if (isSyncAdapter) - { - if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) - { - throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); - } - } - - // iterate over all lists that match the selection - final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); - - try - { - while (cursor.moveToNext()) - { - final ListAdapter list = new CursorContentValuesListAdapter(ListAdapter._ID.getFrom(cursor), cursor, new ContentValues()); - - mListProcessorChain.delete(db, list, isSyncAdapter); - mChanged.set(true); - count++; - } - } - finally - { - cursor.close(); - } - - break; - - } - /* - * Task won't be removed, just marked as deleted if the caller isn't a sync adapter. Sync adapters can remove tasks immediately. - */ - case TASK_ID: - // add id to selection and fall through - selection = updateSelection(selectId(uri), selection); - - case TASKS: - { - // TODO: filter by account name and type if present in uri. - - if (isSyncAdapter) - { - if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) - { - throw new IllegalArgumentException("Sync adapters must specify an account and account type: " + uri); - } - } - - // iterate over all tasks that match the selection - final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); - - try - { - while (cursor.moveToNext()) - { - final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, new ContentValues()); - - mTaskProcessorChain.delete(db, task, isSyncAdapter); - - mChanged.set(true); - count++; - } - } - finally - { - cursor.close(); - } - - break; - } - - case INSTANCE_ID: - // add id to selection and fall through - selection = updateSelection(selectId(uri), selection); - - case INSTANCES: - { - // iterate over all instances that match the selection - try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) - { - while (cursor.moveToNext()) - { - mInstanceProcessorChain.delete(db, new CursorContentValuesInstanceAdapter(cursor, new ContentValues()), isSyncAdapter); - mChanged.set(true); - count++; - } - } - - break; - } - - case ALARM_ID: - // add id to selection and fall through - selection = updateSelection(selectId(uri), selection); - - case ALARMS: - - count = db.delete(Tables.ALARMS, selection, selectionArgs); - break; - - case PROPERTY_ID: - selection = updateSelection(selectPropertyId(uri), selection); - - case PROPERTIES: - // fetch all properties that match the selection - Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); - - try - { - int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); - int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); - int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); - while (cursor.moveToNext()) - { - long propertyId = cursor.getLong(propIdCol); - long taskId = cursor.getLong(taskIdCol); - String mimeType = cursor.getString(mimeTypeCol); - if (mimeType != null) - { - PropertyHandler handler = PropertyHandlerFactory.get(mimeType); - count += handler.delete(db, taskId, propertyId, cursor, isSyncAdapter); - } - } - } - finally - { - cursor.close(); - } - postNotifyUri(Properties.getContentUri(mAuthority)); - break; - - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - - if (count > 0) - { - postNotifyUri(uri); - postNotifyUri(Instances.getContentUri(mAuthority)); - postNotifyUri(Tasks.getContentUri(mAuthority)); - } - return count; - } - - - @Override - public Uri insertInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, final boolean isSyncAdapter) - { - long rowId; - Uri result_uri; - - String accountName = getAccountName(uri); - String accountType = getAccountType(uri); - - switch (mUriMatcher.match(uri)) - { - case SYNCSTATE: - { - if (!isSyncAdapter) - { - throw new IllegalAccessError("only sync adapters may access syncstate"); - } - if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) - { - throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); - } - values.put(SyncState.ACCOUNT_NAME, accountName); - values.put(SyncState.ACCOUNT_TYPE, accountType); - rowId = db.replace(Tables.SYNCSTATE, null, values); - result_uri = TaskContract.SyncState.getContentUri(mAuthority); - break; - } - case LISTS: - { - final ListAdapter list = new ContentValuesListAdapter(values); - list.set(ListAdapter.ACCOUNT_NAME, accountName); - list.set(ListAdapter.ACCOUNT_TYPE, accountType); - - mListProcessorChain.insert(db, list, isSyncAdapter); - mChanged.set(true); - - rowId = list.id(); - result_uri = TaskContract.TaskLists.getContentUri(mAuthority); - // if the account is unknown we need to ask the user - // - // AGENDULA CHANGE: also require the type to be one we authenticate ourselves, for - // the same reason Utils.cleanUpLists does. Without GET_ACCOUNTS the cache only ever - // holds our own accounts, so upstream's test would call *every* externally-synced - // list stale and fire a broadcast about it on each insert. Nothing listens today; - // the point is that whatever listens tomorrow gets a signal that means something. - if (Build.VERSION.SDK_INT >= 26 && - !TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && - Utils.isOwnAccountType(getContext(), accountType) && - !mAccountCache.get().contains(new Account(accountName, accountType))) - { - // store the fact that we have an unknown account in this transaction - mStaleListCreated.set(true); - Log.d(TAG, String.format("List with unknown account %s inserted.", new Account(accountName, accountType))); - } - break; - } - case TASKS: - final TaskAdapter task = new ContentValuesTaskAdapter(values); - - mTaskProcessorChain.insert(db, task, isSyncAdapter); - - mChanged.set(true); - - rowId = task.id(); - result_uri = TaskContract.Tasks.getContentUri(mAuthority); - - postNotifyUri(Instances.getContentUri(mAuthority)); - postNotifyUri(Tasks.getContentUri(mAuthority)); - - break; - - // inserting instances is currently disabled because we only expand one instance, - // so even though a new task (exception) would be created, no instance might show up - // we need to resolve this discrepancy. Until then this feature remains disabled. -// case INSTANCES: -// { -// InstanceAdapter instance = mInstanceProcessorChain.insert(db, new ContentValuesInstanceAdapter(values), isSyncAdapter); -// rowId = instance.id(); -// result_uri = TaskContract.Instances.getContentUri(mAuthority); -// -// postNotifyUri(Instances.getContentUri(mAuthority)); -// postNotifyUri(Tasks.getContentUri(mAuthority)); -// -// break; -// } - case PROPERTIES: - String mimetype = values.getAsString(Properties.MIMETYPE); - - if (mimetype == null) - { - throw new IllegalArgumentException("missing mimetype in property values"); - } - - Long taskId = values.getAsLong(Properties.TASK_ID); - if (taskId == null) - { - throw new IllegalArgumentException("missing task id in property values"); - } - - if (values.containsKey(Properties.PROPERTY_ID)) - { - throw new IllegalArgumentException("property id can not be written"); - } - - PropertyHandler handler = PropertyHandlerFactory.get(mimetype); - rowId = handler.insert(db, taskId, values, isSyncAdapter); - result_uri = TaskContract.Properties.getContentUri(mAuthority); - if (rowId >= 0) - { - postNotifyUri(Tasks.getContentUri(mAuthority)); - postNotifyUri(Instances.getContentUri(mAuthority)); - } - break; - - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - - if (rowId > 0 && result_uri != null) - { - result_uri = ContentUris.withAppendedId(result_uri, rowId); - postNotifyUri(result_uri); - postNotifyUri(uri); - return result_uri; - } - throw new SQLException("Failed to insert row into " + uri); - } - - - @Override - public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentValues values, String selection, String[] selectionArgs, - final boolean isSyncAdapter) - { - int count = 0; - boolean dataChanged = false; - switch (mUriMatcher.match(uri)) - { - case SYNCSTATE_ID: - // the id is ignored, we only match by account type and name given in the Uri - case SYNCSTATE: - { - if (!isSyncAdapter) - { - throw new IllegalAccessError("only sync adapters may access syncstate"); - } - - String accountName = getAccountName(uri); - String accountType = getAccountType(uri); - if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) - { - throw new IllegalArgumentException("uri must contain an account when accessing syncstate"); - } - - if (values.size() == 0) - { - // we're done - break; - } - - values.put(SyncState.ACCOUNT_NAME, accountName); - values.put(SyncState.ACCOUNT_TYPE, accountType); - - long id = db.replace(Tables.SYNCSTATE, null, values); - if (id >= 0) - { - count = 1; - } - break; - } - case LIST_ID: - // update selection and fall through - selection = updateSelection(selectId(uri), selection); - - case LISTS: - { - // iterate over all task lists that match the selection - final Cursor cursor = db.query(Tables.LISTS, null, selection, selectionArgs, null, null, null, null); - - int idCol = cursor.getColumnIndex(TaskContract.TaskLists._ID); - - try - { - while (cursor.moveToNext()) - { - final long listId = cursor.getLong(idCol); - - // clone list values if we have more than one list to update - // we need this, because the processors may change the values - final ListAdapter list = new CursorContentValuesListAdapter(listId, cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); - - if (list.hasUpdates()) - { - mListProcessorChain.update(db, list, isSyncAdapter); - dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); - } - // note we still count the row even if no update was necessary - count++; - } - } - finally - { - cursor.close(); - } - break; - } - case TASK_ID: - // update selection and fall through - selection = updateSelection(selectId(uri), selection); - - case TASKS: - { - // iterate over all tasks that match the selection - final Cursor cursor = db.query(Tables.TASKS_VIEW, null, selection, selectionArgs, null, null, null, null); - - try - { - while (cursor.moveToNext()) - { - // clone task values if we have more than one task to update - // we need this, because the processors may change the values - final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); - - if (task.hasUpdates()) - { - mTaskProcessorChain.update(db, task, isSyncAdapter); - dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); - } - // note we still count the row even if no update was necessary - count++; - } - } - finally - { - cursor.close(); - } - - if (dataChanged) - { - postNotifyUri(Instances.getContentUri(mAuthority)); - postNotifyUri(Tasks.getContentUri(mAuthority)); - } - break; - } - - case INSTANCE_ID: - // update selection and fall through - selection = updateSelection(selectId(uri), selection); - - case INSTANCES: - { - // iterate over all instances that match the selection - - try (Cursor cursor = db.query(Tables.INSTANCE_VIEW, null, selection, selectionArgs, null, null, null, null)) - { - while (cursor.moveToNext()) - { - // clone task values if we have more than one task to update - // we need this, because the processors may change the values - final InstanceAdapter instance = new CursorContentValuesInstanceAdapter(cursor, - cursor.getCount() > 1 ? new ContentValues(values) : values); - - if (instance.hasUpdates()) - { - mInstanceProcessorChain.update(db, instance, isSyncAdapter); - dataChanged = true; - } - // note we still count the row even if no update was necessary - count++; - } - } - - if (dataChanged) - { - postNotifyUri(Instances.getContentUri(mAuthority)); - postNotifyUri(Tasks.getContentUri(mAuthority)); - } - break; - } - case PROPERTY_ID: - selection = updateSelection(selectPropertyId(uri), selection); - - case PROPERTIES: - if (values.containsKey(Properties.MIMETYPE)) - { - throw new IllegalArgumentException("property mimetypes can not be modified"); - } - - if (values.containsKey(Properties.TASK_ID)) - { - throw new IllegalArgumentException("task id can not be changed"); - } - - if (values.containsKey(Properties.PROPERTY_ID)) - { - throw new IllegalArgumentException("property id can not be changed"); - } - - // fetch all properties that match the selection - Cursor cursor = db.query(Tables.PROPERTIES, null, selection, selectionArgs, null, null, null); - - try - { - int propIdCol = cursor.getColumnIndex(Properties.PROPERTY_ID); - int taskIdCol = cursor.getColumnIndex(Properties.TASK_ID); - int mimeTypeCol = cursor.getColumnIndex(Properties.MIMETYPE); - while (cursor.moveToNext()) - { - long propertyId = cursor.getLong(propIdCol); - long taskId = cursor.getLong(taskIdCol); - String mimeType = cursor.getString(mimeTypeCol); - if (mimeType != null) - { - PropertyHandler handler = PropertyHandlerFactory.get(mimeType); - count += handler.update(db, taskId, propertyId, values, cursor, isSyncAdapter); - } - } - } - finally - { - cursor.close(); - } - postNotifyUri(Properties.getContentUri(mAuthority)); - break; - - case CATEGORY_ID: - String newCategorySelection = updateSelection(selectId(uri), selection); - validateCategoryValues(values, false, isSyncAdapter); - count = db.update(Tables.CATEGORIES, values, newCategorySelection, selectionArgs); - break; - case ALARM_ID: - String newAlarmSelection = updateSelection(selectId(uri), selection); - validateAlarmValues(values, false, isSyncAdapter); - count = db.update(Tables.ALARMS, values, newAlarmSelection, selectionArgs); - break; - default: - ContentOperation operation = ContentOperation.get(mUriMatcher.match(uri), OPERATIONS); - - if (operation == null) - { - throw new IllegalArgumentException("Unknown URI " + uri); - } - - operation.run(getContext(), mAsyncHandler, uri, db, values); - } - - if (dataChanged) - { - // send notifications, because non-sync columns have been updated - postNotifyUri(uri); - mChanged.set(true); - } - - return count; - } - - - /** - * Update task due and task start notifications. - */ - private void updateNotifications() - { - mAsyncHandler.post(new Runnable() - { - - @Override - public void run() - { - ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(getContext(), null); - } - }); - } - - - /** - * Validate the given category values. - * - * @param values - * The category properties to validate. - * - * @throws IllegalArgumentException - * if any of the values is invalid. - */ - private void validateCategoryValues(ContentValues values, boolean isNew, boolean isSyncAdapter) - { - // row id can not be changed or set manually - if (values.containsKey(Categories._ID)) - { - throw new IllegalArgumentException("_ID can not be set manually"); - } - - if (isNew != values.containsKey(Categories.ACCOUNT_NAME) && (!isNew || values.get(Categories.ACCOUNT_NAME) != null)) - { - throw new IllegalArgumentException("ACCOUNT_NAME is write-once and required on INSERT"); - } - - if (isNew != values.containsKey(Categories.ACCOUNT_TYPE) && (!isNew || values.get(Categories.ACCOUNT_TYPE) != null)) - { - throw new IllegalArgumentException("ACCOUNT_TYPE is write-once and required on INSERT"); - } - } - - - /** - * Validate the given alarm values. - * - * @param values - * The alarm values to validate - * - * @throws IllegalArgumentException - * if any of the values is invalid. - */ - private void validateAlarmValues(ContentValues values, boolean isNew, boolean isSyncAdapter) - { - if (values.containsKey(Alarms.ALARM_ID)) - { - throw new IllegalArgumentException("ALARM_ID can not be set manually"); - } - } - - - @Override - public String getType(Uri uri) - { - switch (mUriMatcher.match(uri)) - { - case LISTS: - return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; - case LIST_ID: - return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + TaskLists.CONTENT_URI_PATH; - case TASKS: - return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; - case TASK_ID: - return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Tasks.CONTENT_URI_PATH; - case INSTANCES: - return ContentResolver.CURSOR_DIR_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; - case INSTANCE_ID: - return ContentResolver.CURSOR_ITEM_BASE_TYPE + "/org.dmfs.tasks." + Instances.CONTENT_URI_PATH; - default: - throw new IllegalArgumentException("Unsupported URI: " + uri); - } - } - - - @Override - protected void onEndTransaction(boolean callerIsSyncAdapter) - { - super.onEndTransaction(callerIsSyncAdapter); - if (mChanged.compareAndSet(true, false)) - { - updateNotifications(); - Utils.sendActionProviderChangedBroadCast(getContext(), mAuthority); - } - - if (Boolean.TRUE.equals(mStaleListCreated.get())) - { - // notify UI about the stale lists, it's up the UI to deal with this, either by showing a notification or an instant popup. - Intent visbilityRequest = new Intent("org.dmfs.tasks.action.STALE_LIST_BROADCAST").setPackage(getContext().getPackageName()); - getContext().sendBroadcast(visbilityRequest); - } - } - - - @Override - public SQLiteOpenHelper getDatabaseHelper(Context context) - { - TaskDatabaseHelper helper = new TaskDatabaseHelper(context, this); - - return helper; - } - - - @Override - public void onDatabaseCreated(SQLiteDatabase db) - { - // notify listeners that the database has been created - Intent dbInitializedIntent = new Intent(TaskContract.ACTION_DATABASE_INITIALIZED); - dbInitializedIntent.setDataAndType(TaskContract.getContentUri(mAuthority), TaskContract.MIMETYPE_AUTHORITY); - // Android SDK 26 doesn't allow us to send implicit broadcasts, this particular brodcast is only for internal use, so just make it explicit by setting our package name - dbInitializedIntent.setPackage(getContext().getPackageName()); - getContext().sendBroadcast(dbInitializedIntent); - } - - - @Override - public void onDatabaseUpdate(SQLiteDatabase db, int oldVersion, int newVersion) - { - if (oldVersion < 15) - { - mAsyncHandler.post(() -> ContentOperation.UPDATE_TIMEZONE.fire(getContext(), null)); - } - } - - - @Override - protected boolean syncToNetwork(Uri uri) - { - return true; - } - - - @Override - public void onAccountsUpdated(Account[] accounts) - { - // cache the known accounts so we can check whether we know accounts for which new lists are added - mAccountCache.set(new HashSet<>(Arrays.asList(accounts))); - // TODO: we probably can move the cleanup code here and get rid of the Utils class - Utils.cleanUpLists(getContext(), getDatabaseHelper().getWritableDatabase(), accounts, mAuthority); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java b/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java deleted file mode 100644 index e9b6a07..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/TaskProviderBroadcastReceiver.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.annotation.SuppressLint; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Build; - -import org.dmfs.rfc5545.DateTime; - -import java.util.TimeZone; - - -/** - * A receiver for all task provider related broadcasts. This receiver merely forwards all incoming broadcasts to the provider, so they can be handled - * asynchronously in the provider context. - * - * @author Marten Gajda - */ -public class TaskProviderBroadcastReceiver extends BroadcastReceiver -{ - private final static int REQUEST_CODE_ALARM = 1337; - - // AGENDULA CHANGE: renamed into our namespace. Only ever used for a PendingIntent we address to - // this very class, so it collides with nothing — but a device may have OpenTasks installed too, - // and identical action strings across two apps are the kind of thing that is very confusing to - // read in a bug report. - private final static String ACTION_NOTIFICATION_ALARM = "de.jeanlucmakiola.agendula.provider.NOTIFICATION_ALARM"; - - - /** - * Registers a system alarm to update notifications at a specific time. - * - * @param context - * A Context. - * @param updateTime - * When to fire the alarm. - */ - // AGENDULA CHANGE: MissingPermission added. Lint cannot see that the setExact call below is already - // guarded by canScheduleExactAlarms(), with a set() fallback when it returns false. - @SuppressLint({ "NewApi", "MissingPermission" }) - static void planNotificationUpdate(Context context, DateTime updateTime) - { - AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent alarmIntent = new Intent(context, TaskProviderBroadcastReceiver.class); - alarmIntent.setAction(ACTION_NOTIFICATION_ALARM); - - // AGENDULA CHANGE: FLAG_IMMUTABLE added. Since Android 12 a PendingIntent must state its - // mutability, and this call throws IllegalArgumentException without it once targetSdk >= 31. - // Upstream targets 29, so it never hit this; we target 36. Nothing fills the intent in later, - // so immutable is also the correct choice on the merits. - PendingIntent pendingIntent = PendingIntent.getBroadcast( - context, REQUEST_CODE_ALARM, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - // cancel any previous alarm - am.cancel(pendingIntent); - - if (updateTime.isFloating()) - { - // convert floating times to absolute times - updateTime = new DateTime(TimeZone.getDefault(), updateTime.getYear(), updateTime.getMonth(), updateTime.getDayOfMonth(), updateTime.getHours(), - updateTime.getMinutes(), updateTime.getSeconds()); - } - - // AlarmManager API changed in v19 (KitKat) and the "set" method is not called at the exact time anymore - // - // AGENDULA CHANGE: fall back to an inexact alarm when exact ones aren't permitted. On API - // 31-32 SCHEDULE_EXACT_ALARM is revocable, and setExact then throws SecurityException — here, - // inside a receiver handling a system broadcast, which means the app dies every time the - // timezone changes. This alarm only re-runs the provider's own bookkeeping, so a few minutes - // of drift costs nothing; Agendula's user-visible due reminders are armed by ReminderScheduler, - // which asks for the permission properly. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()) - { - am.setExact(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); - } - else - { - am.set(AlarmManager.RTC_WAKEUP, updateTime.getTimestamp(), pendingIntent); - } - } - - - @Override - public void onReceive(Context context, Intent intent) - { - String action = intent.getAction(); - if (action == null) - { - return; - } - - // AGENDULA CHANGE: the cases below are upstream's, written out rather than reached by - // fall-through. Upstream's switch has no `break` anywhere, so TIMEZONE_CHANGED already runs - // all three operations and ACTION_NOTIFICATION_ALARM runs the last two — which is what this - // does, unchanged. It is spelled out because the comment upstream attaches to the first case - // ("don't trigger the notifications update yet") describes breaks that were never written, - // so the code and the stated intent disagree and only one of them can be preserved. - // - // Behaviour wins: a vendored fork is the wrong place to act on a guess. Whether the missing - // breaks are the bug, or the comment is, wants a device with a task due across a timezone - // change to settle — see provider/PROVENANCE.md. - if (Intent.ACTION_TIMEZONE_CHANGED.equals(action)) - { - // the local timezone has been changed, notify the provider to take the necessary steps. - ContentOperation.UPDATE_TIMEZONE.fire(context, null); - } - if (Intent.ACTION_TIMEZONE_CHANGED.equals(action) || ACTION_NOTIFICATION_ALARM.equals(action)) - { - // it's time for the next notification - ContentOperation.POST_NOTIFICATIONS.fire(context, null); - } - // at this time all other actions trigger an update of the notification alarm - ContentOperation.UPDATE_NOTIFICATION_ALARM.fire(context, null); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/Utils.java b/provider/src/main/java/org/dmfs/provider/tasks/Utils.java deleted file mode 100644 index febcd52..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/Utils.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.accounts.AuthenticatorDescription; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.iterables.SingletonIterable; -import org.dmfs.jems.iterable.composite.Joined; -import org.dmfs.jems.iterable.decorators.Mapped; -import org.dmfs.jems.procedure.composite.Batch; -import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; -import org.dmfs.provider.tasks.utils.ResourceArray; -import org.dmfs.provider.tasks.utils.With; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Instances; -import org.dmfs.tasks.contract.TaskContract.SyncState; -import org.dmfs.tasks.contract.TaskContract.TaskListColumns; -import org.dmfs.tasks.contract.TaskContract.TaskListSyncColumns; -import org.dmfs.tasks.contract.TaskContract.TaskLists; -import org.dmfs.tasks.contract.TaskContract.Tasks; -import de.jeanlucmakiola.agendula.provider.R; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - - -/** - * The Class Utils. - * - * @author Tobias Reinsch - * @author Marten Gajda - */ -public class Utils -{ - private static final AtomicReference> sOwnAccountTypes = new AtomicReference<>(null); - - - public static void sendActionProviderChangedBroadCast(Context context, String authority) - { - // TODO: Using the TaskContract content uri results in a "Unknown URI content" error message. Using the Tasks content uri instead will break the - // broadcast receiver. We have to find away around this - // TODO: coalesce fast consecutive broadcasts, a delay of up to 1 second should be acceptable - - new With<>(new Intent(Intent.ACTION_PROVIDER_CHANGED, TaskContract.getContentUri(authority))) - .process(providerChangedIntent -> - new Batch(context::sendBroadcast) - .process(new Mapped<>( - packageName -> new Intent(providerChangedIntent).setPackage(packageName), - // TODO: fow now we hard code 3rd party package names, this should be replaced by some sort or registry - // see https://github.com/dmfs/opentasks/issues/824 - new Joined<>( - new SingletonIterable<>(context.getPackageName()), - new ResourceArray(context, R.array.agendula_provider_changed_receivers))))); - } - - - /** - * The account types this package owns an authenticator for. - *

- * AGENDULA CHANGE. Upstream held {@code android.permission.GET_ACCOUNTS} and so could enumerate every account on the device; we dropped it, because - * since API 26 an authenticator already makes its own accounts visible to its own package and nothing else concerns us. - *

- * That deletion is only safe together with {@link #cleanUpLists}: an account we cannot see is indistinguishable from an account that has been removed, - * and the upstream cleanup treats the latter as a licence to delete the lists hanging off it. Restricting the cleanup to types in this set makes that - * failure impossible by construction rather than by care — the provider can only ever prune accounts it is authoritative about. - *

- * While Agendula ships no sync adapter this set is empty and no list is ever pruned. Our own account type joins it automatically the moment the - * authenticator is declared, with no further change here. - * - * @return the account types authenticated by this very package, possibly empty — never null. - */ - static Set ownAccountTypes(Context context) - { - Set cached = sOwnAccountTypes.get(); - if (cached != null) - { - return cached; - } - String ownPackage = context.getPackageName(); - Set types = new HashSet<>(); - for (AuthenticatorDescription description : AccountManager.get(context).getAuthenticatorTypes()) - { - if (ownPackage.equals(description.packageName)) - { - types.add(description.type); - } - } - // Which authenticators our own package declares is fixed at install time, so this cannot go - // stale within a process. Worth caching: isOwnAccountType runs on every task-list insert and - // getAuthenticatorTypes is a binder round trip. - Set result = Collections.unmodifiableSet(types); - sOwnAccountTypes.compareAndSet(null, result); - return sOwnAccountTypes.get(); - } - - - /** - * Whether {@code accountType} is authenticated by this package. See {@link #ownAccountTypes}. - */ - static boolean isOwnAccountType(Context context, String accountType) - { - return ownAccountTypes(context).contains(accountType); - } - - - /** - * Drops the {@link #ownAccountTypes} cache. Tests only — in a real process the answer is fixed at install time, which is the entire reason it is cached. - */ - static void clearOwnAccountTypesCache() - { - sOwnAccountTypes.set(null); - } - - - public static void cleanUpLists(Context context, SQLiteDatabase db, Account[] accounts, String authority) - { - // make a list of the accounts array - List accountList = Arrays.asList(accounts); - // AGENDULA CHANGE — see ownAccountTypes: only types we authenticate ourselves may be pruned. - Set prunableTypes = ownAccountTypes(context); - - db.beginTransaction(); - - try - { - Cursor c = db.query(Tables.LISTS, new String[] { TaskListColumns._ID, TaskListSyncColumns.ACCOUNT_NAME, TaskListSyncColumns.ACCOUNT_TYPE }, null, - null, null, null, null); - - // build a list of all task list ids that no longer have an account - List obsoleteLists = new ArrayList(); - try - { - while (c.moveToNext()) - { - String accountType = c.getString(2); - // mark list for removal if it is non-local, of a type we authenticate - // ourselves, and the account is not in accountList - if (!TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType) && prunableTypes.contains(accountType)) - { - Account account = new Account(c.getString(1), accountType); - if (!accountList.contains(account)) - { - obsoleteLists.add(c.getLong(0)); - - // remove syncstate for this account right away - db.delete(Tables.SYNCSTATE, SyncState.ACCOUNT_NAME + "=? and " + SyncState.ACCOUNT_TYPE + "=?", new String[] { - account.name, - account.type }); - } - } - } - } - finally - { - c.close(); - } - - if (obsoleteLists.size() == 0) - { - // nothing to do here - return; - } - - // remove all accounts in the list - for (Long id : obsoleteLists) - { - if (id != null) - { - db.delete(Tables.LISTS, TaskListColumns._ID + "=" + id, null); - } - } - db.setTransactionSuccessful(); - } - finally - { - db.endTransaction(); - } - // notify all observers - - ContentResolver cr = context.getContentResolver(); - cr.notifyChange(TaskLists.getContentUri(authority), null); - cr.notifyChange(Tasks.getContentUri(authority), null); - cr.notifyChange(Instances.getContentUri(authority), null); - - Utils.sendActionProviderChangedBroadCast(context, authority); - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java deleted file mode 100644 index 4882d21..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/AlarmHandler.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.tasks.contract.TaskContract.Property; - - -/** - * This class is used to handle alarm property values during database transactions. - * - * @author Tobias Reinsch - */ -public class AlarmHandler extends PropertyHandler -{ - - // private static final String[] ALARM_ID_PROJECTION = { Alarms.ALARM_ID }; - // private static final String ALARM_SELECTION = Alarms.ALARM_ID + " =?"; - - - /** - * Validates the content of the alarm prior to insert and update transactions. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property if isNew is false. If isNew is true this value is ignored. - * @param isNew - * Indicates that the content is new and not an update. - * @param values - * The {@link ContentValues} to validate. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The valid {@link ContentValues}. - * - * @throws IllegalArgumentException - * if the {@link ContentValues} are invalid. - */ - @Override - public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) - { - // row id can not be changed or set manually - if (values.containsKey(Property.Alarm.PROPERTY_ID)) - { - throw new IllegalArgumentException("_ID can not be set manually"); - } - - if (!values.containsKey(Property.Alarm.MINUTES_BEFORE)) - { - throw new IllegalArgumentException("alarm property requires a time offset"); - } - - if (!values.containsKey(Property.Alarm.REFERENCE) || values.getAsInteger(Property.Alarm.REFERENCE) < 0) - { - throw new IllegalArgumentException("alarm property requires a valid reference date "); - } - - if (!values.containsKey(Property.Alarm.ALARM_TYPE)) - { - throw new IllegalArgumentException("alarm property requires an alarm type"); - } - - return values; - } - - - /** - * Inserts the alarm into the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task the new property belongs to. - * @param values - * The {@link ContentValues} to insert. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The row id of the new alarm as long - */ - @Override - public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) - { - values = validateValues(db, taskId, -1, true, values, isSyncAdapter); - return super.insert(db, taskId, values, isSyncAdapter); - } - - - /** - * Updates the alarm in the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property. - * @param values - * The {@link ContentValues} to update. - * @param oldValues - * A {@link Cursor} pointing to the old values in the database. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The number of rows affected. - */ - @Override - public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) - { - values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); - return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java deleted file mode 100644 index 8c129cb..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/CategoryHandler.java +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper.CategoriesMapping; -import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; -import org.dmfs.tasks.contract.TaskContract.Categories; -import org.dmfs.tasks.contract.TaskContract.Properties; -import org.dmfs.tasks.contract.TaskContract.Property.Category; -import org.dmfs.tasks.contract.TaskContract.Tasks; - - -/** - * This class is used to handle category property values during database transactions. - * - * @author Tobias Reinsch - */ -public class CategoryHandler extends PropertyHandler -{ - - private static final String[] CATEGORY_ID_PROJECTION = { Categories._ID, Categories.NAME, Categories.COLOR }; - - private static final String CATEGORY_ID_SELECTION = Categories._ID + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; - private static final String CATEGORY_NAME_SELECTION = Categories.NAME + "=? and " + Categories.ACCOUNT_NAME + "=? and " + Categories.ACCOUNT_TYPE + "=?"; - - public static final String IS_NEW_CATEGORY = "is_new_category"; - - - /** - * Validates the content of the category prior to insert and update transactions. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property if isNew is false. If isNew is true this value is ignored. - * @param isNew - * Indicates that the content is new and not an update. - * @param values - * The {@link ContentValues} to validate. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The valid {@link ContentValues}. - * - * @throws IllegalArgumentException - * if the {@link ContentValues} are invalid. - */ - @Override - public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) - { - // the category requires a name or an id - if (!values.containsKey(Category.CATEGORY_ID) && !values.containsKey(Category.CATEGORY_NAME)) - { - throw new IllegalArgumentException("Neiter an id nor a category name was supplied for the category property."); - } - - // get the matching task & account for the property - if (!values.containsKey(Properties.TASK_ID)) - { - throw new IllegalArgumentException("No task id was supplied for the category property"); - } - String[] queryArgs = { values.getAsString(Properties.TASK_ID) }; - String[] queryProjection = { Tasks.ACCOUNT_NAME, Tasks.ACCOUNT_TYPE }; - String querySelection = Tasks._ID + "=?"; - Cursor taskCursor = db.query(Tables.TASKS_VIEW, queryProjection, querySelection, queryArgs, null, null, null); - - String accountName = null; - String accountType = null; - try - { - if (taskCursor.moveToNext()) - { - accountName = taskCursor.getString(0); - accountType = taskCursor.getString(1); - - values.put(Categories.ACCOUNT_NAME, accountName); - values.put(Categories.ACCOUNT_TYPE, accountType); - } - } - finally - { - if (taskCursor != null) - { - taskCursor.close(); - } - } - - if (accountName != null && accountType != null) - { - // search for matching categories - String[] categoryArgs; - Cursor cursor; - - if (values.containsKey(Categories._ID)) - { - // serach by ID - categoryArgs = new String[] { values.getAsString(Category.CATEGORY_ID), accountName, accountType }; - cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_ID_SELECTION, categoryArgs, null, null, null); - } - else - { - // search by name - categoryArgs = new String[] { values.getAsString(Category.CATEGORY_NAME), accountName, accountType }; - cursor = db.query(Tables.CATEGORIES, CATEGORY_ID_PROJECTION, CATEGORY_NAME_SELECTION, categoryArgs, null, null, null); - } - try - { - if (cursor != null && cursor.getCount() == 1) - { - cursor.moveToNext(); - Long categoryID = cursor.getLong(0); - String categoryName = cursor.getString(1); - int color = cursor.getInt(2); - - values.put(Category.CATEGORY_ID, categoryID); - values.put(Category.CATEGORY_NAME, categoryName); - values.put(Category.CATEGORY_COLOR, color); - values.put(IS_NEW_CATEGORY, false); - } - else - { - values.put(IS_NEW_CATEGORY, true); - } - } - finally - { - if (cursor != null) - { - cursor.close(); - } - } - - } - - return values; - } - - - /** - * Inserts the category into the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task the new property belongs to. - * @param values - * The {@link ContentValues} to insert. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The row id of the new category as long - */ - @Override - public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) - { - values = validateValues(db, taskId, -1, true, values, isSyncAdapter); - values = getOrInsertCategory(db, values); - - // insert property row and create relation - long id = super.insert(db, taskId, values, isSyncAdapter); - insertRelation(db, taskId, values.getAsLong(Category.CATEGORY_ID), id); - - // update FTS entry with category name - updateFTSEntry(db, taskId, id, values.getAsString(Category.CATEGORY_NAME)); - return id; - } - - - /** - * Updates the category in the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property. - * @param values - * The {@link ContentValues} to update. - * @param oldValues - * A {@link Cursor} pointing to the old values in the database. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The number of rows affected. - */ - @Override - public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) - { - values = validateValues(db, taskId, propertyId, false, values, isSyncAdapter); - values = getOrInsertCategory(db, values); - - if (values.containsKey(Category.CATEGORY_NAME)) - { - // update FTS entry with new category name - updateFTSEntry(db, taskId, propertyId, values.getAsString(Category.CATEGORY_NAME)); - } - - return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); - } - - - /** - * Check if a category with matching {@link ContentValues} exists and returns the existing category or creates a new category in the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param values - * The {@link ContentValues} of the category. - * - * @return The {@link ContentValues} of the existing or new category. - */ - private ContentValues getOrInsertCategory(SQLiteDatabase db, ContentValues values) - { - if (values.getAsBoolean(IS_NEW_CATEGORY)) - { - // insert new category in category table - ContentValues newCategoryValues = new ContentValues(4); - newCategoryValues.put(Categories.ACCOUNT_NAME, values.getAsString(Categories.ACCOUNT_NAME)); - newCategoryValues.put(Categories.ACCOUNT_TYPE, values.getAsString(Categories.ACCOUNT_TYPE)); - newCategoryValues.put(Categories.NAME, values.getAsString(Category.CATEGORY_NAME)); - newCategoryValues.put(Categories.COLOR, values.getAsInteger(Category.CATEGORY_COLOR)); - - long categoryID = db.insert(Tables.CATEGORIES, "", newCategoryValues); - values.put(Category.CATEGORY_ID, categoryID); - } - - // remove redundant values - values.remove(IS_NEW_CATEGORY); - values.remove(Categories.ACCOUNT_NAME); - values.remove(Categories.ACCOUNT_TYPE); - - return values; - } - - - /** - * Inserts a relation entry in the database to link task and category. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The row id of the task. - * @param categoryId - * The row id of the category. - * - * @return The row id of the inserted relation. - */ - private long insertRelation(SQLiteDatabase db, long taskId, long categoryId, long propertyId) - { - ContentValues relationValues = new ContentValues(3); - relationValues.put(CategoriesMapping.TASK_ID, taskId); - relationValues.put(CategoriesMapping.CATEGORY_ID, categoryId); - relationValues.put(CategoriesMapping.PROPERTY_ID, propertyId); - return db.insert(Tables.CATEGORIES_MAPPING, "", relationValues); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java deleted file mode 100644 index 52d32d3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/DefaultPropertyHandler.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - - -/** - * This class is used to handle properties with unknown / unsupported mime-types. - * - * @author Tobias Reinsch - */ -public class DefaultPropertyHandler extends PropertyHandler -{ - - /** - * Validates the content of the alarm prior to insert and update transactions. - * - * @param db - * The {@link SQLiteDatabase}. - * @param isNew - * Indicates that the content is new and not an update. - * @param values - * The {@link ContentValues} to validate. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The valid {@link ContentValues}. - * - * @throws IllegalArgumentException - * if the {@link ContentValues} are invalid. - */ - @Override - public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) - { - return values; - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java deleted file mode 100644 index a01e39d..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandler.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.FTSDatabaseHelper; -import org.dmfs.provider.tasks.TaskDatabaseHelper.Tables; -import org.dmfs.tasks.contract.TaskContract.Properties; - - -/** - * Abstract class that is used as template for specific property handlers. - * - * @author Tobias Reinsch - */ -public abstract class PropertyHandler -{ - - /** - * Validates the content of the property prior to insert and update transactions. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property if isNew is false. If isNew is true this value is ignored. - * @param isNew - * Indicates that the content is new and not an update. - * @param values - * The {@link ContentValues} to validate. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The valid {@link ContentValues}. - * - * @throws IllegalArgumentException - * if the {@link ContentValues} are invalid. - */ - public abstract ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter); - - - /** - * Inserts the property {@link ContentValues} into the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task the new property belongs to. - * @param values - * The {@link ContentValues} to insert. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The row id of the new property as long - */ - public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) - { - return db.insert(Tables.PROPERTIES, "", values); - } - - - /** - * Updates the property {@link ContentValues} in the database. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property. - * @param values - * The {@link ContentValues} to update. - * @param oldValues - * A {@link Cursor} pointing to the old values in the database. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return The number of rows affected. - */ - public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) - { - return db.update(Tables.PROPERTIES, values, Properties.PROPERTY_ID + "=" + propertyId, null); - } - - - /** - * Deletes the property in the database. - * - * @param db - * The belonging database. - * @param taskId - * The id of the task this property belongs to. - * @param propertyId - * The id of the property. - * @param oldValues - * A {@link Cursor} pointing to the old values in the database. - * @param isSyncAdapter - * Indicates that the transaction was triggered from a SyncAdapter. - * - * @return - */ - public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) - { - return db.delete(Tables.PROPERTIES, Properties.PROPERTY_ID + "=" + propertyId, null); - - } - - - /** - * Method hook to insert FTS entries on database migration. - * - * @param db - * The {@link SQLiteDatabase}. - * @param taskId - * the row id of the task this property belongs to - * @param propertyId - * the id of the property - * @param text - * the searchable text of the property. If the property has multiple text snippets to search in, concat them separated by a space. - */ - protected void updateFTSEntry(SQLiteDatabase db, long taskId, long propertyId, String text) - { - FTSDatabaseHelper.updatePropertyFTSEntry(db, taskId, propertyId, text); - } - - - public ContentValues cloneForNewTask(long newTaskId, ContentValues values) - { - ContentValues newValues = new ContentValues(values); - newValues.remove(Properties.PROPERTY_ID); - newValues.put(Properties.TASK_ID, newTaskId); - return newValues; - } - - - ; -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java deleted file mode 100644 index 0e19463..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/PropertyHandlerFactory.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import org.dmfs.tasks.contract.TaskContract.Property.Alarm; -import org.dmfs.tasks.contract.TaskContract.Property.Category; -import org.dmfs.tasks.contract.TaskContract.Property.Relation; - - -/** - * A factory that creates the matching {@link PropertyHandler} for the given mimetype. - * - * @author Tobias Reinsch - */ -public class PropertyHandlerFactory -{ - private final static PropertyHandler CATEGORY_HANDLER = new CategoryHandler(); - private final static PropertyHandler ALARM_HANDLER = new AlarmHandler(); - private final static PropertyHandler RELATION_HANDLER = new RelationHandler(); - private final static PropertyHandler DEFAULT_PROPERTY_HANDLER = new DefaultPropertyHandler(); - - - /** - * Creates a specific {@link PropertyHandler}. - * - * @param mimeType - * The mimetype of the property. - * - * @return The matching {@link PropertyHandler} for the given mimetype or null - */ - public static PropertyHandler get(String mimeType) - { - if (Category.CONTENT_ITEM_TYPE.equals(mimeType)) - { - return CATEGORY_HANDLER; - } - if (Alarm.CONTENT_ITEM_TYPE.equals(mimeType)) - { - return ALARM_HANDLER; - } - if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) - { - return RELATION_HANDLER; - } - return DEFAULT_PROPERTY_HANDLER; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java b/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java deleted file mode 100644 index 8f896c5..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/handler/RelationHandler.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.handler; - -import android.annotation.SuppressLint; -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.tasks.contract.TaskContract.Property.Relation; -import org.dmfs.tasks.contract.TaskContract.Tasks; - - -/** - * Handles any inserts, updates and deletes on the relations table. - * - * @author Marten Gajda - */ -public class RelationHandler extends PropertyHandler -{ - - @Override - public ContentValues validateValues(SQLiteDatabase db, long taskId, long propertyId, boolean isNew, ContentValues values, boolean isSyncAdapter) - { - if (values.containsKey(Relation.RELATED_CONTENT_URI)) - { - throw new IllegalArgumentException("setting of RELATED_CONTENT_URI not allowed"); - } - - Long id = values.getAsLong(Relation.RELATED_ID); - String uid = values.getAsString(Relation.RELATED_UID); - - if (id == null && uid != null) - { - values.putNull(Relation.RELATED_ID); - } - else if (id != null && uid == null) - { - values.putNull(Relation.RELATED_UID); - } - else - { - throw new IllegalArgumentException("exactly one of RELATED_ID, RELATED_UID and RELATED_URI must be non-null"); - } - - return values; - } - - - @Override - public long insert(SQLiteDatabase db, long taskId, ContentValues values, boolean isSyncAdapter) - { - validateValues(db, taskId, -1, true, values, isSyncAdapter); - resolveFields(db, values); - updateParentId(db, taskId, values, null); - return super.insert(db, taskId, values, isSyncAdapter); - } - - - @Override - public ContentValues cloneForNewTask(long newTaskId, ContentValues values) - { - ContentValues newValues = super.cloneForNewTask(newTaskId, values); - newValues.remove(Relation.RELATED_CONTENT_URI); - return newValues; - } - - - @Override - public int update(SQLiteDatabase db, long taskId, long propertyId, ContentValues values, Cursor oldValues, boolean isSyncAdapter) - { - validateValues(db, taskId, propertyId, false, values, isSyncAdapter); - resolveFields(db, values); - updateParentId(db, taskId, values, oldValues); - return super.update(db, taskId, propertyId, values, oldValues, isSyncAdapter); - } - - - @Override - public int delete(SQLiteDatabase db, long taskId, long propertyId, Cursor oldValues, boolean isSyncAdapter) - { - clearParentId(db, taskId, oldValues); - return super.delete(db, taskId, propertyId, oldValues, isSyncAdapter); - } - - - /** - * Resolve _id or _uid, depending of which value is given. - *

- * TODO: store links into the calendar provider if we find an event that matches the UID. - *

- * - * @param db - * The task database. - * @param values - * The {@link ContentValues}. - */ - private void resolveFields(SQLiteDatabase db, ContentValues values) - { - Long id = values.getAsLong(Relation.RELATED_ID); - String uid = values.getAsString(Relation.RELATED_UID); - - if (id != null) - { - values.put(Relation.RELATED_UID, resolveTaskStringField(db, Tasks._ID, id.toString(), Tasks._UID)); - } - else if (uid != null) - { - values.put(Relation.RELATED_ID, resolveTaskLongField(db, Tasks._UID, uid, Tasks._ID)); - } - } - - - private Long resolveTaskLongField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) - { - String result = resolveTaskStringField(db, selectionField, selectionValue, resultField); - if (result != null) - { - return Long.parseLong(result); - } - return null; - } - - - private String resolveTaskStringField(SQLiteDatabase db, String selectionField, String selectionValue, String resultField) - { - Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, new String[] { resultField }, selectionField + "=?", new String[] { selectionValue }, null, null, - null); - if (c != null) - { - try - { - if (c.moveToNext()) - { - return c.getString(0); - } - } - finally - { - c.close(); - } - } - return null; - } - - - /** - * Update {@link Tasks#PARENT_ID} when a parent is assigned to a child. - * - * @param db - * @param taskId - * @param values - * @param oldValues - */ - // AGENDULA CHANGE: lint's Range check flags getInt(getColumnIndex(...)), since getColumnIndex returns -1 for an absent column. Both callers pass a cursor - // over a property row selected with a projection that contains RELATED_TYPE, so the index is never -1 in practice. Suppressed rather than "fixed": - // inventing a fallback value would change what the provider does on a path upstream chose to let fail loudly, and this is a fork, not our design. - @SuppressLint("Range") - private void updateParentId(SQLiteDatabase db, long taskId, ContentValues values, Cursor oldValues) - { - int type; - if (values.containsKey(Relation.RELATED_TYPE)) - { - type = values.getAsInteger(Relation.RELATED_TYPE); - } - else - { - type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); - } - - if (type == Relation.RELTYPE_PARENT) - { - // this is a link to the parent, we need to update the PARENT_ID of this task, if we can - - if (values.containsKey(Relation.RELATED_ID)) - { - ContentValues taskValues = new ContentValues(1); - taskValues.put(Tasks.PARENT_ID, values.getAsLong(Relation.RELATED_ID)); - db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); - } - // else: the parent task is probably not synced yet, we have to fix this in RelationUpdaterHook - } - else if (type == Relation.RELTYPE_CHILD) - { - // this is a link to a child, we need to update the PARENT_ID of the linked task - - if (values.getAsLong(Relation.RELATED_ID) != null) - { - ContentValues taskValues = new ContentValues(1); - taskValues.put(Tasks.PARENT_ID, taskId); - db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + values.getAsLong(Relation.RELATED_ID), null); - } - // else: the child task is probably not synced yet, we have to fix this in RelationUpdaterHook - } - else if (type == Relation.RELTYPE_SIBLING) - { - // this is a link to a sibling, we need to copy the PARENT_ID of the linked task to this task - if (values.getAsLong(Relation.RELATED_ID) != null) - { - // get the parent of the other task first - Long otherParent = resolveTaskLongField(db, Tasks._ID, values.getAsString(Relation.RELATED_ID), Tasks.PARENT_ID); - - ContentValues taskValues = new ContentValues(1); - taskValues.put(Tasks.PARENT_ID, otherParent); - db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); - } - // else: the sibling task is probably not synced yet, we have to fix this in RelationUpdaterHook - } - } - - - /** - * Clear {@link Tasks#PARENT_ID} if a link is removed. - * - * @param db - * @param taskId - * @param oldValues - */ - // AGENDULA CHANGE: see updateParentId — same Range suppression, same reason. - @SuppressLint("Range") - private void clearParentId(SQLiteDatabase db, long taskId, Cursor oldValues) - { - int type = oldValues.getInt(oldValues.getColumnIndex(Relation.RELATED_TYPE)); - - /* - * This is more complicated than it may sound. We don't know the order in which relations are created, updated or removed. So it's possible that a new - * parent relationship has been created and the old one is removed afterwards. In that case we can not simply clear the PARENT_ID. - * - * FIXME: For now we ignore that fact. But we should fix it. - */ - - if (type == Relation.RELTYPE_PARENT) - { - // this was a link to the parent, we're orphaned now, so clear PARENT_ID of this task - - ContentValues taskValues = new ContentValues(1); - taskValues.putNull(Tasks.PARENT_ID); - db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + taskId, null); - } - else if (type == Relation.RELTYPE_CHILD) - { - // this was a link to a child, the child is orphaned now, clear its PARENT_ID - - int relIdCol = oldValues.getColumnIndex(Relation.RELATED_ID); - if (!oldValues.isNull(relIdCol)) - { - ContentValues taskValues = new ContentValues(1); - taskValues.putNull(Tasks.PARENT_ID); - db.update(TaskDatabaseHelper.Tables.TASKS, taskValues, Tasks._ID + "=" + oldValues.getLong(relIdCol), null); - } - } - // else if (type == Relation.RELTYPE_SIBLING) - // { - /* - * This was a link to a sibling, since it's no longer our sibling either it or we're orphaned now We won't know unless we check all relations. - * - * FIXME: properly handle this case - */ - // } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java deleted file mode 100644 index 4cae1c8..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractInstanceAdapter.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentUris; -import android.net.Uri; - -import org.dmfs.tasks.contract.TaskContract; - - -/** - * An abstract implementation of a {@link InstanceAdapter} to server as the base for more concrete adapters. - * - * @author Marten Gajda - */ -public abstract class AbstractInstanceAdapter implements InstanceAdapter -{ - @Override - public final Uri uri(String authority) - { - return ContentUris.withAppendedId(TaskContract.Instances.getContentUri(authority), id()); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java deleted file mode 100644 index 80478e3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractListAdapter.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentUris; -import android.content.ContentValues; -import android.net.Uri; - -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * An abstract implementation of a {@link ListAdapter} to server as the base for more concrete adapters. - * - * @author Marten Gajda - */ -public abstract class AbstractListAdapter implements ListAdapter -{ - private final ContentValues mState = new ContentValues(10); - - - @Override - public Uri uri(String authority) - { - return ContentUris.withAppendedId(TaskContract.TaskLists.getContentUri(authority), id()); - } - - - @Override - public T getState(FieldAdapter stateFieldAdater) - { - return stateFieldAdater.getFrom(mState); - } - - - @Override - public void setState(FieldAdapter stateFieldAdater, T value) - { - stateFieldAdater.setIn(mState, value); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java deleted file mode 100644 index 1f23f77..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/AbstractTaskAdapter.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentUris; -import android.content.ContentValues; -import android.net.Uri; - -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * An abstract implementation of a {@link TaskAdapter} to server as the base for more concrete adapters. - * - * @author Marten Gajda - */ -public abstract class AbstractTaskAdapter implements TaskAdapter -{ - private final ContentValues mState = new ContentValues(10); - - - @Override - public Uri uri(String authority) - { - return ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(authority), id()); - } - - - @Override - public boolean isRecurring() - { - // recurring tasks must have an RRULE or RDATEs and at least one of DTSTART and DUE date - return (valueOf(RRULE) != null || valueOf(RDATE).iterator().hasNext()) && (valueOf(DTSTART) != null || valueOf(DUE) != null); - } - - - @Override - public boolean recurrenceUpdated() - { - return isUpdated(RRULE) || isUpdated(DTSTART) || isUpdated(DUE) || isUpdated(DURATION) || isUpdated(RDATE) || isUpdated(EXDATE); - } - - - @Override - public T getState(FieldAdapter stateFieldAdater) - { - return stateFieldAdater.getFrom(mState); - } - - - @Override - public void setState(FieldAdapter stateFieldAdater, T value) - { - stateFieldAdater.setIn(mState, value); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java deleted file mode 100644 index 58a7e54..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesInstanceAdapter.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.jems.single.elementary.Reduced; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. - * - * @author Marten Gajda - */ -public class ContentValuesInstanceAdapter extends AbstractInstanceAdapter -{ - private long mId; - private final ContentValues mValues; - - - public ContentValuesInstanceAdapter(ContentValues values) - { - this(-1L, values); - } - - - public ContentValuesInstanceAdapter(long id, ContentValues values) - { - mId = id; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return null; - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - return fieldAdapter.isSetIn(mValues); - } - - - @Override - public boolean isWriteable() - { - return true; - } - - - @Override - public boolean hasUpdates() - { - return mValues.size() > 0; - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - if (mId < 0) - { - mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); - return mId > 0 ? 1 : 0; - } - else - { - return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); - } - } - - - @Override - public T getState(FieldAdapter stateFieldAdater) - { - return null; - } - - - @Override - public void setState(FieldAdapter stateFieldAdater, T value) - { - - } - - - @Override - public InstanceAdapter duplicate() - { - return new ContentValuesInstanceAdapter(new ContentValues(mValues)); - } - - - @Override - public TaskAdapter taskAdapter() - { - // make sure we remove any instance fields - return new ContentValuesTaskAdapter(new Reduced( - () -> new ContentValues(mValues), - (contentValues, column) -> { - contentValues.remove(column); - return contentValues; - }, - INSTANCE_COLUMN_NAMES).value()); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java deleted file mode 100644 index d441848..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesListAdapter.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * @author Marten Gajda - */ -public class ContentValuesListAdapter extends AbstractListAdapter -{ - private long mId; - private final ContentValues mValues; - - - public ContentValuesListAdapter(ContentValues values) - { - this(-1L, values); - } - - - public ContentValuesListAdapter(long id, ContentValues values) - { - mId = id; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return null; - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - return fieldAdapter.isSetIn(mValues); - } - - - @Override - public boolean isWriteable() - { - return true; - } - - - @Override - public boolean hasUpdates() - { - return mValues.size() > 0; - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - if (mId < 0) - { - mId = db.insert(TaskDatabaseHelper.Tables.LISTS, null, mValues); - return mId > 0 ? 1 : 0; - } - else - { - return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); - } - } - - - @Override - public ListAdapter duplicate() - { - return new ContentValuesListAdapter(new ContentValues(mValues)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java deleted file mode 100644 index c8a3b8d..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/ContentValuesTaskAdapter.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A {@link TaskAdapter} for tasks that are stored in a {@link ContentValues}. - * - * @author Marten Gajda - */ -public class ContentValuesTaskAdapter extends AbstractTaskAdapter -{ - private long mId; - private final ContentValues mValues; - - - public ContentValuesTaskAdapter(ContentValues values) - { - this(-1L, values); - } - - - public ContentValuesTaskAdapter(long id, ContentValues values) - { - mId = id; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return null; - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - return fieldAdapter.isSetIn(mValues); - } - - - @Override - public boolean isWriteable() - { - return true; - } - - - @Override - public boolean hasUpdates() - { - return mValues.size() > 0; - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - if (mId < 0) - { - mId = db.insert(TaskDatabaseHelper.Tables.TASKS, null, mValues); - return mId > 0 ? 1 : 0; - } - else - { - return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); - } - } - - - @Override - public TaskAdapter duplicate() - { - return new ContentValuesTaskAdapter(new ContentValues(mValues)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java deleted file mode 100644 index 6213cb3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.iterables.decorators.Sieved; -import org.dmfs.iterables.elementary.Seq; -import org.dmfs.jems.iterable.decorators.Mapped; -import org.dmfs.jems.single.elementary.Collected; -import org.dmfs.jems.single.elementary.Reduced; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.provider.tasks.utils.ContainsValues; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.ArrayList; - - -/** - * An {@link InstanceAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be - * stored in the database with {@link #commit(SQLiteDatabase)}. - * - * @author Marten Gajda - */ -public class CursorContentValuesInstanceAdapter extends AbstractInstanceAdapter -{ - private final long mId; - private final Cursor mCursor; - private final ContentValues mValues; - - - public CursorContentValuesInstanceAdapter(Cursor cursor, ContentValues values) - { - if (cursor == null && !_ID.existsIn(values)) - { - mId = -1L; - } - else - { - mId = _ID.getFrom(cursor); - } - mCursor = cursor; - mValues = values; - } - - - public CursorContentValuesInstanceAdapter(long id, Cursor cursor, ContentValues values) - { - mId = id; - mCursor = cursor; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - if (mValues == null) - { - return fieldAdapter.getFrom(mCursor); - } - return fieldAdapter.getFrom(mCursor, mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mCursor); - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - if (mValues == null || !fieldAdapter.isSetIn(mValues)) - { - return false; - } - Object oldValue = fieldAdapter.getFrom(mCursor); - Object newValue = fieldAdapter.getFrom(mValues); - - return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); - } - - - @Override - public boolean isWriteable() - { - return mValues != null; - } - - - @Override - public boolean hasUpdates() - { - return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); - } - - - @Override - public T getState(FieldAdapter stateFieldAdater) - { - return null; - } - - - @Override - public void setState(FieldAdapter stateFieldAdater, T value) - { - - } - - - @Override - public InstanceAdapter duplicate() - { - ContentValues newValues = new ContentValues(mValues); - - // copy all columns (except _ID) that are not in the values yet - for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) - { - String column = mCursor.getColumnName(i); - if (!newValues.containsKey(column) && !TaskContract.Instances._ID.equals(column)) - { - newValues.put(column, mCursor.getString(i)); - } - } - - return new ContentValuesInstanceAdapter(newValues); - } - - - @Override - public TaskAdapter taskAdapter() - { - // make sure we remove any instance fields - ContentValues values = new Reduced( - () -> new ContentValues(mValues), - (contentValues, column) -> { - contentValues.remove(column); - return contentValues; - }, - INSTANCE_COLUMN_NAMES).value(); - - // create a new cursor which doesn't contain the instance columns - String[] cursorColumns = new Collected<>( - ArrayList::new, - new Sieved<>(col -> !INSTANCE_COLUMN_NAMES.contains(col), new Seq<>(mCursor.getColumnNames()))) - .value().toArray(new String[0]); - MatrixCursor cursor = new MatrixCursor(cursorColumns); - cursor.addRow( - new Mapped<>( - column -> mCursor.getType(column) == Cursor.FIELD_TYPE_BLOB ? mCursor.getBlob(column) : mCursor.getString(column), - new Mapped<>( - mCursor::getColumnIndex, - new Seq<>(cursorColumns)))); - cursor.moveToFirst(); - return new CursorContentValuesTaskAdapter(valueOf(InstanceAdapter.TASK_ID), cursor, values); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java deleted file mode 100644 index 4bdffb5..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.provider.tasks.utils.ContainsValues; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * @author Marten Gajda - */ -public class CursorContentValuesListAdapter extends AbstractListAdapter -{ - private final long mId; - private final Cursor mCursor; - private final ContentValues mValues; - - - public CursorContentValuesListAdapter(long id, Cursor cursor, ContentValues values) - { - mId = id; - mCursor = cursor; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mCursor, mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mCursor); - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - if (mValues == null || !fieldAdapter.isSetIn(mValues)) - { - return false; - } - Object oldValue = fieldAdapter.getFrom(mCursor); - Object newValue = fieldAdapter.getFrom(mValues); - - return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); - } - - - @Override - public boolean isWriteable() - { - return true; - } - - - @Override - public boolean hasUpdates() - { - return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - return db.update(TaskDatabaseHelper.Tables.LISTS, mValues, TaskContract.TaskListColumns._ID + "=" + mId, null); - } - - - @Override - public ListAdapter duplicate() - { - ContentValues newValues = new ContentValues(mValues); - - // copy all columns (except _ID) that are not in the values yet - for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) - { - String column = mCursor.getColumnName(i); - if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) - { - newValues.put(column, mCursor.getString(i)); - } - } - - return new ContentValuesListAdapter(newValues); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java deleted file mode 100644 index 0ed20df..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.provider.tasks.utils.ContainsValues; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A {@link TaskAdapter} that adapts a {@link Cursor} and a {@link ContentValues} instance. All changes are written to the {@link ContentValues} and can be - * stored in the database with {@link #commit(SQLiteDatabase)}. - * - * @author Marten Gajda - */ -public class CursorContentValuesTaskAdapter extends AbstractTaskAdapter -{ - private final long mId; - private final Cursor mCursor; - private final ContentValues mValues; - - - public CursorContentValuesTaskAdapter(Cursor cursor, ContentValues values) - { - if (cursor == null && !_ID.existsIn(values)) - { - mId = -1L; - } - else - { - mId = _ID.getFrom(cursor); - } - mCursor = cursor; - mValues = values; - } - - - public CursorContentValuesTaskAdapter(long id, Cursor cursor, ContentValues values) - { - mId = id; - mCursor = cursor; - mValues = values; - } - - - @Override - public long id() - { - return mId; - } - - - @Override - public T valueOf(FieldAdapter fieldAdapter) - { - if (mValues == null) - { - return fieldAdapter.getFrom(mCursor); - } - return fieldAdapter.getFrom(mCursor, mValues); - } - - - @Override - public T oldValueOf(FieldAdapter fieldAdapter) - { - return fieldAdapter.getFrom(mCursor); - } - - - @Override - public boolean isUpdated(FieldAdapter fieldAdapter) - { - if (mValues == null || !fieldAdapter.isSetIn(mValues)) - { - return false; - } - Object oldValue = fieldAdapter.existsIn(mCursor) ? fieldAdapter.getFrom(mCursor) : null; - Object newValue = fieldAdapter.getFrom(mValues); - // we need to special case RRULE, because RecurrenceRule doesn't support `equals` - if (fieldAdapter != TaskAdapter.RRULE) - { - return oldValue == null && newValue != null || oldValue != null && !oldValue.equals(newValue); - } - else - { - // in case of RRULE we compare the String values. - return oldValue == null && newValue != null || oldValue != null && (newValue == null || !oldValue.toString().equals(newValue.toString())); - } - } - - - @Override - public boolean isWriteable() - { - return mValues != null; - } - - - @Override - public boolean hasUpdates() - { - return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); - } - - - @Override - public void set(FieldAdapter fieldAdapter, T value) throws IllegalStateException - { - fieldAdapter.setIn(mValues, value); - } - - - @Override - public void unset(FieldAdapter fieldAdapter) throws IllegalStateException - { - fieldAdapter.removeFrom(mValues); - } - - - @Override - public int commit(SQLiteDatabase db) - { - if (mValues.size() == 0) - { - return 0; - } - - return db.update(TaskDatabaseHelper.Tables.TASKS, mValues, TaskContract.TaskColumns._ID + "=" + mId, null); - } - - - @Override - public TaskAdapter duplicate() - { - ContentValues newValues = new ContentValues(mValues); - - // copy all columns (except _ID) that are not in the values yet - for (int i = 0, count = mCursor.getColumnCount(); i < count; ++i) - { - String column = mCursor.getColumnName(i); - if (!newValues.containsKey(column) && !TaskContract.Tasks._ID.equals(column)) - { - newValues.put(column, mCursor.getString(i)); - } - } - - return new ContentValuesTaskAdapter(newValues); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java deleted file mode 100644 index b3d6c7e..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/EntityAdapter.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; - -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; - - -/** - * Adapter to read values of a specific entity type from primitive data sets like {@link Cursor}s or {@link ContentValues}s. - * - * @author Marten Gajda - */ -public interface EntityAdapter -{ - /** - * Returns the row id of the entity or -1 if the entity has not been stored yet. - * - * @return The entity row id or -1. - */ - long id(); - - /** - * Returns the {@link Uri} of the entity using the given authority. - * - * @param authority - * The authority of this provider. - * - * @return A {@link Uri} or null if this entity has not been stored yet. - */ - Uri uri(String authority); - - /** - * Returns the value identified by the given {@link FieldAdapter}. - * - * @param fieldAdapter - * The {@link FieldAdapter} of the value to return. - * - * @return The value, maybe be null. - */ - T valueOf(FieldAdapter fieldAdapter); - - /** - * Returns the old value identified by the given {@link FieldAdapter}. This will be equal to the value returned by {@link #valueOf(FieldAdapter)} unless it - * has been overridden, in which case this returns the former value. - * - * @param fieldAdapter - * The {@link FieldAdapter} of the value to return. - * - * @return The value, maybe be null. - */ - T oldValueOf(FieldAdapter fieldAdapter); - - /** - * Returns whether the given field has been overridden or not. - * - * @param fieldAdapter - * The {@link FieldAdapter} of the field to check. - * - * @return true if the field has been overridden, false otherwise. - */ - boolean isUpdated(FieldAdapter fieldAdapter); - - /** - * Returns whether this adapter supports modifying values. - * - * @return true if the task values can be changed by this adapter, false otherwise. - */ - boolean isWriteable(); - - /** - * Returns whether any value has been modified. - * - * @return true if there are modified values, false otherwise. - */ - boolean hasUpdates(); - - /** - * Sets a value of the adapted entity. The value is identified by a {@link FieldAdapter}. - * - * @param fieldAdapter - * The {@link FieldAdapter} of the value to set. - * @param value - * The new value. - */ - void set(FieldAdapter fieldAdapter, T value); - - /** - * Remove a value from the change set. In effect the respective field will keep it's old value. - * - * @param fieldAdapter - * The {@link FieldAdapter} of the field to un-set. - */ - void unset(FieldAdapter fieldAdapter); - - /** - * Commit all changes to the database. - * - * @param db - * A writable database. - * - * @return The number of entries affected. This may be 0 if no fields have been changed. - */ - int commit(SQLiteDatabase db); - - /** - * Return the value of a temporary state field. The state of an entity is not committed to the database, it's only bound to the instances of this - * {@link EntityAdapter} and will be lost once it gets garbage collected. - * - * @param stateFieldAdater - * The {@link FieldAdapter} of a state field. - * - * @return The value of the state field. - */ - T getState(FieldAdapter stateFieldAdater); - - /** - * Set the value of a state field. This value is not stored in the database. Instead it only exists as long as this {@link EntityAdapter} exists. - * - * @param stateFieldAdater - * The {@link FieldAdapter} of the state field to set. - * @param value - * The new state value. - */ - void setState(FieldAdapter stateFieldAdater, T value); - - /*** - * Creates a {@link EntityAdapter} for a new entity initialized with the values of this entity (except for _ID). - * - * @return A new {@link EntityAdapter} having the same values. - */ - EntityAdapter duplicate(); -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java deleted file mode 100644 index b2c9de3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/InstanceAdapter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Instances; -import org.dmfs.tasks.contract.TaskContract.Tasks; - -import java.util.Collection; -import java.util.HashSet; - -import static java.util.Arrays.asList; - - -/** - * Adapter to read instance values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. - * - * @author Marten Gajda - */ -public interface InstanceAdapter extends EntityAdapter -{ - - Collection INSTANCE_COLUMN_NAMES = new HashSet<>(asList( - TaskContract.Instances.INSTANCE_START, - TaskContract.Instances.INSTANCE_START_SORTING, - TaskContract.Instances.INSTANCE_DUE, - TaskContract.Instances.INSTANCE_DUE_SORTING, - TaskContract.Instances.INSTANCE_DURATION, - TaskContract.Instances.INSTANCE_ORIGINAL_TIME, - TaskContract.Instances.TASK_ID, - TaskContract.Instances.DISTANCE_FROM_CURRENT, - "_id:1")); - - /** - * Adapter for the row id of a task instance. - */ - LongFieldAdapter _ID = new LongFieldAdapter(Instances._ID); - - /** - * Adapter for the due date of a task instance. - */ - DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter<>(Instances.INSTANCE_DUE, Tasks.TZ, Tasks.IS_ALLDAY); - - /** - * Adapter for the start date of a task instance. - */ - DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter<>(Instances.INSTANCE_START, Tasks.TZ, Tasks.IS_ALLDAY); - - /** - * Adapter for the start sorting of a task instance. - */ - LongFieldAdapter INSTANCE_START_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_START_SORTING); - - /** - * Adapter for the due sorting of a task instance. - */ - LongFieldAdapter INSTANCE_DUE_SORTING = new LongFieldAdapter<>(Instances.INSTANCE_DUE_SORTING); - - /** - * Adapter for the original time of a task instance. - */ - DateTimeFieldAdapter INSTANCE_ORIGINAL_TIME = new DateTimeFieldAdapter<>(Instances.INSTANCE_ORIGINAL_TIME, Tasks.TZ, Tasks.IS_ALLDAY); - - /** - * Adapter for the distance of a task instance from the current instance. - */ - IntegerFieldAdapter DISTANCE_FROM_CURRENT = new IntegerFieldAdapter<>(Instances.DISTANCE_FROM_CURRENT); - - /** - * Adapter for the title of a task instance. - */ - StringFieldAdapter TITLE = new StringFieldAdapter<>(Tasks.TITLE); - - /** - * Adapter for the row id of the task. - */ - LongFieldAdapter TASK_ID = new LongFieldAdapter(Instances.TASK_ID); - - @Override - InstanceAdapter duplicate(); - - /** - * Returns a {@link TaskAdapter} for the task component of the instanced view. - * - * @return - */ - TaskAdapter taskAdapter(); -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java deleted file mode 100644 index d72a673..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/ListAdapter.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; -import org.dmfs.tasks.contract.TaskContract.TaskLists; - - -/** - * Adapter to read list values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. - * - * @author Marten Gajda - */ -public interface ListAdapter extends EntityAdapter -{ - /** - * Adapter for the row id of a task list. - */ - LongFieldAdapter _ID = new LongFieldAdapter(TaskLists._ID); - - /** - * Adapter for the _sync_id of a list. - */ - StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskLists._SYNC_ID); - - /** - * Adapter for the sync version of a list. - */ - StringFieldAdapter SYNC_VERSION = new StringFieldAdapter(TaskLists.SYNC_VERSION); - - /** - * Adapter for the account name of a list. - */ - StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(TaskLists.ACCOUNT_NAME); - - /** - * Adapter for the account type of a list. - */ - StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(TaskLists.ACCOUNT_TYPE); - - /** - * Adapter for the owner of a list. - */ - StringFieldAdapter OWNER = new StringFieldAdapter(TaskLists.OWNER); - - /** - * Adapter for the name of a list. - */ - StringFieldAdapter LIST_NAME = new StringFieldAdapter(TaskLists.LIST_NAME); - - /** - * Adapter for the color of a list. - */ - IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskLists.LIST_COLOR); - - /*** - * Creates a {@link ListAdapter} for a new task initialized with the values of this task (except for _ID). - * - * @return A new task having the same values. - */ - @Override - ListAdapter duplicate(); -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java deleted file mode 100644 index 4668cce..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/TaskAdapter.java +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.provider.tasks.model.adapters.BinaryFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.DateTimeFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.DateTimeIterableFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.DurationFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.RRuleFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.StringFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.UrlFieldAdapter; -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.Instances; -import org.dmfs.tasks.contract.TaskContract.Tasks; - - -/** - * Adapter to read task values from primitive data sets like {@link Cursor}s or {@link ContentValues}s. - * - * @author Marten Gajda - */ -public interface TaskAdapter extends EntityAdapter -{ - /** - * Adapter for the row id of a task. - */ - LongFieldAdapter _ID = new LongFieldAdapter(Tasks._ID); - - /** - * Adapter for the version of a task. - */ - LongFieldAdapter VERSION = new LongFieldAdapter<>(Tasks.VERSION); - - /** - * Adapter for the task row id of as instance. - */ - LongFieldAdapter INSTANCE_TASK_ID = new LongFieldAdapter(Instances.TASK_ID); - - /** - * Adapter for the row id of the list of a task. - */ - LongFieldAdapter LIST_ID = new LongFieldAdapter(Tasks.LIST_ID); - - /** - * Adapter for the owner of the list of a task. - */ - StringFieldAdapter LIST_OWNER = new StringFieldAdapter(Tasks.LIST_OWNER); - - /** - * Adapter for the row id of original instance of a task. - */ - LongFieldAdapter ORIGINAL_INSTANCE_ID = new LongFieldAdapter(Tasks.ORIGINAL_INSTANCE_ID); - - /** - * Adapter for the sync_id of original instance of a task. - */ - StringFieldAdapter ORIGINAL_INSTANCE_SYNC_ID = new StringFieldAdapter(Tasks.ORIGINAL_INSTANCE_SYNC_ID); - - /** - * Adapter for the original instance all day flag of a task. - */ - BooleanFieldAdapter ORIGINAL_INSTANCE_ALLDAY = new BooleanFieldAdapter(Tasks.ORIGINAL_INSTANCE_ALLDAY); - - /** - * Adapter for the parent_id of a task. - */ - LongFieldAdapter PARENT_ID = new LongFieldAdapter(Tasks.PARENT_ID); - - /** - * Adapter for the all day flag of a task. - */ - BooleanFieldAdapter IS_ALLDAY = new BooleanFieldAdapter(Tasks.IS_ALLDAY); - - /** - * Adapter for the percent complete value of a task. - */ - IntegerFieldAdapter PERCENT_COMPLETE = new IntegerFieldAdapter(Tasks.PERCENT_COMPLETE); - - /** - * Adapter for the status of a task. - */ - IntegerFieldAdapter STATUS = new IntegerFieldAdapter(Tasks.STATUS); - - /** - * Adapter for the priority value of a task. - */ - IntegerFieldAdapter PRIORITY = new IntegerFieldAdapter(Tasks.PRIORITY); - - /** - * Adapter for the classification value of a task. - */ - IntegerFieldAdapter CLASSIFICATION = new IntegerFieldAdapter(Tasks.CLASSIFICATION); - - /** - * Adapter for the list name of a task. - */ - StringFieldAdapter LIST_NAME = new StringFieldAdapter(Tasks.LIST_NAME); - - /** - * Adapter for the account name of a task. - */ - StringFieldAdapter ACCOUNT_NAME = new StringFieldAdapter(Tasks.ACCOUNT_NAME); - - /** - * Adapter for the account type of a task. - */ - StringFieldAdapter ACCOUNT_TYPE = new StringFieldAdapter(Tasks.ACCOUNT_TYPE); - - /** - * Adapter for the title of a task. - */ - StringFieldAdapter TITLE = new StringFieldAdapter(Tasks.TITLE); - - /** - * Adapter for the location of a task. - */ - StringFieldAdapter LOCATION = new StringFieldAdapter(Tasks.LOCATION); - - /** - * Adapter for the description of a task. - */ - StringFieldAdapter DESCRIPTION = new StringFieldAdapter(Tasks.DESCRIPTION); - - /** - * Adapter for the start date of a task. - */ - DateTimeFieldAdapter DTSTART = new DateTimeFieldAdapter(Tasks.DTSTART, Tasks.TZ, Tasks.IS_ALLDAY); - - /** - * Adapter for the original date of a task. - */ - DateTimeFieldAdapter ORIGINAL_INSTANCE_TIME = new DateTimeFieldAdapter(Tasks.ORIGINAL_INSTANCE_TIME, Tasks.TZ, - Tasks.ORIGINAL_INSTANCE_ALLDAY); - - /** - * Adapter for the raw start date timestamp of a task. - */ - LongFieldAdapter DTSTART_RAW = new LongFieldAdapter(Tasks.DTSTART); - - /** - * Adapter for the due date of a task. - */ - DateTimeFieldAdapter DUE = new DateTimeFieldAdapter(Tasks.DUE, Tasks.TZ, Tasks.IS_ALLDAY); - - /** - * Adapter for the raw due date timestamp of a task. - */ - LongFieldAdapter DUE_RAW = new LongFieldAdapter(Tasks.DUE); - - /** - * Adapter for the start date of a task. - */ - DurationFieldAdapter DURATION = new DurationFieldAdapter(Tasks.DURATION); - - /** - * Adapter for the dirty flag of a task. - */ - BooleanFieldAdapter _DIRTY = new BooleanFieldAdapter(Tasks._DIRTY); - - /** - * Adapter for the deleted flag of a task. - */ - BooleanFieldAdapter _DELETED = new BooleanFieldAdapter(Tasks._DELETED); - - /** - * Adapter for the completed date of a task. - */ - DateTimeFieldAdapter COMPLETED = new DateTimeFieldAdapter(Tasks.COMPLETED, null, null); - - /** - * Adapter for the created date of a task. - */ - DateTimeFieldAdapter CREATED = new DateTimeFieldAdapter(Tasks.CREATED, null, null); - - /** - * Adapter for the last modified date of a task. - */ - DateTimeFieldAdapter LAST_MODIFIED = new DateTimeFieldAdapter(Tasks.LAST_MODIFIED, null, null); - - /** - * Adapter for the URL of a task. - */ - UrlFieldAdapter URL = new UrlFieldAdapter(TaskContract.Tasks.URL); - - /** - * Adapter for the UID of a task. - */ - StringFieldAdapter _UID = new StringFieldAdapter(TaskContract.Tasks._UID); - - /** - * Adapter for the raw time zone of a task. - */ - StringFieldAdapter TIMEZONE_RAW = new StringFieldAdapter(TaskContract.Tasks.TZ); - - /** - * Adapter for the Color of the task. - */ - IntegerFieldAdapter LIST_COLOR = new IntegerFieldAdapter(TaskContract.Tasks.LIST_COLOR); - - /** - * Adapter for the access level of the task list. - */ - IntegerFieldAdapter LIST_ACCESS_LEVEL = new IntegerFieldAdapter(TaskContract.Tasks.LIST_ACCESS_LEVEL); - - /** - * Adapter for the visibility setting of the task list. - */ - BooleanFieldAdapter LIST_VISIBLE = new BooleanFieldAdapter(TaskContract.Tasks.VISIBLE); - - /** - * Adpater for the ID of the task. - */ - IntegerFieldAdapter TASK_ID = new IntegerFieldAdapter(TaskContract.Tasks._ID); - - /** - * Adapter for the IS_CLOSED flag of a task. - */ - BooleanFieldAdapter IS_CLOSED = new BooleanFieldAdapter(TaskContract.Tasks.IS_CLOSED); - - /** - * Adapter for the IS_NEW flag of a task. - */ - BooleanFieldAdapter IS_NEW = new BooleanFieldAdapter(TaskContract.Tasks.IS_NEW); - - /** - * Adapter for the PINNED flag of a task. - */ - BooleanFieldAdapter PINNED = new BooleanFieldAdapter(TaskContract.Tasks.PINNED); - - /** - * Adapter for the HAS_ALARMS flag of a task. - */ - BooleanFieldAdapter HAS_ALARMS = new BooleanFieldAdapter(TaskContract.Tasks.HAS_ALARMS); - - /** - * Adapter for the HAS_PROPERTIES flag of a task. - */ - BooleanFieldAdapter HAS_PROPERTIES = new BooleanFieldAdapter(TaskContract.Tasks.HAS_PROPERTIES); - - /** - * Adapter for the RRULE of a task. - */ - RRuleFieldAdapter RRULE = new RRuleFieldAdapter(TaskContract.Tasks.RRULE); - - /** - * Adapter for the RDATE of a task. - */ - DateTimeIterableFieldAdapter RDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.RDATE, - TaskContract.Tasks.TZ); - - /** - * Adapter for the EXDATE of a task. - */ - DateTimeIterableFieldAdapter EXDATE = new DateTimeIterableFieldAdapter(TaskContract.Tasks.EXDATE, - TaskContract.Tasks.TZ); - - /** - * Adapter for the SYNC1 field of a task. - */ - BinaryFieldAdapter SYNC1 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC1); - - /** - * Adapter for the SYNC2 field of a task. - */ - BinaryFieldAdapter SYNC2 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC2); - - /** - * Adapter for the SYNC3 field of a task. - */ - BinaryFieldAdapter SYNC3 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC3); - - /** - * Adapter for the SYNC4 field of a task. - */ - BinaryFieldAdapter SYNC4 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC4); - - /** - * Adapter for the SYNC5 field of a task. - */ - BinaryFieldAdapter SYNC5 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC5); - - /** - * Adapter for the SYNC6 field of a task. - */ - BinaryFieldAdapter SYNC6 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC6); - - /** - * Adapter for the SYNC7 field of a task. - */ - BinaryFieldAdapter SYNC7 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC7); - - /** - * Adapter for the SYNC8 field of a task. - */ - BinaryFieldAdapter SYNC8 = new BinaryFieldAdapter(TaskContract.Tasks.SYNC8); - - /** - * Adapter for the SYNC_VERSION field of a task. - */ - BinaryFieldAdapter SYNC_VERSION = new BinaryFieldAdapter(TaskContract.Tasks.SYNC_VERSION); - - /** - * Adapter for the SYNC_ID field of a task. - */ - StringFieldAdapter SYNC_ID = new StringFieldAdapter(TaskContract.Tasks._SYNC_ID); - - /** - * Adapter for the due date of a task instance. - */ - DateTimeFieldAdapter INSTANCE_DUE = new DateTimeFieldAdapter(Instances.INSTANCE_DUE, Tasks.TZ, - Tasks.IS_ALLDAY); - - /** - * Adapter for the start date of a task instance. - */ - DateTimeFieldAdapter INSTANCE_START = new DateTimeFieldAdapter(Instances.INSTANCE_START, Tasks.TZ, - Tasks.IS_ALLDAY); - - /** - * Returns whether the adapted task is recurring. - * - * @return true if the task is recurring, false otherwise. - */ - boolean isRecurring(); - - /** - * Returns whether any value that's relevant for recurrence has been modified thought this adapter. This returns true if any of - * {@link TaskContract.TaskColumns#DTSTART}, {@link TaskContract.TaskColumns#DUE},{@link TaskContract.TaskColumns#DURATION}, - * {@link TaskContract.TaskColumns#RRULE}, {@link TaskContract.TaskColumns#RDATE} or {@link TaskContract.TaskColumns#EXDATE} has been modified. - * - * @return true if the recurrence set has changed, false otherwise. - */ - boolean recurrenceUpdated(); - - /*** - * Creates a {@link TaskAdapter} for a new task initialized with the values of this task (except for _ID). - * - * @return A new task having the same values. - */ - @Override - TaskAdapter duplicate(); -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java deleted file mode 100644 index 0d6c24d..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BinaryFieldAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a binary value from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class BinaryFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link BinaryFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public BinaryFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public byte[] getFrom(ContentValues values) - { - return values.getAsByteArray(mFieldName); - } - - - @Override - public byte[] getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return cursor.isNull(columnIdx) ? null : cursor.getBlob(columnIdx); - } - - - @Override - public void setIn(ContentValues values, byte[] value) - { - if (value != null) - { - values.put(mFieldName, value); - } - else - { - values.putNull(mFieldName); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java deleted file mode 100644 index ef7e948..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/BooleanFieldAdapter.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a {@link Boolean} value from a {@link Cursor} or {@link ContentValues}. - *

- * Implementation detail: - *

- * The values are loaded and stored as 0 (for false) and 1 (for true). - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class BooleanFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link BooleanFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public BooleanFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public Boolean getFrom(ContentValues values) - { - Integer value = values.getAsInteger(mFieldName); - - return value != null && value > 0; - } - - - @Override - public Boolean getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return !cursor.isNull(columnIdx) && cursor.getInt(columnIdx) > 0; - } - - - @Override - public void setIn(ContentValues values, Boolean value) - { - values.put(mFieldName, value ? 1 : 0); - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java deleted file mode 100644 index 5c9159b..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeFieldAdapter.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.rfc5545.DateTime; - -import java.util.TimeZone; - - -/** - * Knows how to load and store {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. - *

- * {@link DateTime} values are stored as three separate values: - *

    - *
  • a timestamp in milliseconds since the epoch
  • - *
  • a time zone
  • - *
  • an allday flag
  • - *
- *

- * This adapter combines those three fields to a {@link DateTime} value. If the time zone field is null the time zone is always set to UTC. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class DateTimeFieldAdapter extends SimpleFieldAdapter -{ - private final String mTimestampField; - private final String mTzField; - private final String mAllDayField; - private final boolean mAllDayDefault; - - - /** - * Constructor for a new {@link DateTimeFieldAdapter}. - * - * @param timestampField - * The name of the field that holds the time stamp in milliseconds. - * @param tzField - * The name of the field that holds the time zone (as Olson ID). If the field name is null the time is always set to UTC. - * @param alldayField - * The name of the field that indicated that this time is a date not a date-time. If this fieldName is null all loaded values are - * non-allday. - */ - public DateTimeFieldAdapter(String timestampField, String tzField, String alldayField) - { - if (timestampField == null) - { - throw new IllegalArgumentException("timestampField must not be null"); - } - mTimestampField = timestampField; - mTzField = tzField; - mAllDayField = alldayField; - mAllDayDefault = false; - } - - - @Override - String fieldName() - { - return mTimestampField; - } - - - @Override - public DateTime getFrom(ContentValues values) - { - Long timestamp = values.getAsLong(mTimestampField); - if (timestamp == null) - { - // if the time stamp is null we return null - return null; - } - String timezone = mTzField == null ? null : values.getAsString(mTzField); - DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); - - // cache mAlldayField locally - String allDayField = mAllDayField; - - // set the allday flag appropriately - Integer allDayInt = allDayField == null ? null : values.getAsInteger(allDayField); - - if ((allDayInt != null && allDayInt != 0) || (allDayField == null && mAllDayDefault)) - { - value = value.toAllDay(); - } - - return value; - } - - - @Override - public DateTime getFrom(Cursor cursor) - { - int tsIdx = cursor.getColumnIndex(mTimestampField); - int tzIdx = mTzField == null ? -1 : cursor.getColumnIndex(mTzField); - int adIdx = mAllDayField == null ? -1 : cursor.getColumnIndex(mAllDayField); - - if (tsIdx < 0 || (mTzField != null && tzIdx < 0) || (mAllDayField != null && adIdx < 0)) - { - throw new IllegalArgumentException("At least one column is missing in cursor."); - } - - if (cursor.isNull(tsIdx)) - { - // if the time stamp is null we return null - return null; - } - - Long timestamp = cursor.getLong(tsIdx); - - String timezone = mTzField == null ? null : cursor.getString(tzIdx); - DateTime value = new DateTime(timezone == null ? null : TimeZone.getTimeZone(timezone), timestamp); - - // set the allday flag appropriately - Integer allDayInt = adIdx < 0 ? null : cursor.getInt(adIdx); - - if ((allDayInt != null && allDayInt != 0) || (mAllDayField == null && mAllDayDefault)) - { - value = value.toAllDay(); - } - return value; - } - - - @Override - public DateTime getFrom(Cursor cursor, ContentValues values) - { - int tsIdx; - int tzIdx; - int adIdx; - long timestamp; - String timeZoneId = null; - Integer allDay = 0; - - if (values != null && values.containsKey(mTimestampField)) - { - if (values.getAsLong(mTimestampField) == null) - { - // if the time stamp is null we return null - return null; - } - timestamp = values.getAsLong(mTimestampField); - } - else if (cursor != null && (tsIdx = cursor.getColumnIndex(mTimestampField)) >= 0) - { - if (cursor.isNull(tsIdx)) - { - // if the time stamp is null we return null - return null; - } - timestamp = cursor.getLong(tsIdx); - } - else - { - throw new IllegalArgumentException("Missing timestamp column."); - } - - if (mTzField != null) - { - if (values != null && values.containsKey(mTzField)) - { - timeZoneId = values.getAsString(mTzField); - } - else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTzField)) >= 0) - { - timeZoneId = cursor.getString(tzIdx); - } - else - { - throw new IllegalArgumentException("Missing timezone column."); - } - } - - if (mAllDayField != null) - { - if (values != null && values.containsKey(mAllDayField)) - { - allDay = values.getAsInteger(mAllDayField); - } - else if (cursor != null && (adIdx = cursor.getColumnIndex(mAllDayField)) >= 0) - { - allDay = cursor.getInt(adIdx); - } - else - { - throw new IllegalArgumentException("Missing timezone column."); - } - } - - DateTime value = new DateTime(timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId), timestamp); - - if (allDay != 0) - { - value = value.toAllDay(); - } - return value; - } - - - @Override - public void setIn(ContentValues values, DateTime value) - { - if (value != null) - { - // just store all three parts separately - values.put(mTimestampField, value.getTimestamp()); - - if (mTzField != null) - { - TimeZone timezone = value.getTimeZone(); - values.put(mTzField, timezone == null ? null : timezone.getID()); - } - if (mAllDayField != null) - { - values.put(mAllDayField, value.isAllDay() ? 1 : 0); - } - } - else - { - // write timestamp only, other fields may still use allday and timezone - values.put(mTimestampField, (Long) null); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java deleted file mode 100644 index 3f1ebbc..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapter.java +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; -import android.text.TextUtils; - -import org.dmfs.iterables.EmptyIterable; -import org.dmfs.iterables.Split; -import org.dmfs.iterables.decorators.DelegatingIterable; -import org.dmfs.jems.iterable.decorators.Mapped; -import org.dmfs.rfc5545.DateTime; - -import java.util.TimeZone; - - -/** - * Knows how to load and store {@link Iterable}s of {@link DateTime} values from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class DateTimeIterableFieldAdapter extends SimpleFieldAdapter, EntityType> -{ - private final String mDateTimeListFieldName; - private final String mTimeZoneFieldName; - - - /** - * Constructor for a new {@link DateTimeIterableFieldAdapter}. - * - * @param datetimeListFieldName - * The name of the field that holds the {@link DateTime} list. - * @param timezoneFieldName - * The name of the field that holds the time zone name. - */ - public DateTimeIterableFieldAdapter(String datetimeListFieldName, String timezoneFieldName) - { - if (datetimeListFieldName == null) - { - throw new IllegalArgumentException("datetimeListFieldName must not be null"); - } - mDateTimeListFieldName = datetimeListFieldName; - mTimeZoneFieldName = timezoneFieldName; - } - - - @Override - String fieldName() - { - return mDateTimeListFieldName; - } - - - @Override - public Iterable getFrom(ContentValues values) - { - String datetimeList = values.getAsString(mDateTimeListFieldName); - if (datetimeList == null) - { - // no list, return an empty Iterable - return EmptyIterable.instance(); - } - - // create a new TimeZone for the given time zone string - String timezoneString = mTimeZoneFieldName == null ? null : values.getAsString(mTimeZoneFieldName); - TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); - - return new DateTimeList(timeZone, datetimeList); - } - - - @Override - public Iterable getFrom(Cursor cursor) - { - int tdLIdx = cursor.getColumnIndex(mDateTimeListFieldName); - int tzIdx = mTimeZoneFieldName == null ? -1 : cursor.getColumnIndex(mTimeZoneFieldName); - - if (tdLIdx < 0 || (mTimeZoneFieldName != null && tzIdx < 0)) - { - throw new IllegalArgumentException("At least one column is missing in cursor."); - } - - if (cursor.isNull(tdLIdx)) - { - // if the time stamp list is null we return an empty Iterable - return EmptyIterable.instance(); - } - - String datetimeList = cursor.getString(tdLIdx); - - // create a new TimeZone for the given time zone string - String timezoneString = mTimeZoneFieldName == null ? null : cursor.getString(tzIdx); - TimeZone timeZone = timezoneString == null ? null : TimeZone.getTimeZone(timezoneString); - - return new DateTimeList(timeZone, datetimeList); - } - - - @Override - public Iterable getFrom(Cursor cursor, ContentValues values) - { - int tsIdx; - int tzIdx; - String datetimeList; - String timeZoneId = null; - - if (values != null && values.containsKey(mDateTimeListFieldName)) - { - if (values.getAsString(mDateTimeListFieldName) == null) - { - // the date times are null, so we return null - return EmptyIterable.instance(); - } - datetimeList = values.getAsString(mDateTimeListFieldName); - } - else if (cursor != null && (tsIdx = cursor.getColumnIndex(mDateTimeListFieldName)) >= 0) - { - if (cursor.isNull(tsIdx)) - { - // the date times are null, so we return an empty Iterable. - return EmptyIterable.instance(); - } - datetimeList = cursor.getString(tsIdx); - } - else - { - throw new IllegalArgumentException("Missing date time list column."); - } - - if (mTimeZoneFieldName != null) - { - if (values != null && values.containsKey(mTimeZoneFieldName)) - { - timeZoneId = values.getAsString(mTimeZoneFieldName); - } - else if (cursor != null && (tzIdx = cursor.getColumnIndex(mTimeZoneFieldName)) >= 0) - { - timeZoneId = cursor.getString(tzIdx); - } - else - { - throw new IllegalArgumentException("Missing timezone column."); - } - } - - // create a new TimeZone for the given time zone string - TimeZone timeZone = timeZoneId == null ? null : TimeZone.getTimeZone(timeZoneId); - - return new DateTimeList(timeZone, datetimeList); - } - - - @Override - public void setIn(ContentValues values, Iterable value) - { - if (value != null) - { - String stringValue = TextUtils.join(",", new Mapped<>(dt -> dt.isFloating() ? dt : dt.shiftTimeZone(DateTime.UTC), value)); - values.put(mDateTimeListFieldName, stringValue.isEmpty() ? null : stringValue); - } - else - { - values.put(mDateTimeListFieldName, (String) null); - } - } - - - private final class DateTimeList extends DelegatingIterable - { - - public DateTimeList(TimeZone timeZone, String dateTimeList) - { - super(new Mapped<>( - datetime -> !datetime.isFloating() && timeZone != null ? datetime.shiftTimeZone(timeZone) : datetime, - new Mapped( - charSequence -> DateTime.parse(timeZone, charSequence.toString()), - new Split(dateTimeList, ',')))); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java deleted file mode 100644 index 5a5f8eb..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/DurationFieldAdapter.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.rfc5545.Duration; - - -/** - * Knows how to load and store {@link Duration} values from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class DurationFieldAdapter extends SimpleFieldAdapter -{ - - private final String mFieldName; - - - /** - * Constructor for a new {@link DurationFieldAdapter}. - * - * @param urlField - * The field name that holds the {@link Duration}. - */ - public DurationFieldAdapter(String urlField) - { - if (urlField == null) - { - throw new IllegalArgumentException("urlField must not be null"); - } - mFieldName = urlField; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public Duration getFrom(ContentValues values) - { - String rawValue = values.getAsString(mFieldName); - if (rawValue == null) - { - return null; - } - - return Duration.parse(rawValue); - } - - - @Override - public Duration getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - - if (cursor.isNull(columnIdx)) - { - return null; - } - - return Duration.parse(cursor.getString(columnIdx)); - } - - - @Override - public void setIn(ContentValues values, Duration value) - { - if (value != null) - { - values.put(mFieldName, value.toString()); - } - else - { - values.putNull(mFieldName); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java deleted file mode 100644 index e9fe287..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FieldAdapter.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a specific field from or to {@link ContentValues} or from {@link Cursor}s. - * - * @param - * The type of the value this adapter stores. - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public interface FieldAdapter -{ - - /** - * Check if a value is present and non-null in the given {@link ContentValues}. - * - * @param values - * The {@link ContentValues} to check. - * - * @return - */ - boolean existsIn(ContentValues values); - - /** - * Check if a value is present (may be null) in the given {@link ContentValues}. - * - * @param values - * The {@link ContentValues} to check. - * - * @return - */ - boolean isSetIn(ContentValues values); - - /** - * Get the value from the given {@link ContentValues} - * - * @param values - * The {@link ContentValues} that contain the value to return. - * - * @return The value. - */ - FieldType getFrom(ContentValues values); - - /** - * Check if a value is present and non-null in the given {@link Cursor}. - * - * @param cursor - * The {@link Cursor} that contains the value to check. - * - * @return - */ - boolean existsIn(Cursor cursor); - - /** - * Get the value from the given {@link Cursor} - * - * @param cursor - * The {@link Cursor} that contain the value to return. - * - * @return The value. - */ - FieldType getFrom(Cursor cursor); - - /** - * Check if a value is present and non-null in the given {@link Cursor} or {@link ContentValues}. - * - * @param cursor - * The {@link Cursor} that contains the value to check. - * @param values - * The {@link ContentValues} that contains the value to check. - * - * @return - */ - boolean existsIn(Cursor cursor, ContentValues values); - - /** - * Get the value from the given {@link Cursor} or {@link ContentValues}, with the {@link ContentValues} taking precedence over the cursor values. - * - * @param cursor - * The {@link Cursor} that contains the value to return. - * @param values - * The {@link ContentValues} that contains the value to return. - * - * @return The value. - */ - FieldType getFrom(Cursor cursor, ContentValues values); - - /** - * Set a value in the given {@link ContentValues}. - * - * @param values - * The {@link ContentValues} to store the new value in. - * @param value - * The new value to store. - */ - void setIn(ContentValues values, FieldType value); - - /** - * Remove a value from the given {@link ContentValues}. - * - * @param values - * The {@link ContentValues} from which to remove the value. - */ - void removeFrom(ContentValues values); - - /** - * Copy the value from a {@link Cursor} to the given {@link ContentValues}. - * - * @param source - * The {@link Cursor} that contains the value to copy. - * @param dest - * The {@link ContentValues} to receive the value. - */ - void copyValue(Cursor source, ContentValues dest); - - /** - * Copy the value from {@link ContentValues} to another {@link ContentValues} object. - * - * @param source - * The {@link ContentValues} that contains the value to copy. - * @param dest - * The {@link ContentValues} to receive the value. - */ - void copyValue(ContentValues source, ContentValues dest); - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java deleted file mode 100644 index 28b8a01..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/FloatFieldAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a {@link Float} value from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class FloatFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link FloatFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public FloatFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public Float getFrom(ContentValues values) - { - return values.getAsFloat(mFieldName); - } - - - @Override - public Float getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return cursor.isNull(columnIdx) ? null : cursor.getFloat(columnIdx); - } - - - @Override - public void setIn(ContentValues values, Float value) - { - if (value != null) - { - values.put(mFieldName, value); - } - else - { - values.putNull(mFieldName); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java deleted file mode 100644 index 933c5e8..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/IntegerFieldAdapter.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store an {@link Integer} from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class IntegerFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link IntegerFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public IntegerFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public Integer getFrom(ContentValues values) - { - // return the value as Integer - return values.getAsInteger(mFieldName); - } - - - @Override - public Integer getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return cursor.isNull(columnIdx) ? null : cursor.getInt(columnIdx); - } - - - @Override - public void setIn(ContentValues values, Integer value) - { - if (value != null) - { - values.put(mFieldName, value); - } - else - { - values.putNull(mFieldName); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java deleted file mode 100644 index 517ca23..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/LongFieldAdapter.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a {@link Long} value from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class LongFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link LongFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public LongFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public Long getFrom(ContentValues values) - { - return values.getAsLong(mFieldName); - } - - - @Override - public Long getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return cursor.isNull(columnIdx) ? null : cursor.getLong(columnIdx); - } - - - @Override - public void setIn(ContentValues values, Long value) - { - if (value != null) - { - values.put(mFieldName, value); - } - else - { - values.putNull(mFieldName); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java deleted file mode 100644 index 201b075..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/RRuleFieldAdapter.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; -import org.dmfs.rfc5545.recur.RecurrenceRule; - - -/** - * Knows how to load and store a {@link RecurrenceRule} from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class RRuleFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link RRuleFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public RRuleFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public RecurrenceRule getFrom(ContentValues values) - { - String rrule = values.getAsString(mFieldName); - if (rrule == null) - { - return null; - } - try - { - return new RecurrenceRule(rrule); - } - catch (InvalidRecurrenceRuleException e) - { - throw new IllegalArgumentException("can not parse RRULE '" + rrule + "'", e); - } - } - - - @Override - public RecurrenceRule getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - if (cursor.isNull(columnIdx)) - { - return null; - } - - try - { - return new RecurrenceRule(cursor.getString(columnIdx)); - } - catch (InvalidRecurrenceRuleException e) - { - throw new IllegalArgumentException("can not parse RRULE '" + cursor.getString(columnIdx) + "'", e); - } - } - - - @Override - public void setIn(ContentValues values, RecurrenceRule value) - { - if (value != null) - { - values.put(mFieldName, value.toString()); - } - else - { - values.putNull(mFieldName); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java deleted file mode 100644 index 2752ba9..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/SimpleFieldAdapter.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * An abstract {@link FieldAdapter} that implements a couple of methods as used by most simple FieldAdapters. - * - * @param - * The Type of the field this adapter handles. - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public abstract class SimpleFieldAdapter implements FieldAdapter -{ - - /** - * Returns the sole field name of this adapter. - * - * @return - */ - abstract String fieldName(); - - - @Override - public boolean existsIn(ContentValues values) - { - return values.get(fieldName()) != null; - } - - - @Override - public boolean isSetIn(ContentValues values) - { - return values.containsKey(fieldName()); - } - - - @Override - public boolean existsIn(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(fieldName()); - return columnIdx >= 0 && !cursor.isNull(columnIdx); - } - - - @Override - public FieldType getFrom(Cursor cursor, ContentValues values) - { - return values.containsKey(fieldName()) ? getFrom(values) : getFrom(cursor); - } - - - @Override - public boolean existsIn(Cursor cursor, ContentValues values) - { - return existsIn(values) || existsIn(cursor); - } - - - @Override - public void removeFrom(ContentValues values) - { - values.remove(fieldName()); - } - - - @Override - public void copyValue(Cursor cursor, ContentValues values) - { - setIn(values, getFrom(cursor)); - } - - - @Override - public void copyValue(ContentValues oldValues, ContentValues newValues) - { - setIn(newValues, getFrom(oldValues)); - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java deleted file mode 100644 index 4c5311a..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/StringFieldAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - - -/** - * Knows how to load and store a {@link String} value from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class StringFieldAdapter extends SimpleFieldAdapter -{ - - /** - * The field name this adapter uses to store the values. - */ - private final String mFieldName; - - - /** - * Constructor for a new {@link StringFieldAdapter}. - * - * @param fieldName - * The name of the field to use when loading or storing the value. - */ - public StringFieldAdapter(String fieldName) - { - if (fieldName == null) - { - throw new IllegalArgumentException("fieldName must not be null"); - } - mFieldName = fieldName; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public String getFrom(ContentValues values) - { - // return the value as String - return values.getAsString(mFieldName); - } - - - @Override - public String getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - return cursor.getString(columnIdx); - } - - - @Override - public void setIn(ContentValues values, String value) - { - if (value != null) - { - values.put(mFieldName, value); - } - else - { - values.putNull(mFieldName); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java b/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java deleted file mode 100644 index 53496b8..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/model/adapters/UrlFieldAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; -import android.database.Cursor; - -import java.net.URI; -import java.net.URL; - - -/** - * Knows how to load and store {@link URL} values from a {@link Cursor} or {@link ContentValues}. - * - * @param - * The type of the entity the field belongs to. - * - * @author Marten Gajda - */ -public final class UrlFieldAdapter extends SimpleFieldAdapter -{ - - private final String mFieldName; - - - /** - * Constructor for a new {@link UrlFieldAdapter}. - * - * @param urlField - * The field name that holds the URL. - */ - public UrlFieldAdapter(String urlField) - { - if (urlField == null) - { - throw new IllegalArgumentException("urlField must not be null"); - } - mFieldName = urlField; - } - - - @Override - String fieldName() - { - return mFieldName; - } - - - @Override - public URI getFrom(ContentValues values) - { - return values.get(mFieldName) == null ? null : URI.create(values.getAsString(mFieldName)); - } - - - @Override - public URI getFrom(Cursor cursor) - { - int columnIdx = cursor.getColumnIndex(mFieldName); - if (columnIdx < 0) - { - throw new IllegalArgumentException("The column '" + mFieldName + "' is missing in cursor."); - } - - return cursor.isNull(columnIdx) ? null : URI.create(cursor.getString(columnIdx)); - } - - - @Override - public void setIn(ContentValues values, URI value) - { - if (value != null) - { - values.put(mFieldName, value.toASCIIString()); - } - else - { - values.putNull(mFieldName); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java deleted file mode 100644 index 8ae6323..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/EntityProcessor.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors; - -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.model.EntityAdapter; - - -/** - * @author Marten Gajda - */ -public interface EntityProcessor> -{ - T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); - - T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); - - void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter); - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java deleted file mode 100644 index 87f7379..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/Logging.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors; - -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import org.dmfs.provider.tasks.model.EntityAdapter; - - -/** - * @author Marten Gajda - */ -public final class Logging> implements EntityProcessor -{ - public static final String TAG = "Logging EntityProcessor"; - private final EntityProcessor mDelegate; - - - public Logging(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - Log.d(TAG, "before insert"); - T result = mDelegate.insert(db, entityAdapter, isSyncAdapter); - Log.d(TAG, "after insert on " + entityAdapter.id()); - return result; - } - - - @Override - public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - Log.d(TAG, "before update of " + entityAdapter.id()); - T result = mDelegate.update(db, entityAdapter, isSyncAdapter); - Log.d(TAG, "after update of " + entityAdapter.id()); - return result; - } - - - @Override - public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - Log.d(TAG, "before delete of " + entityAdapter.id()); - mDelegate.delete(db, entityAdapter, isSyncAdapter); - Log.d(TAG, "after delete of " + entityAdapter.id()); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java deleted file mode 100644 index d86f026..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/NoOpProcessor.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2018 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors; - -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.model.EntityAdapter; - - -/** - * A simple No-Op {@link EntityProcessor}. - * - * @author Marten Gajda - */ -public final class NoOpProcessor> implements EntityProcessor -{ - @Override - public T insert(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - return entityAdapter; - } - - - @Override - public T update(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - return entityAdapter; - } - - - @Override - public void delete(SQLiteDatabase db, T entityAdapter, boolean isSyncAdapter) - { - // do nothing - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java deleted file mode 100644 index 8c98fd0..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java +++ /dev/null @@ -1,337 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.instances; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.iterables.SingletonIterable; -import org.dmfs.iterables.decorators.Sieved; -import org.dmfs.jems.iterable.composite.Joined; -import org.dmfs.jems.optional.adapters.FirstPresent; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.predicate.composite.AnyOf; -import org.dmfs.jems.predicate.composite.Not; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.InstanceAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; -import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.provider.tasks.utils.Timestamps; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.Duration; -import org.dmfs.rfc5545.recur.RecurrenceRule; -import org.dmfs.rfc5545.recurrenceset.RecurrenceList; -import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.HashSet; -import java.util.TimeZone; - -import static java.util.Arrays.asList; - - -/** - * An instance {@link EntityProcessor} detaches completed instances at the start of a recurring task. - * - * @author Marten Gajda - */ -public final class Detaching implements EntityProcessor -{ - - private final EntityProcessor mDelegate; - private final EntityProcessor mTaskDelegate; - - - public Detaching(EntityProcessor delegate, EntityProcessor taskDelegate) - { - mDelegate = delegate; - mTaskDelegate = taskDelegate; - } - - - @Override - public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - // just delegate for now - // if we ever support inserting instances, we'll have to make sure that inserting a completed instance results in a detached task - return mDelegate.insert(db, entityAdapter, isSyncAdapter); - } - - - /** - * Detach the given instance if all of the following conditions are met - *

- * - The instance is a recurrence instance (INSTANCE_ORIGINAL_TIME != null) - * - and the task has been closed (IS_CLOSED != 0) - * - and the instance is the first non-closed instance (DISTANCE_FROM_CURRENT==0). - *

- */ - @Override - public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - if (entityAdapter.valueOf(InstanceAdapter.DISTANCE_FROM_CURRENT) != 0 // not the first open task - - // not closed, note we can't use IS_CLOSED at this point because its not updated yet - || (!new HashSet<>(asList(TaskContract.Tasks.STATUS_COMPLETED, TaskContract.Tasks.STATUS_CANCELLED)).contains( - entityAdapter.valueOf(new IntegerFieldAdapter<>(TaskContract.Tasks.STATUS)))) - - // not recurring - || entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME) == null) - { - // not a detachable instance - return mDelegate.update(db, entityAdapter, isSyncAdapter); - } - // update instance accordingly and detach it - return detachAll(db, mDelegate.update(db, entityAdapter, isSyncAdapter)); - } - - - @Override - public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - // just delegate - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - /** - * Detach all closed instances preceding the given one. - *

- * TODO: this method needs some refactoring - */ - private InstanceAdapter detachAll(SQLiteDatabase db, InstanceAdapter entityAdapter) - { - // keep some values for later - long masterId = new FirstPresent<>( - new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.ORIGINAL_INSTANCE_ID))), - new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.TASK_ID)))).value(); - DateTime instanceOriginalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); - - // detach instances which are completed - try (Cursor instances = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, - null, - String.format("%s < 0 and %s == ?", TaskContract.Instances.DISTANCE_FROM_CURRENT, TaskContract.Instances.ORIGINAL_INSTANCE_ID), - new String[] { String.valueOf(masterId) }, - null, - null, - null)) - { - while (instances.moveToNext()) - { - detachSingle(db, new CursorContentValuesInstanceAdapter(instances, new ContentValues())); - } - } - - // move the master to the first incomplete task - try (Cursor task = db.query(TaskDatabaseHelper.Tables.TASKS_VIEW, - null, - String.format("%s == ?", TaskContract.Tasks._ID), - new String[] { String.valueOf(masterId) }, - null, - null, - null)) - { - if (task.moveToFirst()) - { - TaskAdapter masterTask = new CursorContentValuesTaskAdapter(task, new ContentValues()); - DateTime oldStart = new FirstPresent<>( - new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), - new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); - - // assume we have no instances left - boolean noInstances = true; - - // update RRULE, if existent - RecurrenceRule rule = masterTask.valueOf(TaskAdapter.RRULE); - int count = 0; - if (rule != null) - { - RecurrenceSet ruleSet = new RecurrenceSet(); - ruleSet.addInstances(new RecurrenceRuleAdapter(rule)); - if (rule.getCount() == null) - { - // rule has no count limit, allowing us to exclude exdates - ruleSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); - } - RecurrenceSetIterator ruleIterator = ruleSet.iterator( - oldStart.getTimeZone(), - oldStart.getTimestamp()); - - // move DTSTART to next RRULE instance which is > instanceOriginalTime - // reduce COUNT by the number of skipped instances, if present - while (count < 1000 && ruleIterator.hasNext()) - { - DateTime inst = new DateTime(oldStart.getTimeZone(), ruleIterator.next()); - if (instanceOriginalTime.before(inst)) - { - updateStart(masterTask, inst); - noInstances = false; // just found another instance - break; - } - count += 1; - } - - if (noInstances) - { - // remove the RRULE but keep a mask for the old start - masterTask.set(TaskAdapter.EXDATE, - new Joined<>(new SingletonIterable<>(oldStart), new Sieved<>(new Not<>(oldStart::equals), masterTask.valueOf(TaskAdapter.EXDATE)))); - masterTask.set(TaskAdapter.RRULE, null); - } - else - { - // adjust COUNT if present - if (rule.getCount() != null) - { - rule.setCount(rule.getCount() - count); - masterTask.set(TaskAdapter.RRULE, rule); - } - } - } - - DateTime newStart = new FirstPresent<>( - new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), - new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); - - // update RDATE and EXDATE - masterTask.set(TaskAdapter.RDATE, new Sieved<>(instanceOriginalTime::before, masterTask.valueOf(TaskAdapter.RDATE))); - masterTask.set(TaskAdapter.EXDATE, - new Sieved<>(new AnyOf<>(instanceOriginalTime::before, newStart::equals), masterTask.valueOf(TaskAdapter.EXDATE))); - - // First check if we still have any RDATE instances left - // TODO: 6 lines for something we should be able to express in one simple expression, we need to straighten lib-recur!! - RecurrenceSet rdateSet = new RecurrenceSet(); - rdateSet.addInstances(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.RDATE)).value())); - rdateSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); - RecurrenceSetIterator iterator = rdateSet.iterator(DateTime.UTC, Long.MIN_VALUE); - iterator.fastForward(Long.MIN_VALUE + 1); // skip bogus start - noInstances &= !iterator.hasNext(); - - if (noInstances) - { - // no more instances left, remove the master - mTaskDelegate.delete(db, masterTask, false); - } - else - { - if (masterTask.valueOf(TaskAdapter.RRULE) == null) - { - // we don't have any RRULE, allowing us to adjust DTSTART/DUE to the first RDATE - DateTime start = new DateTime(iterator.next()); - if (masterTask.valueOf(TaskAdapter.IS_ALLDAY)) - { - start = start.toAllDay(); - } - else if (masterTask.valueOf(TaskAdapter.TIMEZONE_RAW) != null) - { - start = start.shiftTimeZone(TimeZone.getTimeZone(masterTask.valueOf(TaskAdapter.TIMEZONE_RAW))); - } - updateStart(masterTask, start); - } - - // we still have instances, update the database - mTaskDelegate.update(db, masterTask, false); - } - } - } - - return entityAdapter; - } - - - private void updateStart(TaskAdapter task, DateTime newStart) - { - // this new instance becomes the new start (or due if we don't have a start) - if (task.valueOf(TaskAdapter.DTSTART) != null) - { - DateTime oldStart = task.valueOf(TaskAdapter.DTSTART); - task.set(TaskAdapter.DTSTART, newStart); - if (task.valueOf(TaskAdapter.DUE) != null) - { - long duration = task.valueOf(TaskAdapter.DUE).getTimestamp() - oldStart.getTimestamp(); - task.set(TaskAdapter.DUE, - newStart.addDuration( - new Duration(1, (int) (duration / (3600 * 24 * 1000)), (int) (duration % (3600 * 24 * 1000)) / 1000))); - } - } - else - { - task.set(TaskAdapter.DUE, newStart); - } - - } - - - /** - * Detach the given instance. - *

- * - clone the override into a new deleted task (set _DELETED == 1) - * - detach the original override by removing the ORIGINAL_INSTANCE_ID, ORIGINAL_INSTANCE_SYNC_ID, ORIGINAL_INSTANCE_START and ORIGINAL_INSTANCE_ALLDAY - * (i.e. all columns which relate this to the original) - * - wipe _SYNC_ID, _UID and all sync columns (make this an unsynced task) - */ - private void detachSingle(SQLiteDatabase db, InstanceAdapter entityAdapter) - { - TaskAdapter original = entityAdapter.taskAdapter(); - TaskAdapter cloneAdapter = original.duplicate(); - - // first prepare the original to resemble the same instance but as a new, detached task - original.set(TaskAdapter.SYNC_ID, null); - original.set(TaskAdapter.SYNC_VERSION, null); - original.set(TaskAdapter.SYNC1, null); - original.set(TaskAdapter.SYNC2, null); - original.set(TaskAdapter.SYNC3, null); - original.set(TaskAdapter.SYNC4, null); - original.set(TaskAdapter.SYNC5, null); - original.set(TaskAdapter.SYNC6, null); - original.set(TaskAdapter.SYNC7, null); - original.set(TaskAdapter.SYNC8, null); - original.set(TaskAdapter._UID, null); - original.set(TaskAdapter._DIRTY, true); - original.set(TaskAdapter.ORIGINAL_INSTANCE_ID, null); - original.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); - original.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, null); - original.unset(TaskAdapter.COMPLETED); - original.commit(db); - - // wipe INSTANCE_ORIGINAL_TIME from instances entry - ContentValues noOriginalTime = new ContentValues(); - noOriginalTime.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - db.update(TaskDatabaseHelper.Tables.INSTANCES, noOriginalTime, "_ID = ?", new String[] { String.valueOf(entityAdapter.id()) }); - - // reset the clone to be a deleted instance - cloneAdapter.set(TaskAdapter._DELETED, true); - // remove joined field values - cloneAdapter.unset(TaskAdapter.LIST_ACCESS_LEVEL); - cloneAdapter.unset(TaskAdapter.LIST_COLOR); - cloneAdapter.unset(TaskAdapter.LIST_NAME); - cloneAdapter.unset(TaskAdapter.LIST_OWNER); - cloneAdapter.unset(TaskAdapter.LIST_VISIBLE); - cloneAdapter.unset(TaskAdapter.ACCOUNT_NAME); - cloneAdapter.unset(TaskAdapter.ACCOUNT_TYPE); - cloneAdapter.commit(db); - - // note, we don't have to create an instance for the clone because it's deleted - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java deleted file mode 100644 index f02ed1d..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.instances; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.iterables.decorators.Filtered; -import org.dmfs.iterables.elementary.Seq; -import org.dmfs.iterators.filters.NoneOf; -import org.dmfs.jems.iterable.composite.Joined; -import org.dmfs.jems.optional.adapters.FirstPresent; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.handler.PropertyHandler; -import org.dmfs.provider.tasks.handler.PropertyHandlerFactory; -import org.dmfs.provider.tasks.model.ContentValuesInstanceAdapter; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.InstanceAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.Locale; - - -/** - * An instance {@link EntityProcessor} which delegates to the appropriate task {@link EntityProcessor}. - * - * @author Marten Gajda - */ -public final class TaskValueDelegate implements EntityProcessor -{ - private final static Iterable> SPECIAL_FIELD_ADAPTERS = new Seq<>( - TaskAdapter.SYNC1, - TaskAdapter.SYNC2, - TaskAdapter.SYNC3, - TaskAdapter.SYNC4, - TaskAdapter.SYNC5, - TaskAdapter.SYNC6, - TaskAdapter.SYNC7, - TaskAdapter.SYNC8, - TaskAdapter.SYNC_ID, - TaskAdapter.SYNC_VERSION, - // unset any list and read-only fields - TaskAdapter.VERSION, - TaskAdapter.ACCOUNT_NAME, - TaskAdapter.ACCOUNT_TYPE, - TaskAdapter.LIST_VISIBLE, - TaskAdapter.LIST_COLOR, - TaskAdapter.LIST_NAME, - TaskAdapter.LIST_ACCESS_LEVEL, - TaskAdapter.LIST_OWNER, - TaskAdapter._DELETED, - TaskAdapter._DIRTY, - TaskAdapter.IS_NEW, - TaskAdapter.IS_CLOSED, - TaskAdapter.HAS_PROPERTIES, - TaskAdapter.HAS_ALARMS, - TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, /* this will be resolved automatically */ - // also unset any recurrence fields - TaskAdapter.RRULE, - TaskAdapter.RDATE, - TaskAdapter.EXDATE, - TaskAdapter.CREATED, - TaskAdapter.LAST_MODIFIED - ); - - private final EntityProcessor mDelegate; - - - public TaskValueDelegate(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - TaskAdapter taskAdapter = entityAdapter.taskAdapter(); - Long masterTaskId = null; - if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) - { - // this is going to be an override to an existing task - make sure we add an RDATE first - masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); - DateTime originalTime = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME); - // get the master and add an rdate - try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) - { - if (c.moveToFirst()) - { - TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); - if (masterTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) - { - throw new IllegalArgumentException("Can't add an instance to an override instance"); - } - DateTime masterDate = new Backed(new FirstPresent<>(new Seq<>( - new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DTSTART)), - new NullSafe<>(masterTaskAdapter.valueOf(TaskAdapter.DUE)))), () -> null).value(); - if (!masterTaskAdapter.isRecurring() && masterDate != null) - { - // master is not recurring yet, also add its start as an RDATE - appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, masterDate); - } - // TODO: should we throw if the new master has no DTSTART? - appendDate(masterTaskAdapter, TaskAdapter.RDATE, TaskAdapter.EXDATE, originalTime); - mDelegate.update(db, masterTaskAdapter, false); - - } - else - { - throw new IllegalArgumentException(String.format(Locale.ENGLISH, "No task with _ID %d found", masterTaskId)); - } - } - } - - // move on with inserting the instance - TaskAdapter taskResult = mDelegate.insert(db, entityAdapter.taskAdapter(), false); - - if (masterTaskId != null) - { - // we just cloned the master task into a new instance, we need to copy the properties as well - copyProperties(db, masterTaskId, taskResult.id()); - } - - try (Cursor c = db.query(TaskDatabaseHelper.Tables.INSTANCES, new String[] { TaskContract.Instances._ID }, - TaskContract.Instances.TASK_ID + "=" + taskResult.id(), null, null, null, null)) - { - // the cursor should contain exactly one row after this operation - c.moveToFirst(); - return new ContentValuesInstanceAdapter(c.getLong(0), new ContentValues()); - } - } - - - @Override - public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - // if this is the master of a recurring task, we create a new instance or update an existing one for this override, otherwise we just delegate - TaskAdapter taskAdapter = entityAdapter.taskAdapter(); - if (taskAdapter.isRecurring()) - { - // clone the task to create an unsynced override - InstanceAdapter newInstanceAdapter = entityAdapter.duplicate(); - TaskAdapter override = newInstanceAdapter.taskAdapter(); - override.set(TaskAdapter.ORIGINAL_INSTANCE_ID, entityAdapter.valueOf(InstanceAdapter.TASK_ID)); - override.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); - // unset all fields which have special meaning - for (FieldAdapter specialFieldAdapter : SPECIAL_FIELD_ADAPTERS) - { - override.unset(specialFieldAdapter); - } - - // make sure we update DTSTART and DUE to match the instance values (unless they are set explicitly) - if (!taskAdapter.isUpdated(TaskAdapter.DTSTART)) - { - // set DTSTART to the instance start - override.set(TaskAdapter.DTSTART, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_START)); - } - if (!taskAdapter.isUpdated(TaskAdapter.DUE) && !taskAdapter.isUpdated(TaskAdapter.DURATION)) - { - // set DUE to the effective instance DUE and wipe any duration - override.set(TaskAdapter.DUE, newInstanceAdapter.valueOf(InstanceAdapter.INSTANCE_DUE)); - override.set(TaskAdapter.DURATION, null); - } - // copy original instance allday flag - override.set(TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, taskAdapter.valueOf(TaskAdapter.IS_ALLDAY)); - - TaskAdapter newTask = mDelegate.insert(db, override, false); - - copyProperties(db, taskAdapter.id(), newTask.id()); - } - else - { - // this is a non-recurring task or it's already an override, just delegate the update - mDelegate.update(db, taskAdapter, false); - } - return entityAdapter; - } - - - @Override - public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - // deleted instances are converted to deleted tasks (for non-recurring tasks) or exdates (for recurring tasks). - TaskAdapter taskAdapter = entityAdapter.taskAdapter(); - - if (taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) - { - /* this is an override - we have to: - * - mark it deleted - * - add an exclusion to the master task - * - * TODO: if this instance was added by an RDATE, just remove the RDATE - * TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate - * TODO: if this is the last instance of a finite task, consider just setting a new recurrence end - */ - long masterTaskId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); - DateTime originalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); - - // delete the override - mDelegate.delete(db, taskAdapter, false); - - // get the master and add an exdate - try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null /* all */, TaskContract.Tasks._ID + "=" + masterTaskId, null, null, null, null)) - { - if (c.moveToFirst()) - { - TaskAdapter masterTaskAdapter = new CursorContentValuesTaskAdapter(masterTaskId, c, new ContentValues()); - appendDate(masterTaskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, originalTime); - mDelegate.update(db, masterTaskAdapter, false); - } - } - } - else if (taskAdapter.isRecurring()) - { - // TODO: if this is the first instance, consider moving the recurrence start instead of adding an exdate - // TODO: if this is the last instance of a finite task, consider just setting a new recurrence end - appendDate(taskAdapter, TaskAdapter.EXDATE, TaskAdapter.RDATE, entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME)); - mDelegate.update(db, taskAdapter, false); - } - else - { - // task is non-recurring, delete it as a non-sync-adapter (effectively setting the _deleted flag) - mDelegate.delete(db, taskAdapter, false); - } - } - - - private void appendDate(TaskAdapter taskAdapter, FieldAdapter, TaskAdapter> addfieldAdapter, FieldAdapter, TaskAdapter> removefieldAdapter, DateTime dateTime) - { - taskAdapter.set(addfieldAdapter, new Joined<>(new Filtered<>(taskAdapter.valueOf(addfieldAdapter), new NoneOf<>(dateTime)), new Seq<>(dateTime))); - taskAdapter.set(removefieldAdapter, new Filtered<>(taskAdapter.valueOf(removefieldAdapter), new NoneOf<>(dateTime))); - } - - - /** - * Copy the properties from the give original task to the new task. - * - * @param db - * The {@link SQLiteDatabase} - * @param originalId - * The ID of the task of which to copy the properties - * @param newId - * The ID of the task to copy the properties to. - */ - private void copyProperties(SQLiteDatabase db, long originalId, long newId) - { - // for each property of the original task - try (Cursor c = db.query(TaskDatabaseHelper.Tables.PROPERTIES, null /* all */, - String.format(Locale.ENGLISH, "%s = %d", TaskContract.Properties.TASK_ID, originalId), null, null, null, null)) - { - // load the property and insert it for the new task - ContentValues values = new ContentValues(c.getColumnCount()); - while (c.moveToNext()) - { - values.clear(); - DatabaseUtils.cursorRowToContentValues(c, values); - PropertyHandler ph = PropertyHandlerFactory.get(values.getAsString(TaskContract.Properties.MIMETYPE)); - ph.insert(db, newId, ph.cloneForNewTask(newId, values), false); - } - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java deleted file mode 100644 index 238e650..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Validating.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.instances; - -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.iterables.decorators.Sieved; -import org.dmfs.iterables.elementary.Seq; -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.adapters.First; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.InstanceAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.model.adapters.FieldAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.Locale; - - -/** - * An {@link EntityProcessor} which validates the instance data. - * - * @author Marten Gajda - */ -public final class Validating implements EntityProcessor -{ - private final static Iterable> INSTANCE_FIELD_ADAPTERS = new Seq<>( - InstanceAdapter._ID, - InstanceAdapter.INSTANCE_START, - InstanceAdapter.INSTANCE_START_SORTING, - InstanceAdapter.INSTANCE_DUE, - InstanceAdapter.INSTANCE_DUE_SORTING, - InstanceAdapter.INSTANCE_ORIGINAL_TIME, - InstanceAdapter.DISTANCE_FROM_CURRENT, - InstanceAdapter.TASK_ID); - - private final static Iterable> RECURRENCE_FIELD_ADAPTERS = new Seq<>( - TaskAdapter.RRULE, - TaskAdapter.RDATE, - TaskAdapter.EXDATE); - - private static final Iterable> ORIGINAL_INSTANCE_FIELD_ADAPTERS = new Seq<>( - TaskAdapter.ORIGINAL_INSTANCE_ID, - TaskAdapter.ORIGINAL_INSTANCE_TIME, - TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, - TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID); - - private final EntityProcessor mDelegate; - - - public Validating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - validateIsSyncAdapter(isSyncAdapter); - validateValues(entityAdapter); - validateInstanceIsNew(db, entityAdapter); - - return mDelegate.insert(db, entityAdapter, false); - } - - - @Override - public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - validateIsSyncAdapter(isSyncAdapter); - validateValues(entityAdapter); - validateOriginalInstanceValues(entityAdapter); - return mDelegate.update(db, entityAdapter, false); - } - - - @Override - public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) - { - validateIsSyncAdapter(isSyncAdapter); - mDelegate.delete(db, entityAdapter, false); - } - - - private void validateIsSyncAdapter(boolean isSyncAdapter) - { - if (isSyncAdapter) - { - throw new UnsupportedOperationException("Sync adapters are not expected to write to the instances table."); - } - } - - - private void validateInstanceIsNew(SQLiteDatabase db, InstanceAdapter entityAdapter) - { - Optional instanceId = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)); - Optional instanceTime = new NullSafe<>(entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)); - - // check if ORIGINAL_INSTANCE_ID and ORIGINAL_INSTANCE_TIME are both present/absent at the same time - if (instanceId.isPresent() != instanceTime.isPresent()) - { - throw new IllegalArgumentException(String.format("%s and %s must either be both absent or both present", - TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks.ORIGINAL_INSTANCE_TIME)); - } - - if (instanceId.isPresent()) - { - String timeStampString = Long.toString(instanceTime.value().getTimestamp()); - // Make sure there is no instance at the given time already - try (Cursor c = db.query( - TaskDatabaseHelper.Tables.INSTANCE_VIEW, - new String[] { TaskContract.Instances._ID }, - // find any instance which refers to the given original ID and has the same instance time - // for recurring tasks this matches the INSTANCE_ORIGINAL_TIME, for non-recurring tasks this matches start or due (whichever is present). - String.format("(%1$s == ? or %2$s == ?) and (%3$s == ? or %3$s is null and %4$s == ? or %3$s is null and %4$s is null and %5$s == ?) ", - TaskContract.Instances.TASK_ID, - TaskContract.Instances.ORIGINAL_INSTANCE_ID, - TaskContract.Instances.INSTANCE_ORIGINAL_TIME, - TaskContract.Instances.INSTANCE_START, - TaskContract.Instances.INSTANCE_DUE), - new String[] { - instanceId.value().toString(), - instanceId.value().toString(), - timeStampString, - timeStampString, - timeStampString }, - null, - null, - null)) - { - if (c.getCount() > 0) - { - throw new IllegalArgumentException(String.format(Locale.ENGLISH, "Instance %s of task %d already exists", - entityAdapter.taskAdapter().valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME).toString(), instanceId.value())); - } - } - } - } - - - private void validateValues(InstanceAdapter instanceAdapter) - { - // actually, no instance value can be changed, the instance table only allows for updating task values - if (new First<>(new Sieved<>(instanceAdapter::isUpdated, INSTANCE_FIELD_ADAPTERS)).isPresent()) - { - throw new IllegalArgumentException("Instance columns are read-only."); - } - - TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); - // By definition, single instances don't have a recurrence set on their own, hence changes to the recurrence fields are not allowed. - if (new First<>(new Sieved<>(taskAdapter::isUpdated, RECURRENCE_FIELD_ADAPTERS)).isPresent()) - { - throw new IllegalArgumentException("Recurrence values can not be modified through the instances table."); - } - } - - - private void validateOriginalInstanceValues(InstanceAdapter instanceAdapter) - { - TaskAdapter taskAdapter = instanceAdapter.taskAdapter(); - // Updates of ORIGINAL_INSTANCE_* fields are not allowed - if (new First<>(new Sieved<>(taskAdapter::isUpdated, ORIGINAL_INSTANCE_FIELD_ADAPTERS)).isPresent()) - { - throw new IllegalArgumentException("ORIGINAL_INSTANCE_* fields can not be updated through the instances table."); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java deleted file mode 100644 index 0e33369..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/ListCommitProcessor.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.lists; - -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.ListAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A processor that performs the actual operations on task lists. - * - * @author Marten Gajda - */ -public final class ListCommitProcessor implements EntityProcessor -{ - - @Override - public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) - { - list.commit(db); - return list; - } - - - @Override - public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) - { - list.commit(db); - return list; - } - - - @Override - public void delete(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) - { - db.delete(TaskDatabaseHelper.Tables.LISTS, TaskContract.TaskLists._ID + "=" + list.id(), null); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java deleted file mode 100644 index 8b27215..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/lists/Validating.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.lists; - -import android.database.sqlite.SQLiteDatabase; -import android.text.TextUtils; - -import org.dmfs.provider.tasks.model.ListAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; - - -/** - * A processor to validate the values of a task list. - * - * @author Marten Gajda - */ -public final class Validating implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Validating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public ListAdapter insert(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) - { - if (!isSyncAdapter) - { - throw new UnsupportedOperationException("Caller must be a sync adapter to create task lists"); - } - - if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_NAME))) - { - throw new IllegalArgumentException("ACCOUNT_NAME is required on INSERT"); - } - - if (TextUtils.isEmpty(list.valueOf(ListAdapter.ACCOUNT_TYPE))) - { - throw new IllegalArgumentException("ACCOUNT_TYPE is required on INSERT"); - } - - verifyCommon(list, isSyncAdapter); - return mDelegate.insert(db, list, isSyncAdapter); - } - - - @Override - public ListAdapter update(SQLiteDatabase db, ListAdapter list, boolean isSyncAdapter) - { - if (list.isUpdated(ListAdapter.ACCOUNT_NAME)) - { - throw new IllegalArgumentException("ACCOUNT_NAME is write-once"); - } - - if (list.isUpdated(ListAdapter.ACCOUNT_TYPE)) - { - throw new IllegalArgumentException("ACCOUNT_TYPE is write-once"); - } - - verifyCommon(list, isSyncAdapter); - return mDelegate.update(db, list, isSyncAdapter); - } - - - @Override - public void delete(SQLiteDatabase db, ListAdapter entityAdapter, boolean isSyncAdapter) - { - if (!isSyncAdapter) - { - throw new UnsupportedOperationException("Caller must be a sync adapter to delete task lists"); - } - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - /** - * Performs tests that are common to insert an update operations. - * - * @param list - * The {@link ListAdapter} to verify. - * @param isSyncAdapter - * true if the caller is a sync adapter, false otherwise. - */ - private void verifyCommon(ListAdapter list, boolean isSyncAdapter) - { - // row id can not be changed or set manually - if (list.isUpdated(ListAdapter._ID)) - { - throw new IllegalArgumentException("_ID can not be set manually"); - } - - if (isSyncAdapter) - { - // sync adapters may do all the stuff below - return; - } - - if (list.isUpdated(ListAdapter.LIST_COLOR)) - { - throw new IllegalArgumentException("Only sync adapters can change the LIST_COLOR."); - } - if (list.isUpdated(ListAdapter.LIST_NAME)) - { - throw new IllegalArgumentException("Only sync adapters can change the LIST_NAME."); - } - if (list.isUpdated(ListAdapter.SYNC_ID)) - { - throw new IllegalArgumentException("Only sync adapters can change the _SYNC_ID."); - } - if (list.isUpdated(ListAdapter.SYNC_VERSION)) - { - throw new IllegalArgumentException("Only sync adapters can change SYNC_VERSION."); - } - if (list.isUpdated(ListAdapter.OWNER)) - { - throw new IllegalArgumentException("Only sync adapters can change the list OWNER."); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java deleted file mode 100644 index 65bbe9a..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A processor to adjust some task values automatically. - *

- * Other then recurrence exceptions no relations are handled by this code. Relation specific changes go to {@link Relating}. - * - * @author Marten Gajda - */ -public final class AutoCompleting implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - private static final String[] TASK_ID_PROJECTION = { TaskContract.Tasks._ID }; - private static final String[] TASK_SYNC_ID_PROJECTION = { TaskContract.Tasks._SYNC_ID }; - - private static final String SYNC_ID_SELECTION = TaskContract.Tasks._SYNC_ID + "=?"; - private static final String TASK_ID_SELECTION = TaskContract.Tasks._ID + "=?"; - - - public AutoCompleting(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - updateFields(db, task, isSyncAdapter); - - if (!isSyncAdapter) - { - // set created date for tasks created on the device - task.set(TaskAdapter.CREATED, DateTime.now()); - } - - TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - - if (isSyncAdapter && result.isRecurring()) - { - // task is recurring, update ORIGINAL_INSTANCE_ID of all exceptions that may already exists - ContentValues values = new ContentValues(1); - TaskAdapter.ORIGINAL_INSTANCE_ID.setIn(values, result.id()); - db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID + "=? and " - + TaskContract.Tasks.ORIGINAL_INSTANCE_ID + " is null", new String[] { result.valueOf(TaskAdapter.SYNC_ID) }); - } - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - updateFields(db, task, isSyncAdapter); - TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); - - if (isSyncAdapter && result.isRecurring() && result.isUpdated(TaskAdapter.SYNC_ID)) - { - // task is recurring, update ORIGINAL_INSTANCE_SYNC_ID of all exceptions that may already exists - ContentValues values = new ContentValues(1); - TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID.setIn(values, result.valueOf(TaskAdapter.SYNC_ID)); - db.update(TaskDatabaseHelper.Tables.TASKS, values, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + result.id(), null); - } - return result; - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - private void updateFields(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - if (!isSyncAdapter) - { - task.set(TaskAdapter._DIRTY, true); - task.set(TaskAdapter.LAST_MODIFIED, DateTime.now()); - - // set proper STATUS if task has been completed - if (task.valueOf(TaskAdapter.COMPLETED) != null && !task.isUpdated(TaskAdapter.STATUS)) - { - task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); - } - } - - if (task.isUpdated(TaskAdapter.PRIORITY)) - { - Integer priority = task.valueOf(TaskAdapter.PRIORITY); - if (priority != null && priority == 0) - { - // replace priority 0 by null, it's the default and we need that for proper sorting - task.set(TaskAdapter.PRIORITY, null); - } - } - - // Find corresponding ORIGINAL_INSTANCE_ID - if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID)) - { - String[] syncId = { task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) }; - try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_ID_PROJECTION, SYNC_ID_SELECTION, syncId, null, null, null)) - { - if (cursor.moveToNext()) - { - Long originalId = cursor.getLong(0); - task.set(TaskAdapter.ORIGINAL_INSTANCE_ID, originalId); - } - } - } - else if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) // Find corresponding ORIGINAL_INSTANCE_SYNC_ID - { - String[] id = { Long.toString(task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)) }; - try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_SYNC_ID_PROJECTION, TASK_ID_SELECTION, id, null, null, null)) - { - if (cursor.moveToNext()) - { - String originalSyncId = cursor.getString(0); - task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, originalSyncId); - } - } - } - - // check that PERCENT_COMPLETE is an Integer between 0 and 100 if supplied also update status and completed accordingly - if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) - { - Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); - - if (!isSyncAdapter && percent != null && percent == 100) - { - if (!task.isUpdated(TaskAdapter.STATUS)) - { - task.set(TaskAdapter.STATUS, TaskContract.Tasks.STATUS_COMPLETED); - } - - if (!task.isUpdated(TaskAdapter.COMPLETED)) - { - task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); - } - } - else if (!isSyncAdapter && percent != null) - { - if (!task.isUpdated(TaskAdapter.COMPLETED)) - { - task.set(TaskAdapter.COMPLETED, null); - } - } - } - - // validate STATUS and set IS_NEW and IS_CLOSED accordingly - if (task.isUpdated(TaskAdapter.STATUS) || task.id() < 0 /* this is true when the task is new */) - { - Integer status = task.valueOf(TaskAdapter.STATUS); - if (status == null) - { - status = TaskContract.Tasks.STATUS_DEFAULT; - task.set(TaskAdapter.STATUS, status); - } - - task.set(TaskAdapter.IS_NEW, status == TaskContract.Tasks.STATUS_NEEDS_ACTION); - task.set(TaskAdapter.IS_CLOSED, status == TaskContract.Tasks.STATUS_COMPLETED || status == TaskContract.Tasks.STATUS_CANCELLED); - - /* - * Update PERCENT_COMPLETE and COMPLETED (if not given). Sync adapters should know what they're doing, so don't update anything if caller is a sync - * adapter. - */ - if (status == TaskContract.Tasks.STATUS_COMPLETED && !isSyncAdapter) - { - task.set(TaskAdapter.PERCENT_COMPLETE, 100); - if (!task.isUpdated(TaskAdapter.COMPLETED) || task.valueOf(TaskAdapter.COMPLETED) == null) - { - task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); - } - } - else if (!isSyncAdapter) - { - task.set(TaskAdapter.COMPLETED, null); - } - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java deleted file mode 100644 index 1891ff3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.jems.function.elementary.DiffMap; -import org.dmfs.jems.iterable.composite.Diff; -import org.dmfs.jems.iterable.decorators.Mapped; -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.pair.Pair; -import org.dmfs.jems.pair.elementary.RightSidedPair; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.model.adapters.BooleanFieldAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.provider.tasks.utils.InstanceValuesIterable; -import org.dmfs.provider.tasks.utils.Limited; -import org.dmfs.provider.tasks.utils.OverrideValuesFunction; -import org.dmfs.provider.tasks.utils.Range; -import org.dmfs.provider.tasks.utils.RowIterator; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.Locale; - -import static org.dmfs.provider.tasks.model.TaskAdapter.IS_CLOSED; - - -/** - * A processor that creates or updates the instance values of a task. - * - * @author Marten Gajda - */ -public final class Instantiating implements EntityProcessor -{ - /** - * Projection we use to read the overrides of a task - */ - private final static String[] OVERRIDE_PROJECTION = { - TaskContract.Tasks._ID, - TaskContract.Tasks.DTSTART, - TaskContract.Tasks.DUE, - TaskContract.Tasks.DURATION, - TaskContract.Tasks.TZ, - TaskContract.Tasks.IS_ALLDAY, - TaskContract.Tasks.IS_CLOSED, - TaskContract.Tasks.ORIGINAL_INSTANCE_TIME, - TaskContract.Tasks.ORIGINAL_INSTANCE_ALLDAY }; - - /** - * This is a field adapter for a pseudo column to indicate that the instances may need an update, even if no relevant value has changed. This is useful to - * force an update of the sorting values when the local timezone has been changed. - *

- * TODO: get rid of it - */ - private final static BooleanFieldAdapter UPDATE_REQUESTED = new BooleanFieldAdapter( - "org.dmfs.tasks.TaskInstanceProcessor.UPDATE_REQUESTED"); - - // for now we only expand the next upcoming instance - private final static int UPCOMING_INSTANCE_COUNT_LIMIT = 1; - - - /** - * Add a pseudo column to the given {@link ContentValues} to request an instances update, even if no time value has changed. - * - * @param values - * The {@link ContentValues} to add the pseudo column to. - */ - public static void addUpdateRequest(ContentValues values) - { - UPDATE_REQUESTED.setIn(values, true); - } - - - private final EntityProcessor mDelegate; - - - public Instantiating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) - { - // an override was created, insert a single task - updateOverrideInstance(db, result, result.id()); - } - else - { - // update the recurring instances, there may already be overrides, so we use the update method - updateMasterInstances(db, result, result.id()); - } - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - // TODO: get rid if this mechanism - boolean updateRequested = task.isUpdated(UPDATE_REQUESTED) ? task.valueOf(UPDATE_REQUESTED) : false; - task.unset(UPDATE_REQUESTED); - - TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); - - if (!result.isUpdated(TaskAdapter.DTSTART) && !result.isUpdated(TaskAdapter.DUE) && !result.isUpdated(TaskAdapter.DURATION) - && !result.isUpdated(TaskAdapter.STATUS) && !result.isUpdated(TaskAdapter.RDATE) && !result.isUpdated(TaskAdapter.RRULE) && !result.isUpdated( - TaskAdapter.EXDATE) && !result.isUpdated(IS_CLOSED) && !updateRequested) - { - // date values didn't change and update not requested -> no need to update the instances table - return result; - } - if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) == null) - { - updateMasterInstances(db, result, result.id()); - } - else - { - updateOverrideInstance(db, result, result.id()); - } - return result; - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - // Note: there is a database trigger which cleans the instances table automatically when a task is deleted - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - /** - * Update the instance of an override. - *

- * TODO: take instance overrides into account - * - * @param db - * an {@link SQLiteDatabase}. - * @param taskAdapter - * the {@link TaskAdapter} of the task to insert. - * @param id - * the row id of the new task. - */ - private void updateOverrideInstance(SQLiteDatabase db, TaskAdapter taskAdapter, long id) - { - long origId = taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); - int count = 0; - if (!taskAdapter.isUpdated(IS_CLOSED)) - { - // task status was not updated, we can take the shortcut and only update any existing instance values - for (Single values : new InstanceValuesIterable(id, taskAdapter)) - { - if (count++ > 1) - { - throw new RuntimeException("more than one instance returned for task instance which was supposed to have exactly one"); - } - ContentValues contentValues = values.value(); - // we don't know the current distance, but it for sure hasn't changed either, so just make sure we don't change it - contentValues.remove(TaskContract.Instances.DISTANCE_FROM_CURRENT); - // TASK_ID hasn't changed either - contentValues.remove(TaskContract.Instances.TASK_ID); - - db.update(TaskDatabaseHelper.Tables.INSTANCES, - contentValues, - String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances.TASK_ID, id), - null); - } - if (count == 0) - { - throw new RuntimeException("no instance returned for task which was supposed to have exactly one"); - } - } - else - { - // task status was updated, this might affect other instances, update them all - // ensure the distance from current is set properly for all sibling instances - try (Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, - String.format(Locale.ENGLISH, "(%s = %d)", TaskContract.Tasks._ID, origId), null, null, null, null)) - { - if (c.moveToFirst()) - { - TaskAdapter ta = new CursorContentValuesTaskAdapter(c, new ContentValues()); - updateMasterInstances(db, ta, ta.id()); - } - } - } - } - - - /** - * Updates the instances of an existing task - * - * @param db - * An {@link SQLiteDatabase}. - * @param taskAdapter - * the {@link TaskAdapter} of the task to update - * @param id - * the row id of the new task - */ - private void updateMasterInstances(SQLiteDatabase db, TaskAdapter taskAdapter, long id) - { - try (Cursor existingInstances = db.query( - TaskDatabaseHelper.Tables.INSTANCE_VIEW, - new String[] { - TaskContract.Instances._ID, - TaskContract.InstanceColumns.INSTANCE_ORIGINAL_TIME, - TaskContract.InstanceColumns.INSTANCE_START, - TaskContract.InstanceColumns.INSTANCE_START_SORTING, - TaskContract.InstanceColumns.INSTANCE_DUE, - TaskContract.InstanceColumns.INSTANCE_DUE_SORTING, - TaskContract.InstanceColumns.INSTANCE_DURATION, - TaskContract.InstanceColumns.TASK_ID, - TaskContract.InstanceColumns.DISTANCE_FROM_CURRENT, - TaskContract.Instances.IS_CLOSED }, - String.format(Locale.ENGLISH, "%s = ? or %s = ?", TaskContract.Instances.TASK_ID, TaskContract.Instances.ORIGINAL_INSTANCE_ID), - new String[] { Long.toString(id), Long.toString(id) }, - null, - null, - TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - Cursor overrides = db.query( - TaskDatabaseHelper.Tables.TASKS, - OVERRIDE_PROJECTION, - String.format("%s = ? AND %s != 1", TaskContract.Tasks.ORIGINAL_INSTANCE_ID, TaskContract.Tasks._DELETED), - new String[] { Long.toString(id) }, - null, - null, - TaskContract.Tasks.ORIGINAL_INSTANCE_TIME);) - { - - /* - * The goal of the code below is to update existing instances in place (as opposed to delete and recreate all instances). We do this for two reasons: - * 1) efficiency, in most cases existing instances don't change, deleting and recreating them would be overly expensive - * 2) stable row ids, deleting and recreating instances would change their id and void any existing URIs to them - */ - final int idIdx = existingInstances.getColumnIndex(TaskContract.Instances._ID); - final int startIdx = existingInstances.getColumnIndex(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - final int taskIdIdx = existingInstances.getColumnIndex(TaskContract.Instances.TASK_ID); - final int isClosedIdx = existingInstances.getColumnIndex(TaskContract.Instances.IS_CLOSED); - final int distanceIdx = existingInstances.getColumnIndex(TaskContract.Instances.DISTANCE_FROM_CURRENT); - - // get an Iterator of all expected instances - // for very long or even infinite series we need to stop iterating at some point. - - Iterable, Optional>> diff = new Diff<>( - new Mapped<>(Single::value, new Limited<>(10000 /* hard limit for infinite rules*/, - new Mapped<>( - new DiffMap<>( - (original, override) -> override, // we have both, a regular instance and an override -> take the override - original -> original, - override -> override // we only have an override :-o, not really valid but tolerated - ), - new Diff<>( - new InstanceValuesIterable(id, taskAdapter), - new Mapped<>( - cursor -> - new OverrideValuesFunction() - .value(new CursorContentValuesTaskAdapter(cursor, new ContentValues())), - () -> new RowIterator(overrides)), - (left, right) -> { - Long leftLong = left.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - Long rightLong = right.value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - // null is always smaller - if (leftLong == null) - { - return rightLong == null ? 0 : -1; - } - if (rightLong == null) - { - return 1; - } - - long ldiff = leftLong - rightLong; - return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); - })))), - new Range(existingInstances.getCount()), - (newInstanceValues, cursorRow) -> - { - existingInstances.moveToPosition(cursorRow); - long ldiff = new Backed<>(new NullSafe<>(newInstanceValues.getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME)), 0L).value() - - existingInstances.getLong(startIdx); - return ldiff < 0 ? -1 : (ldiff > 0 ? 1 : 0); - }); - - int distance = -1; - // sync the instances table with the new instances - for (Pair, Optional> next : diff) - { - if (distance >= UPCOMING_INSTANCE_COUNT_LIMIT - 1) - { - // we already expanded enough instances - if (!next.right().isPresent()) - { - // if no further instances exist, stop here - Long original = next.left().value().getAsLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - if (original != null && existingInstances.moveToLast() && existingInstances.getLong(startIdx) < original) - { - break; - } - - // we may have to delete a few future instances - continue; - } - next = new RightSidedPair<>(next.right()); - } - - if (!next.left().isPresent()) - { - // there is no new instance for this old one, remove it - existingInstances.moveToPosition(next.right().value()); - db.delete(TaskDatabaseHelper.Tables.INSTANCES, - String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), null); - } - else if (!next.right().isPresent()) - { - // there is no old instance for this new one, add it - ContentValues values = next.left().value(); - if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) - { - distance += 1; - } - values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); - db.insert(TaskDatabaseHelper.Tables.INSTANCES, "", values); - } - else // both sides are present - { - // update this instance - existingInstances.moveToPosition(next.right().value()); - ContentValues values = next.left().value(); - if (distance >= 0 || values.getAsLong(TaskContract.Instances.DISTANCE_FROM_CURRENT) >= 0) - { - // the distance needs to be updated - distance += 1; - values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); - } - - ContentValues updates = updatedOnly(values, existingInstances); - if (updates.size() > 0) - { - db.update(TaskDatabaseHelper.Tables.INSTANCES, - updates, - String.format(Locale.ENGLISH, "%s = %d", TaskContract.Instances._ID, existingInstances.getLong(idIdx)), - null); - } - } - } - } - } - - - private static ContentValues updatedOnly(ContentValues newValues, Cursor oldValues) - { - ContentValues result = new ContentValues(newValues); - for (String key : newValues.keySet()) - { - int columnIdx = oldValues.getColumnIndex(key); - if (columnIdx < 0) - { - throw new RuntimeException("Missing column " + key + " in Cursor "); - } - if (oldValues.isNull(columnIdx) && newValues.get(key) == null) - { - result.remove(key); - } - else if (!oldValues.isNull(columnIdx) && newValues.get(key) != null && oldValues.getLong(columnIdx) == newValues.getAsLong(key)) - { - result.remove(key); - } - } - return result; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java deleted file mode 100644 index ea9d9cb..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Moving.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * This processor makes sure that changing the list a task belongs is properly handled by sync adapters. This is achieved by emulating an atomic copy & delete - * operation. - *

- * TODO: at present we only move recurrence exceptions based on the original row id. We should consider to move exceptions based on the original SYNC_ID as well - * to support moving exception sets of tasks without known master instance. - * - * @author Marten Gajda - */ -public final class Moving implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Moving(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - return mDelegate.insert(db, task, isSyncAdapter); - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - if (isSyncAdapter) - { - // sync-adapters have to implement the move logic themselves - return mDelegate.update(db, task, isSyncAdapter); - } - - if (!task.isUpdated(TaskAdapter.LIST_ID)) - { - // list has not been changed - return mDelegate.update(db, task, isSyncAdapter); - } - - long oldList = task.oldValueOf(TaskAdapter.LIST_ID); - long newList = task.valueOf(TaskAdapter.LIST_ID); - - if (oldList == newList) - { - // list has not been changed - return mDelegate.update(db, task, isSyncAdapter); - } - - Long newMasterId; - Long deletedMasterId = null; - - if (task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null) - { - // this is an exception, move the master first - newMasterId = task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID); - if (newMasterId != null) - { - // find the master task - Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks._ID + "=" + newMasterId, null, null, null, null); - try - { - if (c.moveToFirst()) - { - // move the master task - deletedMasterId = moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, null, true); - } - - } - finally - { - c.close(); - } - } - - // now move this exception, make sure we link the deleted exception to the deleted master - moveTask(db, task, oldList, newList, deletedMasterId, false); - } - else - { - newMasterId = task.id(); - // move the task to the new list - deletedMasterId = moveTask(db, task, oldList, newList, null, false); - } - - if (task.isRecurring() || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID) != null) - { - // This task is recurring and may have exceptions or it's an exception itself. Move all (other) exceptions to the new list. - Cursor c = db.query(TaskDatabaseHelper.Tables.TASKS, null, TaskContract.Tasks.ORIGINAL_INSTANCE_ID + "=" + newMasterId + " and " - + TaskContract.Tasks._ID + "!=" + task.id(), null, null, null, null); - try - { - while (c.moveToNext()) - { - moveTask(db, new CursorContentValuesTaskAdapter(c, new ContentValues(16)), oldList, newList, deletedMasterId, true); - } - } - finally - { - c.close(); - } - } - - return mDelegate.update(db, task, isSyncAdapter); - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - mDelegate.delete(db, task, isSyncAdapter); - } - - - private Long moveTask(SQLiteDatabase db, TaskAdapter task, long oldList, long newList, Long deletedOriginalId, boolean commitTask) - { - /* - * The task has been moved to a different list. Sync adapters are not expected to support this (especially since the new list may belong to a completely - * different account or even account-type), so we emulate a copy & delete operation. - * - * All sync adapter fields of the task are cleared, so it looks like a new task. In addition we create a new deleted task in the old list having the old - * sync adapter field values. This means that the _ID field of the "deleted" task will not equal the _ID field f the original task. Sync adapters should - * handle that correctly. - */ - - Long result = null; - - // create a deleted task for the old one, unless the task has not been synced yet (which is always true for tasks in the local account) - if (task.valueOf(TaskAdapter.SYNC_ID) != null || task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) != null - || task.valueOf(TaskAdapter.SYNC_VERSION) != null) - { - TaskAdapter deletedTask = task.duplicate(); - deletedTask.set(TaskAdapter.LIST_ID, oldList); - deletedTask.set(TaskAdapter.ORIGINAL_INSTANCE_ID, deletedOriginalId); - deletedTask.set(TaskAdapter._DELETED, true); - - // make sure we unset any values that do not exist in the tasks table - deletedTask.unset(TaskAdapter.LIST_COLOR); - deletedTask.unset(TaskAdapter.LIST_NAME); - deletedTask.unset(TaskAdapter.ACCOUNT_NAME); - deletedTask.unset(TaskAdapter.ACCOUNT_TYPE); - deletedTask.unset(TaskAdapter.LIST_OWNER); - deletedTask.unset(TaskAdapter.LIST_ACCESS_LEVEL); - deletedTask.unset(TaskAdapter.LIST_VISIBLE); - - // create the deleted task - deletedTask.commit(db); - - result = deletedTask.id(); - } - - // clear all sync fields to convert the existing task to a new task - task.set(TaskAdapter.LIST_ID, newList); - task.set(TaskAdapter._DIRTY, true); - task.set(TaskAdapter.SYNC1, null); - task.set(TaskAdapter.SYNC2, null); - task.set(TaskAdapter.SYNC3, null); - task.set(TaskAdapter.SYNC4, null); - task.set(TaskAdapter.SYNC5, null); - task.set(TaskAdapter.SYNC6, null); - task.set(TaskAdapter.SYNC7, null); - task.set(TaskAdapter.SYNC8, null); - task.set(TaskAdapter.SYNC_ID, null); - task.set(TaskAdapter.SYNC_VERSION, null); - task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); - if (commitTask) - { - task.commit(db); - } - - return result; - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java deleted file mode 100644 index 15e82b4..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Originating.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2018 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - -import java.util.Locale; - - -/** - * An {@link EntityProcessor} which updates the {@link TaskContract.Tasks#ORIGINAL_INSTANCE_ID} of any overrides when a master is inserted which has the - * matching {@link TaskContract.Tasks#ORIGINAL_INSTANCE_SYNC_ID}. - * - * @author Marten Gajda - */ -public final class Originating implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Originating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - String syncId = result.valueOf(TaskAdapter.SYNC_ID); - if (syncId != null) - { - // A master task with a syncId has been inserted. - // Update original ID of any existing overrides. - ContentValues values = new ContentValues(1); - values.put(TaskContract.Tasks.ORIGINAL_INSTANCE_ID, result.id()); - db.update(TaskDatabaseHelper.Tables.TASKS, values, String.format(Locale.ENGLISH, "%s = ?", TaskContract.Tasks.ORIGINAL_INSTANCE_SYNC_ID), - new String[] { syncId }); - } - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - return mDelegate.update(db, task, isSyncAdapter); - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - mDelegate.delete(db, task, isSyncAdapter); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java deleted file mode 100644 index d641779..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Relating.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A processor that updates relations for new tasks. - *

- * In general there is no guarantee that a related task is already in the database when a task is - * inserted. In such a case we can not set the {@link TaskContract.Property.Relation#RELATED_ID} value. This processor updates the {@link - * TaskContract.Property.Relation#RELATED_ID} when a task is inserted. - *

- * It also updates {@link TaskContract.Property.Relation#RELATED_UID} when a tasks - * is synced the first time and a UID has been set. - *

- * - * @author Marten Gajda - */ -public final class Relating implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Relating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - // A new task has been inserted by the sync adapter. Update all relations that point to this task. - - if (!isSyncAdapter) - { - // the task was created on the device, so it doesn't have a UID - return result; - } - - String uid = result.valueOf(TaskAdapter._UID); - - if (uid != null) - { - ContentValues v = new ContentValues(1); - v.put(TaskContract.Property.Relation.RELATED_ID, result.id()); - - int updates = db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, - TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_UID + "=?", new String[] { - TaskContract.Property.Relation.CONTENT_ITEM_TYPE, uid }); - - if (updates > 0) - { - // there were other relations pointing towards this task, update PARENT_IDs if necessary - ContentValues parentIdValues = new ContentValues(1); - parentIdValues.put(TaskContract.Tasks.PARENT_ID, result.id()); - // iterate over all tasks which refer to this as their parent and update their PARENT_ID - try (Cursor c = db.query( - TaskDatabaseHelper.Tables.PROPERTIES, new String[] { TaskContract.Property.Relation.TASK_ID }, - String.format("%s = ? and %s = ? and %s = ?", - TaskContract.Property.Relation.MIMETYPE, - TaskContract.Property.Relation.RELATED_ID, - TaskContract.Property.Relation.RELATED_TYPE), - new String[] { - TaskContract.Property.Relation.CONTENT_ITEM_TYPE, - String.valueOf(result.id()), - String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT) }, - null, null, null)) - { - while (c.moveToNext()) - { - db.update(TaskDatabaseHelper.Tables.TASKS, parentIdValues, TaskContract.Tasks._ID + " = ?", new String[] { c.getString(0) }); - } - } - // TODO, way also may have to do this for all the siblings of these tasks. - } - } - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); - // A task has been updated and may have received a UID by the sync adapter. Update all by-id references to this task. - // in this case we don't need to update any PARENT_ID because it should already be set. - - if (!isSyncAdapter) - { - // only sync adapters may assign a UID - return result; - } - - String uid = result.valueOf(TaskAdapter._UID); - - if (uid != null) - { - ContentValues v = new ContentValues(1); - v.put(TaskContract.Property.Relation.RELATED_UID, uid); - - db.update(TaskDatabaseHelper.Tables.PROPERTIES, v, - TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", new String[] { - TaskContract.Property.Relation.CONTENT_ITEM_TYPE, Long.toString(result.id()) }); - } - return result; - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - mDelegate.delete(db, task, isSyncAdapter); - - if (!isSyncAdapter) - { - // remove once the deletion is final, which is when the sync adapter removes it - return; - } - - db.delete(TaskDatabaseHelper.Tables.PROPERTIES, TaskContract.Property.Relation.MIMETYPE + "= ? AND " + TaskContract.Property.Relation.RELATED_ID + "=?", - new String[] { - TaskContract.Property.Relation.CONTENT_ITEM_TYPE, - Long.toString(task.id()) }); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java deleted file mode 100644 index a08f9b7..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Reparenting.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.content.ContentValues; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * An {@link EntityProcessor} which updates a task's parent-child relations when its {@link TaskContract.Tasks#PARENT_ID} is updated. - * - * @author Marten Gajda - */ -public final class Reparenting implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Reparenting(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.insert(db, entityAdapter, isSyncAdapter); - if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) - { - linkParent(db, result); - } - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - if (entityAdapter.isUpdated(TaskAdapter.PARENT_ID)) - { - unlinkParent(db, entityAdapter); - TaskAdapter result = mDelegate.update(db, entityAdapter, isSyncAdapter); - linkParent(db, entityAdapter); - return result; - } - else - { - return mDelegate.update(db, entityAdapter, isSyncAdapter); - } - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - unlinkParent(db, entityAdapter); - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - private void unlinkParent(SQLiteDatabase db, TaskAdapter taskAdapter) - { - if (taskAdapter.oldValueOf(TaskAdapter.PARENT_ID) != null) - { - // delete any parent, child or sibling relation with this task - db.delete(TaskDatabaseHelper.Tables.PROPERTIES, - String.format("%s = ? AND (%s = ? and %s in (?, ?) or %s = ? and %s in (?, ?))", - TaskContract.Property.Relation.MIMETYPE, - TaskContract.Property.Relation.TASK_ID, - TaskContract.Property.Relation.RELATED_TYPE, - TaskContract.Property.Relation.RELATED_ID, - TaskContract.Property.Relation.RELATED_TYPE), - new String[] { - TaskContract.Property.Relation.CONTENT_ITEM_TYPE, - String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), - String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), - String.valueOf(TaskContract.Property.Relation.RELTYPE_PARENT), - String.valueOf(taskAdapter.valueOf(TaskAdapter._ID)), - String.valueOf(TaskContract.Property.Relation.RELTYPE_SIBLING), - String.valueOf(TaskContract.Property.Relation.RELTYPE_CHILD) }); - } - } - - - private void linkParent(SQLiteDatabase db, TaskAdapter taskAdapter) - { - if (taskAdapter.valueOf(TaskAdapter.PARENT_ID) != null) - { - ContentValues values = new ContentValues(); - values.put(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE); - values.put(TaskContract.Property.Relation.TASK_ID, taskAdapter.id()); - values.put(TaskContract.Property.Relation.RELATED_TYPE, TaskContract.Property.Relation.RELTYPE_PARENT); - values.put(TaskContract.Property.Relation.RELATED_ID, taskAdapter.valueOf(TaskAdapter.PARENT_ID)); - values.put(TaskContract.Property.Relation.RELATED_UID, taskAdapter.valueOf(TaskAdapter._UID)); - db.insert(TaskDatabaseHelper.Tables.PROPERTIES, "", values); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java deleted file mode 100644 index 5b62b8e..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Searchable.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.FTSDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.provider.tasks.utils.Profiled; - - -/** - * An {@link EntityProcessor} to update the fast text search table when inserting or updating a task. - * - * @author Marten Gajda - */ -public final class Searchable implements EntityProcessor -{ - private final EntityProcessor mDelegate; - - - public Searchable(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.insert(db, task, isSyncAdapter); - new Profiled("InsertFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); - return result; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - TaskAdapter result = mDelegate.update(db, task, isSyncAdapter); - new Profiled("UpdateFTS").run(() -> FTSDatabaseHelper.updateTaskFTSEntries(db, task)); - return result; - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - new Profiled("DeleteFTS").run(() -> mDelegate.delete(db, entityAdapter, isSyncAdapter)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java deleted file mode 100644 index 677dd61..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/TaskCommitProcessor.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A processor that performs the actual operations on tasks. - * - * @author Marten Gajda - */ -public final class TaskCommitProcessor implements EntityProcessor -{ - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - task.commit(db); - return task; - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - task.commit(db); - return task; - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - String accountType = task.valueOf(TaskAdapter.ACCOUNT_TYPE); - - if (isSyncAdapter || TaskContract.LOCAL_ACCOUNT_TYPE.equals(accountType)) - { - // this is a local task or it's removed by a sync adapter, in either case we delete it right away - db.delete(TaskDatabaseHelper.Tables.TASKS, TaskContract.TaskColumns._ID + "=" + task.id(), null); - } - else - { - // just set the deleted flag otherwise - task.set(TaskAdapter._DELETED, true); - task.commit(db); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java deleted file mode 100644 index a28a97e..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks; - -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.provider.tasks.TaskDatabaseHelper; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.EntityProcessor; -import org.dmfs.rfc5545.Duration; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A processor that validates the values of a task. - * - * @author Marten Gajda - */ -public final class Validating implements EntityProcessor -{ - private static final String[] TASKLIST_ID_PROJECTION = { TaskContract.TaskLists._ID }; - private static final String TASKLISTS_ID_SELECTION = TaskContract.TaskLists._ID + "="; - - private final EntityProcessor mDelegate; - - - public Validating(EntityProcessor delegate) - { - mDelegate = delegate; - } - - - @Override - public TaskAdapter insert(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - verifyCommon(task, isSyncAdapter); - - // LIST_ID must be present and refer to an existing TaskList row id - Long listId = task.valueOf(TaskAdapter.LIST_ID); - if (listId == null) - { - throw new IllegalArgumentException("LIST_ID is required on INSERT"); - } - - // TODO: get rid of this query and use a cache instead - // TODO: ensure that the list is writable unless the caller is a sync adapter - Cursor cursor = db.query(TaskDatabaseHelper.Tables.LISTS, TASKLIST_ID_PROJECTION, TASKLISTS_ID_SELECTION + listId, null, null, null, null); - try - { - if (cursor == null || cursor.getCount() != 1) - { - throw new IllegalArgumentException("LIST_ID must refer to an existing TaskList"); - } - } - finally - { - if (cursor != null) - { - cursor.close(); - } - } - return mDelegate.insert(db, task, isSyncAdapter); - } - - - @Override - public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAdapter) - { - verifyCommon(task, isSyncAdapter); - - // only sync adapters can modify original sync id and original instance id of an existing task - if (!isSyncAdapter && (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID) || task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID))) - { - throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID can be modified by sync adapters only"); - } - - // only sync adapters are allowed to change the UID of existing tasks - if (!isSyncAdapter && task.isUpdated(TaskAdapter._UID)) - { - throw new IllegalArgumentException("modification of _UID is not allowed to non-sync adapters"); - } - - return mDelegate.update(db, task, isSyncAdapter); - } - - - @Override - public void delete(SQLiteDatabase db, TaskAdapter entityAdapter, boolean isSyncAdapter) - { - mDelegate.delete(db, entityAdapter, isSyncAdapter); - } - - - /** - * Performs tests that are common to insert an update operations. - * - * @param task - * The {@link TaskAdapter} to verify. - * @param isSyncAdapter - * true if the caller is a sync adapter, false otherwise. - */ - private void verifyCommon(TaskAdapter task, boolean isSyncAdapter) - { - // row id can not be changed or set manually - if (task.isUpdated(TaskAdapter._ID)) - { - throw new IllegalArgumentException("_ID can not be set manually"); - } - - if (task.isUpdated(TaskAdapter.VERSION)) - { - throw new IllegalArgumentException("VERSION can not be set manually"); - } - - // account name can not be set on a tasks - if (task.isUpdated(TaskAdapter.ACCOUNT_NAME)) - { - throw new IllegalArgumentException("ACCOUNT_NAME can not be set on a tasks"); - } - - // account type can not be set on a tasks - if (task.isUpdated(TaskAdapter.ACCOUNT_TYPE)) - { - throw new IllegalArgumentException("ACCOUNT_TYPE can not be set on a tasks"); - } - - // list color is read only for tasks - if (task.isUpdated(TaskAdapter.LIST_COLOR)) - { - throw new IllegalArgumentException("LIST_COLOR can not be set on a tasks"); - } - - // no one can undelete a task! - if (task.isUpdated(TaskAdapter._DELETED)) - { - throw new IllegalArgumentException("modification of _DELETE is not allowed"); - } - - // only sync adapters are allowed to remove the dirty flag - if (!isSyncAdapter && task.isUpdated(TaskAdapter._DIRTY)) - { - throw new IllegalArgumentException("modification of _DIRTY is not allowed"); - } - - // only sync adapters are allowed to set creation time - if (!isSyncAdapter && task.isUpdated(TaskAdapter.CREATED)) - { - throw new IllegalArgumentException("modification of CREATED is not allowed"); - } - - // IS_NEW is set automatically - if (task.isUpdated(TaskAdapter.IS_NEW)) - { - throw new IllegalArgumentException("modification of IS_NEW is not allowed"); - } - - // IS_CLOSED is set automatically - if (task.isUpdated(TaskAdapter.IS_CLOSED)) - { - throw new IllegalArgumentException("modification of IS_CLOSED is not allowed"); - } - - // HAS_PROPERTIES is set automatically - if (task.isUpdated(TaskAdapter.HAS_PROPERTIES)) - { - throw new IllegalArgumentException("modification of HAS_PROPERTIES is not allowed"); - } - - // HAS_ALARMS is set automatically - if (task.isUpdated(TaskAdapter.HAS_ALARMS)) - { - throw new IllegalArgumentException("modification of HAS_ALARMS is not allowed"); - } - - // only sync adapters are allowed to set modification time - if (!isSyncAdapter && task.isUpdated(TaskAdapter.LAST_MODIFIED)) - { - throw new IllegalArgumentException("modification of MODIFICATION_TIME is not allowed"); - } - - if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) && task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) - { - throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID must not be specified at the same time"); - } - - // check that CLASSIFICATION is an Integer between 0 and 2 if given - if (task.isUpdated(TaskAdapter.CLASSIFICATION)) - { - Integer classification = task.valueOf(TaskAdapter.CLASSIFICATION); - if (classification != null && (classification < 0 || classification > 2)) - { - throw new IllegalArgumentException("CLASSIFICATION must be an integer between 0 and 2"); - } - } - - // check that PRIORITY is an Integer between 0 and 9 if given - if (task.isUpdated(TaskAdapter.PRIORITY)) - { - Integer priority = task.valueOf(TaskAdapter.PRIORITY); - if (priority != null && (priority < 0 || priority > 9)) - { - throw new IllegalArgumentException("PRIORITY must be an integer between 0 and 9"); - } - } - - // check that PERCENT_COMPLETE is an Integer between 0 and 100 - if (task.isUpdated(TaskAdapter.PERCENT_COMPLETE)) - { - Integer percent = task.valueOf(TaskAdapter.PERCENT_COMPLETE); - if (percent != null && (percent < 0 || percent > 100)) - { - throw new IllegalArgumentException("PERCENT_COMPLETE must be null or an integer between 0 and 100"); - } - } - - // validate STATUS - if (task.isUpdated(TaskAdapter.STATUS)) - { - Integer status = task.valueOf(TaskAdapter.STATUS); - if (status != null && (status < TaskContract.Tasks.STATUS_NEEDS_ACTION || status > TaskContract.Tasks.STATUS_CANCELLED)) - { - throw new IllegalArgumentException("invalid STATUS: " + status); - } - } - - // ensure that DUE and DURATION are set properly if DTSTART is given - Long dtStart = task.valueOf(TaskAdapter.DTSTART_RAW); - Long due = task.valueOf(TaskAdapter.DUE_RAW); - Duration duration = task.valueOf(TaskAdapter.DURATION); - - if (dtStart != null) - { - if (due != null && duration != null) - { - throw new IllegalArgumentException("Only one of DUE or DURATION must be supplied."); - } - else if (due != null) - { - if (due < dtStart) - { - throw new IllegalArgumentException("DUE must not be < DTSTART"); - } - } - else if (duration != null) - { - if (duration.getSign() == -1) - { - throw new IllegalArgumentException("DURATION must not be negative"); - } - } - } - else if (duration != null) - { - throw new IllegalArgumentException("DURATION must not be supplied without DTSTART"); - } - - // if one of DTSTART or DUE is given, TZ must not be null unless it's an all-day task - if ((dtStart != null || due != null) && !task.valueOf(TaskAdapter.IS_ALLDAY) && task.valueOf(TaskAdapter.TIMEZONE_RAW) == null) - { - throw new IllegalArgumentException("TIMEZONE must be supplied if one of DTSTART or DUE is not null and not all-day"); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java deleted file mode 100644 index c9e606c..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Dated.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.provider.tasks.utils.Zipped; -import org.dmfs.rfc5545.DateTime; - -import java.util.TimeZone; - - -/** - * A {@link Single} of date and time {@link ContentValues} of an instance. - * - * @author Marten Gajda - */ -public final class Dated extends DelegatingSingle -{ - - public Dated(Optional dateTime, String timeStampColumn, String sortingColumn, Single delegate) - { - super(new Zipped<>( - dateTime, - delegate, - (dateTime1, values) -> - { - // add timestamp and sorting - values.put(timeStampColumn, dateTime1.getTimestamp()); - values.put(sortingColumn, dateTime1.isAllDay() ? dateTime1.getInstance() : dateTime1.shiftTimeZone(TimeZone.getDefault()).getInstance()); - return values; - })); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java deleted file mode 100644 index fafc5ca..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Distant.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A {@link Single} of the instance distance {@link ContentValues} of an instance. - * - * @author Marten Gajda - */ -public final class Distant extends DelegatingSingle -{ - - public Distant(int distance, Single delegate) - { - super(() -> - { - ContentValues values = delegate.value(); - values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, distance); - return values; - }); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java deleted file mode 100644 index d39005f..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDated.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A decorator to a {@link Single} of {@link ContentValues} adding due data. - * - * @author Marten Gajda - */ -public final class DueDated extends DelegatingSingle -{ - public DueDated(Optional due, Single delegate) - { - super(new Dated(due, TaskContract.Instances.INSTANCE_DUE, TaskContract.Instances.INSTANCE_DUE_SORTING, delegate)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java deleted file mode 100644 index 0552a87..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Enduring.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.composite.Zipped; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_DURATION} field based on the - * already populated {@link TaskContract.Instances#INSTANCE_START} and {@link TaskContract.Instances#INSTANCE_DUE} fields. - * - * @author Marten Gajda - */ -public final class Enduring implements Single -{ - private final Single mDelegate; - - - public Enduring(Single delegate) - { - mDelegate = delegate; - } - - - @Override - public ContentValues value() - { - ContentValues values = mDelegate.value(); - // just store the difference between due and start, if both are present, otherwise store null - values.put(TaskContract.Instances.INSTANCE_DURATION, - new Backed( - new Zipped<>( - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_START)), - new NullSafe<>(values.getAsLong(TaskContract.Instances.INSTANCE_DUE)), - (start, due) -> due - start), - () -> null).value()); - return values; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java deleted file mode 100644 index 793ffa1..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/Overridden.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.decorators.Mapped; -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.jems.procedure.composite.ForEach; -import org.dmfs.jems.single.Single; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A decorator for {@link Single}s of Instance {@link ContentValues} which populates the {@link TaskContract.Instances#INSTANCE_ORIGINAL_TIME} field based on - * the given {@link Optional} original start. - * - * @author Marten Gajda - */ -public final class Overridden implements Single -{ - private final Optional mOriginalTime; - private final Single mDelegate; - - - public Overridden(DateTime originalTime, ContentValues delegate) - { - this(new Present<>(originalTime), () -> delegate); - } - - - public Overridden(Optional originalTime, Single delegate) - { - mOriginalTime = originalTime; - mDelegate = delegate; - } - - - @Override - public ContentValues value() - { - ContentValues values = mDelegate.value(); - new ForEach<>(new Mapped<>(DateTime::getTimestamp, mOriginalTime)).process(time -> values.put(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, time)); - return values; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java deleted file mode 100644 index 5ecb320..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDated.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.decorators.DelegatingSingle; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A decorator to a {@link Single} of {@link ContentValues} adding start data. - * - * @author Marten Gajda - */ -public final class StartDated extends DelegatingSingle -{ - public StartDated(Optional start, Single delegate) - { - super(new Dated(start, TaskContract.Instances.INSTANCE_START, TaskContract.Instances.INSTANCE_START_SORTING, delegate)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java deleted file mode 100644 index 65be8e9..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelated.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.single.Single; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A decorator to {@link Single}s of {@link ContentValues} adding a {@link TaskContract.Instances#TASK_ID} to the data. - * - * @author Marten Gajda - */ -public final class TaskRelated implements Single -{ - private final long mTaskId; - private final Single mDelegate; - - - public TaskRelated(TaskAdapter taskAdapter, Single delegate) - { - this(taskAdapter.id(), delegate); - } - - - public TaskRelated(long taskId, Single delegate) - { - mTaskId = taskId; - mDelegate = delegate; - } - - - @Override - public ContentValues value() - { - ContentValues values = mDelegate.value(); - values.put(TaskContract.Instances.TASK_ID, mTaskId); - return values; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java b/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java deleted file mode 100644 index b46d3cc..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceData.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.single.Single; -import org.dmfs.tasks.contract.TaskContract; - - -/** - * A {@link Single} of instance data {@link ContentValues}. It initializes most columns with {@code null} values, except for {@link - * TaskContract.Instances#TASK_ID} which is left out and {@link TaskContract.Instances#DISTANCE_FROM_CURRENT} which is initialized with {@code 0} as well. - * - * @author Marten Gajda - */ -public final class VanillaInstanceData implements Single -{ - @Override - public ContentValues value() - { - ContentValues values = new ContentValues(10); - values.putNull(TaskContract.Instances.INSTANCE_START); - values.putNull(TaskContract.Instances.INSTANCE_START_SORTING); - values.putNull(TaskContract.Instances.INSTANCE_DUE); - values.putNull(TaskContract.Instances.INSTANCE_DUE_SORTING); - values.putNull(TaskContract.Instances.INSTANCE_DURATION); - values.put(TaskContract.Instances.DISTANCE_FROM_CURRENT, 0); - values.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - return values; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java deleted file mode 100644 index 3f37b2e..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; -import android.database.Cursor; - -import org.dmfs.jems.predicate.Predicate; - -import java.util.Arrays; - - -/** - * A {@link Predicate} which determines whether all values of a ContentValues object are present in a {@link Cursor}. - * - * @author Marten Gajda - */ -public final class ContainsValues implements Predicate -{ - private final ContentValues mValues; - - - public ContainsValues(ContentValues values) - { - mValues = values; - } - - - @Override - public boolean satisfiedBy(Cursor testedInstance) - { - for (String key : mValues.keySet()) - { - int columnIdx = testedInstance.getColumnIndex(key); - if (columnIdx < 0) - { - return false; - } - - if (testedInstance.getType(columnIdx) == Cursor.FIELD_TYPE_BLOB) - { - if (!Arrays.equals(mValues.getAsByteArray(key), testedInstance.getBlob(columnIdx))) - { - return false; - } - } - else - { - String stringValue = mValues.getAsString(key); - if (stringValue != null && !stringValue.equals(testedInstance.getString(columnIdx)) || stringValue == null && !testedInstance.isNull(columnIdx)) - { - return false; - } - } - } - return true; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java deleted file mode 100644 index 5ee653d..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/InstanceValuesIterable.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; - -import org.dmfs.iterators.SingletonIterator; -import org.dmfs.jems.iterable.elementary.Seq; -import org.dmfs.jems.iterator.decorators.Mapped; -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.adapters.FirstPresent; -import org.dmfs.jems.optional.composite.Zipped; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.jems.single.Single; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; -import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; -import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.Duration; - -import java.util.Iterator; - - -/** - * An {@link Iterable} of {@link Single} {@link ContentValues} of the instances of a task. - * - * @author Marten Gajda - */ -// TODO: replace Single with Generator -public final class InstanceValuesIterable implements Iterable> -{ - private final long mId; - private final TaskAdapter mTaskAdapter; - - - public InstanceValuesIterable(long id, TaskAdapter taskAdapter) - { - mId = id; - mTaskAdapter = taskAdapter; - } - - - @Override - public Iterator> iterator() - { - Optional start = new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)); - // effective due is either the actual due, start + duration or absent - Optional effectiveDue = new FirstPresent<>( - new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DUE)), - new Zipped<>(start, new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); - - Single baseData = new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, - new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(mId, new VanillaInstanceData()))))); - - if (!mTaskAdapter.isRecurring()) - { - return new SingletonIterator<>( - // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME - new org.dmfs.provider.tasks.utils.Zipped<>( - new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), - baseData, - (DateTime time, ContentValues data) -> new Overridden(time, data).value())); - } - - if (start.isPresent()) - { - Optional effectiveDuration = new FirstPresent<>( - new Seq<>( - new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DURATION)), - new Zipped<>(start, effectiveDue, - (dtStart, due) -> new Duration(1, 0, (int) ((due.getTimestamp() - dtStart.getTimestamp()) / 1000))))); - - return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, - new Overridden(new Present<>(dateTime), - new Enduring( - new DueDated(new Zipped<>(new Present<>(dateTime), effectiveDuration, this::addDuration), - new StartDated(new Present<>(dateTime), - new TaskRelated(mId, new VanillaInstanceData())))))), - new TaskInstanceIterable(mTaskAdapter).iterator()); - } - - // special treatment for recurring tasks without a DTSTART: - return new Mapped<>(dateTime -> new Distant(mTaskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, - new Overridden(new Present<>(dateTime), - new DueDated(new Present<>(dateTime), new TaskRelated(mId, new VanillaInstanceData())))), - new TaskInstanceIterable(mTaskAdapter).iterator()); - - } - - - private DateTime addDuration(DateTime dt, Duration dur) - { - if (dt.isAllDay() && dur.getSecondsOfDay() != 0) - { - dur = new Duration(1, dur.getWeeks() * 7 + dur.getDays() + dur.getSecondsOfDay() / (3600 * 24), 0); - } - return dt.addDuration(dur); - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java deleted file mode 100644 index 43833a1..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/Limited.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import java.util.Iterator; - - -/** - * An {@link Iterable} which limits the number of elements. - *

- * TODO: move to jems - * - * @author Marten Gajda - * @deprecated - */ -@Deprecated -public final class Limited implements Iterable -{ - private final int mCount; - private final Iterable mDelegate; - - - public Limited(int count, Iterable delegate) - { - mCount = count; - mDelegate = delegate; - } - - - @Override - public Iterator iterator() - { - return new LimitedIterator<>(mCount, mDelegate.iterator()); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java deleted file mode 100644 index 88bba90..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/LimitedIterator.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.iterators.AbstractBaseIterator; - -import java.util.Iterator; -import java.util.NoSuchElementException; - - -/** - * An {@link Iterator} which limits the number elements. - * TODO: move to jems - * - * @author Marten Gajda - * @deprecated - */ -@Deprecated -public final class LimitedIterator extends AbstractBaseIterator -{ - private int mCount; - private final Iterator mDelegate; - - - public LimitedIterator(int count, Iterator delegate) - { - mCount = count; - mDelegate = delegate; - } - - - @Override - public boolean hasNext() - { - return mCount > 0 && mDelegate.hasNext(); - } - - - @Override - public T next() - { - if (!hasNext()) - { - throw new NoSuchElementException("No more elements to iterate"); - } - mCount--; - return mDelegate.next(); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java deleted file mode 100644 index b8984b6..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/OverrideValuesFunction.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; - -import org.dmfs.jems.function.Function; -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.adapters.FirstPresent; -import org.dmfs.jems.optional.composite.Zipped; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.single.Single; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Distant; -import org.dmfs.provider.tasks.processors.tasks.instancedata.DueDated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Enduring; -import org.dmfs.provider.tasks.processors.tasks.instancedata.Overridden; -import org.dmfs.provider.tasks.processors.tasks.instancedata.StartDated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.TaskRelated; -import org.dmfs.provider.tasks.processors.tasks.instancedata.VanillaInstanceData; -import org.dmfs.rfc5545.DateTime; - - -/** - * An {@link Iterable} of {@link Single} {@link ContentValues} of the overrides of a task. - * - * @author Marten Gajda - */ -public final class OverrideValuesFunction implements Function> -{ - - @Override - public Single value(TaskAdapter taskAdapter) - { - Optional start = new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DTSTART)); - // effective due is either the actual due, start + duration or absent - Optional effectiveDue = new FirstPresent<>( - new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DUE)), - new Zipped<>(start, new NullSafe<>(taskAdapter.valueOf(TaskAdapter.DURATION)), DateTime::addDuration)); - - Single baseData = new Distant(taskAdapter.valueOf(TaskAdapter.IS_CLOSED) ? -1 : 0, - new Enduring(new DueDated(effectiveDue, new StartDated(start, new TaskRelated(taskAdapter, new VanillaInstanceData()))))); - - // apply the Overridden decorator only if this task has an ORIGINAL_INSTANCE_TIME - return new org.dmfs.provider.tasks.utils.Zipped<>( - new NullSafe<>(taskAdapter.valueOf(TaskAdapter.ORIGINAL_INSTANCE_TIME)), - baseData, - (DateTime time, ContentValues data) -> new Overridden(time, data).value()); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java deleted file mode 100644 index a30f3fc..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/Profiled.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.util.Log; - -import org.dmfs.jems.fragile.Fragile; -import org.dmfs.jems.single.Single; - -import java.util.Locale; - - -/** - * A simple class to measure the execution time of a given piece of code. - * - * @author Marten Gajda - */ -public final class Profiled -{ - private final String mSubject; - - - public Profiled(String subject) - { - mSubject = subject; - } - - - public void run(Runnable runnable) - { - long start = System.currentTimeMillis(); - runnable.run(); - Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); - } - - - public V run(Single runnable) - { - - long start = System.currentTimeMillis(); - try - { - return runnable.value(); - } - finally - { - Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); - } - } - - - public V run(Fragile runnable) throws E - { - - long start = System.currentTimeMillis(); - try - { - return runnable.value(); - } - finally - { - Log.d("Profiled", String.format(Locale.ENGLISH, "Time spent in %s: %d milliseconds", mSubject, System.currentTimeMillis() - start)); - } - } - -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java deleted file mode 100644 index 731e424..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/Range.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.iterator.generators.IntSequenceGenerator; - -import java.util.Iterator; - - -/** - * An {@link Iterable} which iterates a range of numbers. - *

- * TODO: implement in jems - * - * @author Marten Gajda - */ -@Deprecated -public final class Range implements Iterable -{ - private final int mStart; - private final int mEnd; - - - public Range(int end) - { - this(0, end); - } - - - public Range(int start, int end) - { - mStart = start; - mEnd = end; - } - - - @Override - public Iterator iterator() - { - return new LimitedIterator<>(mEnd - mStart, new IntSequenceGenerator(mStart)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java deleted file mode 100644 index e5b5fab..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/ResourceArray.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.Context; - -import org.dmfs.iterators.elementary.Seq; - -import java.util.Iterator; - - -/** - * An {@link Iterable} of a string array resource. - * - * @author Marten Gajda - */ -public final class ResourceArray implements Iterable -{ - private final Context mContext; - private final int mResource; - - - public ResourceArray(Context context, int resource) - { - mContext = context; - mResource = resource; - } - - - @Override - public Iterator iterator() - { - return new Seq<>(mContext.getResources().getStringArray(mResource)); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java deleted file mode 100644 index e49ac38..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/RowIterator.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.database.Cursor; - -import org.dmfs.iterators.AbstractBaseIterator; - -import java.util.NoSuchElementException; - - -/** - * @author Marten Gajda - */ -public final class RowIterator extends AbstractBaseIterator -{ - private final Cursor mCursor; - - - public RowIterator(Cursor cursor) - { - mCursor = cursor; - } - - - @Override - public boolean hasNext() - { - return mCursor.getCount() > 0 && !mCursor.isClosed() && !mCursor.isLast(); - } - - - @Override - public Cursor next() - { - if (!hasNext()) - { - throw new NoSuchElementException("No other rows to iterate."); - } - mCursor.moveToNext(); - return mCursor; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java deleted file mode 100644 index d96ac73..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/TableColumns.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteDatabase; - -import org.dmfs.jems.function.Function; - -import java.util.LinkedList; -import java.util.List; - - -/** - * A {@link Function} which returns all column names of a specific table on a given database. - * - * @author Marten Gajda - */ -public final class TableColumns implements Function> -{ - private final String mTableName; - - - public TableColumns(String tableName) - { - mTableName = tableName; - } - - - @Override - public Iterable value(SQLiteDatabase db) - { - try (Cursor cursor = db.rawQuery(String.format("PRAGMA table_info(%s)", DatabaseUtils.sqlEscapeString(mTableName)), null)) - { - int nameIdx = cursor.getColumnIndexOrThrow("name"); - - List result = new LinkedList<>(); - while (cursor.moveToNext()) - { - result.add(cursor.getString(nameIdx)); - } - - return result; - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java deleted file mode 100644 index b3620b3..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.recur.RecurrenceRule; -import org.dmfs.rfc5545.recurrenceset.RecurrenceList; -import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; - -import java.util.Iterator; -import java.util.TimeZone; - - -/** - * An {@link Iterable} of all the instances of a task. - * - * @author Marten Gajda - */ -public final class TaskInstanceIterable implements Iterable -{ - private final TaskAdapter mTaskAdapter; - - - public TaskInstanceIterable(TaskAdapter taskAdapter) - { - mTaskAdapter = taskAdapter; - } - - - @Override - public Iterator iterator() - { - DateTime dtstart = new Backed(new NullSafe<>(mTaskAdapter.valueOf(TaskAdapter.DTSTART)), () -> mTaskAdapter.valueOf(TaskAdapter.DUE)).value(); - - RecurrenceSet set = new RecurrenceSet(); - RecurrenceRule rule = mTaskAdapter.valueOf(TaskAdapter.RRULE); - if (rule != null) - { - if (rule.getUntil() != null && dtstart.isFloating() != rule.getUntil().isFloating()) - { - // rule UNTIL date mismatches start. This is merely a workaround for existing users. In future we should make sure - // such tasks don't exist - if (dtstart.isFloating()) - { - // make until floating too by making it floating in the current time zone - rule.setUntil(rule.getUntil().shiftTimeZone(TimeZone.getDefault()).swapTimeZone(null)); - } - else - { - // anchor UNTIL in the current time zone - rule.setUntil(new DateTime(null, rule.getUntil().getTimestamp()).swapTimeZone(TimeZone.getDefault())); - } - } - set.addInstances(new RecurrenceRuleAdapter(rule)); - } - - set.addInstances(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.RDATE)).value())); - set.addExceptions(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.EXDATE)).value())); - - return new TaskInstanceIterator(dtstart, set); - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java deleted file mode 100644 index 5b74cfb..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterator.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.iterators.AbstractBaseIterator; -import org.dmfs.jems.optional.decorators.Mapped; -import org.dmfs.jems.optional.elementary.NullSafe; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; - -import java.util.Iterator; -import java.util.NoSuchElementException; -import java.util.TimeZone; - - -/** - * An {@link Iterator} of instances as returned by a {@link RecurrenceSetIterator}. - *

- * TODO: this should go to lib-recur - * - * @author Marten Gajda - */ -public final class TaskInstanceIterator extends AbstractBaseIterator -{ - private final DateTime mStart; - private final RecurrenceSetIterator mSetIterator; - private final String mTimezone; - - - TaskInstanceIterator(DateTime start, RecurrenceSet set) - { - this(start, set.iterator(start.getTimeZone(), start.getTimestamp()), - new Backed<>(new Mapped<>(TimeZone::getID, new NullSafe<>(start.getTimeZone())), () -> null).value()); - } - - - TaskInstanceIterator(DateTime start, RecurrenceSetIterator setIterator, String timezone) - { - mStart = start; - mSetIterator = setIterator; - mTimezone = timezone; - } - - - @Override - public boolean hasNext() - { - return mSetIterator.hasNext(); - } - - - @Override - public DateTime next() - { - if (!hasNext()) - { - throw new NoSuchElementException("No more elements to iterate"); - } - DateTime result = new DateTime(mStart.getTimeZone(), mSetIterator.next()); - return mStart.isAllDay() ? result.toAllDay() : mTimezone == null ? result.swapTimeZone(null) : result; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java deleted file mode 100644 index 84f0949..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.single.Single; -import org.dmfs.rfc5545.DateTime; - - -/** - * A {@link Single} of an array of timestamp values of a given {@link Iterable} of {@link DateTime}s. - * - * @author Marten Gajda - */ -public final class Timestamps implements Single -{ - private final Iterable mDateTimes; - - - public Timestamps(Iterable dateTimes) - { - mDateTimes = dateTimes; - } - - - @Override - public long[] value() - { - int count = 0; - for (DateTime ignored : mDateTimes) - { - count += 1; - } - long[] timeStamps = new long[count]; - int i = 0; - for (DateTime dt : mDateTimes) - { - timeStamps[i++] = dt.getTimestamp(); - } - return timeStamps; - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java deleted file mode 100644 index 7ae6b44..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/With.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.adapters.SinglePresent; -import org.dmfs.jems.procedure.Procedure; -import org.dmfs.jems.single.Single; - - -/** - * Experiemental Procedure which calls another procedure with a given value. - *

- * TODO move to jems if this works out well - * - * @author Marten Gajda - */ -@Deprecated -public final class With implements Procedure> -{ - private final Optional mValue; - - - public With(T value) - { - this(() -> value); - } - - - public With(Single value) - { - this(new SinglePresent<>(value)); - } - - - public With(Optional value) - { - mValue = value; - } - - - @Override - public void process(Procedure delegate) - { - if (mValue.isPresent()) - { - delegate.process(mValue.value()); - } - } -} diff --git a/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java b/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java deleted file mode 100644 index 80df3f7..0000000 --- a/provider/src/main/java/org/dmfs/provider/tasks/utils/Zipped.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.function.BiFunction; -import org.dmfs.jems.optional.Optional; -import org.dmfs.jems.optional.decorators.Mapped; -import org.dmfs.jems.single.Single; -import org.dmfs.jems.single.combined.Backed; -import org.dmfs.jems.single.decorators.DelegatingSingle; - - -/** - * Experimental {@link Single} which applies a {@link BiFunction} based on the presence of an {@link Optional}. - *

- * TODO: maybe a more appropriate name? - *

- * TODO: move to jems - * - * @author Marten Gajda - */ -@Deprecated -public final class Zipped extends DelegatingSingle -{ - public Zipped(Optional optionalValue, Single delegate, BiFunction function) - { - super(new Backed(new Mapped<>(from -> function.value(from, delegate.value()), optionalValue), delegate)); - } -} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java b/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java deleted file mode 100644 index 3ce329d..0000000 --- a/provider/src/main/java/org/dmfs/tasks/contract/TaskContract.java +++ /dev/null @@ -1,1728 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.tasks.contract; - -import android.content.ContentResolver; -import android.content.Intent; -import android.net.Uri; -import android.provider.BaseColumns; -import android.provider.SyncStateContract; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - - -/** - * Task contract. This class defines the interface to the task provider. - *

- * TODO: Add missing javadoc. - *

- *

- * TODO: Specify extended properties - *

- *

- * TODO: Add CONTENT_URI for the attachment store. - *

- *

- * TODO: Also, we could use some refactoring... - *

- * - * @author Marten Gajda - * @author Tobias Reinsch - */ -public final class TaskContract -{ - - private static Map sUriFactories = new HashMap(4); - - /** - * URI parameter to signal that the caller is a sync adapter. - */ - public static final String CALLER_IS_SYNCADAPTER = "caller_is_syncadapter"; - - /** - * URI parameter to signal the request of the extended properties of a task. - */ - public static final String LOAD_PROPERTIES = "load_properties"; - - /** - * URI parameter to submit the account name of the account we operate on. - */ - public static final String ACCOUNT_NAME = "account_name"; - - /** - * URI parameter to submit the account type of the account we operate on. - */ - public static final String ACCOUNT_TYPE = "account_type"; - - /** - * Account name for local, unsynced task lists. - */ - public static final String LOCAL_ACCOUNT_NAME = "Local"; - - /** - * Account type for local, unsynced task lists. - */ - public static final String LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"; - - /** - * Broadcast action that's sent when the task database has been initialized, either because the app was launched for the first time or because the app was - * launched after the user cleared the app data. - *

- * The intent data represents the authority of the provider, the MIME type will be {@link #MIMETYPE_AUTHORITY}. - */ - public static final String ACTION_DATABASE_INITIALIZED = "org.dmfs.tasks.DATABASE_INITIALIZED"; - - /** - * A MIME type of an authority. Authorities itself don't seem to have a MIME type in Android, so we just use our own. - */ - public static final String MIMETYPE_AUTHORITY = ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.org.dmfs.authority.mimetype"; - - /** - * The action of the broadcast that's send when a task becomes due. The intent data will be a {@link Uri} of the task that became due. - */ - public static final String ACTION_BROADCAST_TASK_DUE = "org.dmfs.android.tasks.TASK_DUE"; - - /** - * The action of the broadcast that's send when a task starts. The intent data will be a {@link Uri} of the task that has started. - */ - public static final String ACTION_BROADCAST_TASK_STARTING = "org.dmfs.android.tasks.TASK_START"; - - /** - * A Long extra that contains a timestamp of the event that's triggered. So this is either the timestamp of the start or due date of the task. - */ - public final static String EXTRA_TASK_TIMESTAMP = "org.dmfs.provider.tasks.extra.TIMESTAMP"; - - /** - * A Boolean extra to indicate that the event that was triggered is an all-day date. - */ - public final static String EXTRA_TASK_ALLDAY = "org.dmfs.provider.tasks.extra.ALLDAY"; - - /** - * A String extra containing the timezone id of the task. - */ - public final static String EXTRA_TASK_TIMEZONE = "org.dmfs.provider.tasks.extra.TIMEZONE"; - - /** - * A String extra containing the title of the task. - */ - public final static String EXTRA_TASK_TITLE = "org.dmfs.provider.tasks.extra.TITLE"; - - /** - * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of {@link Uri}s that have been modified. This always - * goes along with an {@link #EXTRA_OPERATIONS} which contains a code for the operation executed on a Uri at the same index. - */ - public final static String EXTRA_OPERATIONS_URIS = "org.dmfs.tasks.OPERATIONS_URIS"; - - /** - * The name of the {@link Intent#ACTION_PROVIDER_CHANGED} extra that contains the {@link ArrayList} of provider operation codes. The following codes are - * used: - *

    - *
  • 0 - for inserts
  • - *
  • 1 - for updates
  • - *
  • 2 - for deletes
  • - *
- */ - public final static String EXTRA_OPERATIONS = "org.dmfs.tasks.OPERATIONS"; - - - /** - * Private constructor to prevent instantiation. - */ - private TaskContract() - { - } - - - /** - * A table provided for sync adapters to use for storing private sync state data. - *

- * Only sync adapters are allowed to access this table and they may access their own rows only. - *

- * Note that only one row per account will be stored. Updating or inserting a sync state for a specific account will override any previous sync state for - * this account. - */ - public static class SyncState implements SyncStateContract.Columns, BaseColumns - { - public final static String CONTENT_URI_PATH = "syncstate"; - - - /** - * Get the sync state content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - /** - * Get the base content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(); - } - - - /** - * A set of columns for synchronization purposes. These columns exist in {@link Tasks} and in {@link TaskLists} but have different meanings. Only sync - * adapters are allowed to change these values. - * - * @author Marten Gajda - */ - public interface CommonSyncColumns - { - - /** - * A unique Sync ID as set by the sync adapter. - *

- * Value: String - *

- */ - String _SYNC_ID = "_sync_id"; - - /** - * Sync version as set by the sync adapter. - *

- * Value: String - *

- */ - String SYNC_VERSION = "sync_version"; - - /** - * Indicates that a task or a task list has been changed. - *

- * Value: Integer - *

- */ - String _DIRTY = "_dirty"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC1 = "sync1"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC2 = "sync2"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC3 = "sync3"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC4 = "sync4"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC5 = "sync5"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC6 = "sync6"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC7 = "sync7"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - String SYNC8 = "sync8"; - - } - - - /** - * Additional sync columns for task lists. - * - * @author Marten Gajda - */ - public interface TaskListSyncColumns - { - - /** - * The name of the account this list belongs to. This field is write-once. - *

- * Value: String - *

- */ - String ACCOUNT_NAME = "account_name"; - - /** - * The type of the account this list belongs to. This field is write-once. - *

- * Value: String - *

- */ - String ACCOUNT_TYPE = "account_type"; - } - - - /** - * Additional sync columns for tasks. - * - * @author Marten Gajda - */ - public interface TaskSyncColumns - { - /** - * The UID of a task. This is field can be changed by a sync adapter only. - *

- * Value: String - *

- */ - String _UID = "_uid"; - - /** - * Deleted flag of a task. This is set to 1 by the content provider when a task app deletes a task. The sync adapter has to remove the task - * again to finish the removal. This value is read-only. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String _DELETED = "_deleted"; - } - - - /** - * Data columns of task lists. - * - * @author Marten Gajda - */ - public interface TaskListColumns - { - - /** - * List ID. - *

- * Value: Long - *

- *

- * read-only - *

- */ - String _ID = "_id"; - - /** - * The name of the task list. - *

- * Value: String - *

- */ - String LIST_NAME = "list_name"; - - /** - * The color of this list as integer (0xaarrggbb). Only the sync adapter can change this. - *

- * Value: Integer - *

- */ - String LIST_COLOR = "list_color"; - - /** - * The access level a user has on this list. This value is not used yet, sync adapters should set it to 0. - *

- * Value: Integer - *

- */ - String ACCESS_LEVEL = "list_access_level"; - - /** - * Indicates that a task list is set to be visible. - *

- * Value: Integer (0 or 1) - *

- */ - String VISIBLE = "visible"; - - /** - * Indicates that a task list is set to be synced. - *

- * Value: Integer (0 or 1) - *

- */ - String SYNC_ENABLED = "sync_enabled"; - - /** - * The email address of the list owner. - *

- * Value: String - *

- */ - String OWNER = "list_owner"; - - } - - - /** - * The task list table holds one entry for each task list. - * - * @author Marten Gajda - */ - public static final class TaskLists implements TaskListColumns, TaskListSyncColumns, CommonSyncColumns - { - public static final String CONTENT_URI_PATH = "tasklists"; - - /** - * The default sort order. - */ - public static final String DEFAULT_SORT_ORDER = ACCOUNT_NAME + ", " + LIST_NAME; - - /** - * An array of columns only a sync adapter is allowed to change. - */ - public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { - ACCESS_LEVEL, _DIRTY, OWNER, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, - _SYNC_ID, SYNC_VERSION, }; - - - /** - * Get the task list content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - /** - * Task data columns. Defines all the values a task can have at most once. - * - * @author Marten Gajda - */ - public interface TaskColumns extends BaseColumns - { - - /** - * The row id of a task. This value is read-only - *

- * Value: Integer - *

- */ - String _ID = "_id"; - - /** - * The local version number of this task. The only guarantee about the value is, it's incremented whenever the task changes (this includes any - * changes applied by sync adapters). - *

- * Note, there is no guarantee about how much it's incremented other than by at least 1. - *

- * Value: Integer - *

- * read-only - */ - String VERSION = "version"; - - /** - * The id of the list this task belongs to. This value is write-once and must not be null. - *

- * Value: Integer - *

- */ - String LIST_ID = "list_id"; - - /** - * The title of the task. - *

- * Value: String - *

- */ - String TITLE = "title"; - - /** - * The location of the task. - *

- * Value: String - *

- */ - String LOCATION = "location"; - - /** - * A geographic location related to the task. The should be a string in the format "longitude,latitude". - *

- * Value: String - *

- */ - String GEO = "geo"; - - /** - * The description of a task. - *

- * Value: String - *

- */ - String DESCRIPTION = "description"; - - /** - * The URL iCalendar field for this task. Must be a valid URI if not null- - *

- * Value: String - *

- */ - String URL = "url"; - - /** - * The email address of the organizer if any, {@code null} otherwise. - *

- * Value: String - *

- */ - String ORGANIZER = "organizer"; - - /** - * The priority of a task. This is an Integer between zero and 9. Zero means there is no priority set. 1 is the highest priority and 9 the lowest. - *

- * Value: Integer - *

- */ - String PRIORITY = "priority"; - - /** - * The default value of {@link #PRIORITY}. - */ - int PRIORITY_DEFAULT = 0; - - /** - * The classification of a task. This value must be either null or one of {@link #CLASSIFICATION_PUBLIC}, {@link #CLASSIFICATION_PRIVATE}, - * {@link #CLASSIFICATION_CONFIDENTIAL}. - *

- * Value: Integer - *

- */ - String CLASSIFICATION = "class"; - - /** - * Classification value for public tasks. - */ - int CLASSIFICATION_PUBLIC = 0; - - /** - * Classification value for private tasks. - */ - int CLASSIFICATION_PRIVATE = 1; - - /** - * Classification value for confidential tasks. - */ - int CLASSIFICATION_CONFIDENTIAL = 2; - - /** - * Default value of {@link #CLASSIFICATION}. - */ - Integer CLASSIFICATION_DEFAULT = null; - - /** - * Date of completion of this task in milliseconds since the epoch or {@code null} if this task has not been completed yet. - *

- * Value: Long - *

- */ - String COMPLETED = "completed"; - - /** - * Indicates that the date of completion is an all-day date. - *

- * Value: Integer - *

- */ - String COMPLETED_IS_ALLDAY = "completed_is_allday"; - - /** - * A number between 0 and 100 that indicates the progress of the task or null. - *

- * Value: Integer (0-100) - *

- */ - String PERCENT_COMPLETE = "percent_complete"; - - /** - * The status of this task. One of {@link #STATUS_NEEDS_ACTION},{@link #STATUS_IN_PROCESS}, {@link #STATUS_COMPLETED}, {@link #STATUS_CANCELLED}. - *

- * Value: Integer - *

- */ - String STATUS = "status"; - - /** - * A specific status indicating that nothing has been done yet. - */ - int STATUS_NEEDS_ACTION = 0; - - /** - * A specific status indicating that some work has been done. - */ - int STATUS_IN_PROCESS = 1; - - /** - * A specific status indicating that the task is completed. - */ - int STATUS_COMPLETED = 2; - - /** - * A specific status indicating that the task has been cancelled. - */ - int STATUS_CANCELLED = 3; - - /** - * The default status is "needs action". - */ - int STATUS_DEFAULT = STATUS_NEEDS_ACTION; - - /** - * A flag that indicates a task is new (i.e. not work has been done yet). This flag is read-only. Its value is 1 when - * {@link #STATUS} equals {@link #STATUS_NEEDS_ACTION} and 0 otherwise. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String IS_NEW = "is_new"; - - /** - * A flag that indicates a task is closed (no more work has to be done). This flag is read-only. Its value is 1 when - * {@link #STATUS} equals {@link #STATUS_COMPLETED} or {@link #STATUS_CANCELLED} and 0 otherwise. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String IS_CLOSED = "is_closed"; - - /** - * An individual color for this task in the format 0xaarrggbb or {@code null} to use {@link TaskListColumns#LIST_COLOR} instead. - *

- * Value: Integer - *

- */ - String TASK_COLOR = "task_color"; - - /** - * When this task starts in milliseconds since the epoch. - *

- * Value: Long - *

- */ - String DTSTART = "dtstart"; - - /** - * Boolean: flag that indicates that this is an all-day task. - */ - String IS_ALLDAY = "is_allday"; - - /** - * When this task has been created in milliseconds since the epoch. - *

- * Value: Long - *

- */ - String CREATED = "created"; - - /** - * When this task had been modified the last time in milliseconds since the epoch. - *

- * Value: Long - *

- */ - String LAST_MODIFIED = "last_modified"; - - /** - * String: An Olson Id of the time zone of this task. If this value is null, it's automatically replaced by the local time zone. - */ - String TZ = "tz"; - - /** - * When this task is due in milliseconds since the epoch. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task - * has no due date). - *

- * Value: Long - *

- */ - String DUE = "due"; - - /** - * The duration of this task. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task has no due date). Setting a - * {@link #DURATION} is not allowed when {@link #DTSTART} is null. The Value must be a duration string as in RFC 5545 Section 3.3.6. - *

- * Value: String - *

- */ - String DURATION = "duration"; - - /** - * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 - * and RFC 5545 Section 3.3.5) that contains dates of instances of e recurring task. - * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. - *

- * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - String RDATE = "rdate"; - - /** - * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 - * and RFC 5545 Section 3.3.5) that contains dates of exceptions of a recurring task. - * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. - *

- * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - String EXDATE = "exdate"; - - /** - * A recurrence rule as specified in RFC 5545 Section 3.3.10. - *

- * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - String RRULE = "rrule"; - - /** - * The _sync_id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or - * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. - *

- * Value: String - *

- */ - String ORIGINAL_INSTANCE_SYNC_ID = "original_instance_sync_id"; - - /** - * The row id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or - * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. - *

- * Value: Long - *

- */ - String ORIGINAL_INSTANCE_ID = "original_instance_id"; - - /** - * The time in milliseconds since the Epoch of the original instance that is overridden by this instance or null if this task is not a - * recurring instance. - *

- * Value: Long - *

- */ - String ORIGINAL_INSTANCE_TIME = "original_instance_time"; - - /** - * A flag indicating that the original instance was an all-day task. - *

- * Value: Integer - *

- */ - String ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"; - - /** - * The row id of the parent task. null if the task has no parent task. - *

- * Note, when writing this value the task {@link Property.Relation} properties are updated accordingly. Any parent or child relations which - * make this a child of another task are deleted and a new {@link Property.Relation#RELTYPE_PARENT} relation pointing to the new parent is created. - * Be aware that Siblings will be split, i.e. they are not moved to the new parent. Currently this might cause siblings to become orphans if they - * don't have a parent-child relationship. This behavior may change in future version. - *

- * - *

- * Value: Long - *

- */ - String PARENT_ID = "parent_id"; - - /** - * The sorting of this task under it's parent task. - *

- * Value: String - *

- */ - String SORTING = "sorting"; - - /** - * Indicates how many alarms a task has. 0 means the task has no alarms. This field is read only as it's set automatically. - *

- * Value: Integer - *

- * Read-only - */ - String HAS_ALARMS = "has_alarms"; - - /** - * Indicates that this task has extended properties like attachments, alarms or relations. This field is read only as it's set automatically. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String HAS_PROPERTIES = "has_properties"; - - /** - * Indicates that this task has been pinned to the notification area. This flag is moved to the exception when an exception for the first instance of a - * recurring task is created. That means, if you edit a pinned recurring task, the pinned flag is moved to the exception and cleared from the master - * task. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String PINNED = "pinned"; - } - - - /** - * Columns that are valid in a search query. - * - * @author Marten Gajda - */ - public interface TaskSearchColumns - { - /** - * The score of a task in a search result. It's an indicator for the relevance of the task. Value is in (0, 1.0] where 0 would be "no relevance" at all - * (though the result doesn't contain such tasks). - *

- * Value: Float - *

- */ - String SCORE = "score"; - } - - - /** - * The task table stores the data of all tasks. - * - * @author Marten Gajda - */ - public static final class Tasks implements TaskColumns, CommonSyncColumns, TaskSyncColumns, TaskSearchColumns - { - /** - * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; - - /** - * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; - - /** - * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_NAME = TaskLists.LIST_NAME; - /** - * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. To change the color of an individual task use {@code TASK_COLOR} instead. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_COLOR = TaskLists.LIST_COLOR; - - /** - * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_OWNER = TaskLists.OWNER; - - /** - * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; - - /** - * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String VISIBLE = "visible"; - - public static final String CONTENT_URI_PATH = "tasks"; - - public static final String SEARCH_URI_PATH = "tasks_search"; - - public static final String SEARCH_QUERY_PARAMETER = "q"; - - public static final String DEFAULT_SORT_ORDER = DUE; - - public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { - _DIRTY, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, _SYNC_ID, - SYNC_VERSION, }; - - - /** - * Get the tasks content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - - public static Uri getSearchUri(String authority, String query) - { - Uri.Builder builder = getUriFactory(authority).getUri(SEARCH_URI_PATH).buildUpon(); - builder.appendQueryParameter(SEARCH_QUERY_PARAMETER, Uri.encode(query)); - return builder.build(); - } - } - - - /** - * Columns of a task instance. - * - * @author Yannic Ahrens - * @author Marten Gajda - */ - public interface InstanceColumns - { - /** - * _ID of task this instance belongs to. - *

- * Value: Long - *

- */ - String TASK_ID = "task_id"; - - /** - * The start date of an instance in milliseconds since the epoch or null if the instance has no start date. At present this is read only. - *

- * Value: Long - *

- */ - String INSTANCE_START = "instance_start"; - - /** - * The due date of an instance in milliseconds since the epoch or null if the instance has no due date. At present this is read only. - *

- * Value: Long - *

- */ - String INSTANCE_DUE = "instance_due"; - - /** - * This column should be used in an order clause to sort instances by start date. The only guarantee about the values in this column is the sort order. - * Don't make any other assumptions about the value. - *

- * Value: Long - *

- *

- * read-only - *

- */ - String INSTANCE_START_SORTING = "instance_start_sorting"; - - /** - * This column should be used in an order clause to sort instances by due date. The only guarantee about the values in this column is the sort order. - * Don't make any other assumptions about the value. - *

- * Value: Long - *

- *

- * read-only - *

- */ - String INSTANCE_DUE_SORTING = "instance_due_sorting"; - - /** - * The duration of an instance in milliseconds or null if the instance has only one of start or due date or none of both. At present this - * is read only. - *

- * Value: Long - *

- */ - String INSTANCE_DURATION = "instance_duration"; - - /** - * The start of the original instance as specified in the master task. For non-recurring task instances this is {@code null}. - *

- * For recurring tasks, these are the timestamps which have been derived from the recurrence rule or dates, except those specified as exdates. - */ - String INSTANCE_ORIGINAL_TIME = "instance_original_time"; - - /** - * The distance of the instance from the current one. For closed instances this is always {@code -1}, for the current instance this is {@code 0}. For - * the instance after the current one this is {@code 1}, for the instance after that one it's {@code 2}, etc.. - *

- * Value: Integer - *

- * read-only - */ - String DISTANCE_FROM_CURRENT = "distance_from_current"; - } - - - /** - * A table containing one entry per task instance. This table is writable in order to allow modification of single instances of a task. Write operations to - * this table will be converted into operations on overrides and forwarded to the task table. - *

- * Note: The {@link #DTSTART}, {@link #DUE} values of instances of recurring tasks represent the actual instance values, i.e. they are different for each - * instance ({@link #DURATION} is always {@code null}). - *

- * Also, none of the instances are recurring themselves, so {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} are always {@code null}. - *

- * TODO: Insert all instances of recurring tasks. - *

- * The following operations are supported: - *

- *

Insert

- *

- * Note, the data of an insert must not contain the fields {@link #RRULE}, {@link #RDATE} or {@link #EXDATE}. If the new instance belongs to an existing - * task the data must contain the fields {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME}. Also note, this table supports writing {@link - * #DURATION} (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} - * {@link #DUE} date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. - *

- * If there already is an instance (with or without override) for the given {@link #ORIGINAL_INSTANCE_ID} and {@link #ORIGINAL_INSTANCE_TIME} an exception - * is thrown. - *

- *
ORIGINAL_INSTANCE_ID valueResult
absent or emptyA new non-recurring task is created with the given - * values.
a valid {@link Tasks} row {@code _ID}An {@link #RDATE} for the given {@link #ORIGINAL_INSTANCE_TIME} time is added to - * the given master task, any {@link #EXDATE} for this time is removed. The task is inserted as an override to the given master. No fields are inherited - * though. {@link #ORIGINAL_INSTANCE_ALLDAY} will be set to {@link #IS_ALLDAY} of the master. - *

- * Note, if the given master is non-recurring, this operation will turn it into a recurring task.

invalid {@link Tasks} row {@code - * _ID}An exception is thrown.
- *

- *

Update

- *

- * Note, the data of an update must not contain any fields related to recurrence ({@link #RRULE}, {@link #RDATE}, {@link #EXDATE}, {@link - * #ORIGINAL_INSTANCE_ID}, {@link #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY}). Also note, this table supports writing {@link #DURATION} - * (if the instance has a {@link #DTSTART}), but reading it back will always return a {@code null} {@link #DURATION} and a non-{@code null} {@link #DUE} - * date. Reading the task in the tasks table will, however, return the original {@link #DURATION}. - *

- * - *
Target task typeResult
Recurring master taskA new override is created with the given data.

Note, - * any fields which are not provided are inherited from the master, except for {@link #DTSTART} and {@link #DUE} which will be inherited from the instance - * and {@link #DURATION}, {@link #RRULE}, {@link #RDATE} and {@link #EXDATE} which are set to {@code null}. {@link #ORIGINAL_INSTANCE_ID}, {@link - * #ORIGINAL_INSTANCE_TIME} and {@link #ORIGINAL_INSTANCE_ALLDAY} will be set accordingly.

Single instance taskThe task is - * updated with the given values.
Recurrence override with existing masterThe task is updated with the given values.
Recurrence override without existing masterThe task is updated with the given values.
- *

- *

Delete

- *

- * - * - *
Target task typeResult
Recurring master taskAn {@link #EXDATE} for this instance is added, any {@link - * #RDATE} for this instance is removed. The instance row is removed.

TODO: mark the task deleted if the remaining recurrence set is empty

Single instance taskThe {@link Tasks#_DELETED} flag of the task is set.
Recurrence override with existing - * masterThe {@link Tasks#_DELETED} flag of the override is set, an {@link #EXDATE} for this instance is added to the master, any {@link #RDATE} - * for this instance is removed from the master. TODO: mark the master deleted if the remaining recurrence set of the master is empty
Recurrence override without existing masterThe {@link Tasks#_DELETED} flag of the task is set.
- * - * @author Yannic Ahrens - * @author Marten Gajda - */ - public static final class Instances implements TaskColumns, InstanceColumns - { - - /** - * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; - - /** - * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; - - /** - * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_NAME = TaskLists.LIST_NAME; - /** - * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. To change the color of an individual task use {@code TASK_COLOR} instead. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_COLOR = TaskLists.LIST_COLOR; - - /** - * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_OWNER = TaskLists.OWNER; - - /** - * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; - - /** - * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String VISIBLE = "visible"; - - /** - * Flag indicating that ths is an instance of a recurring task. - *

- * Value: Integer - *

- * read-only - */ - public static final String IS_RECURRING = "is_recurring"; - - public static final String CONTENT_URI_PATH = "instances"; - - public static final String DEFAULT_SORT_ORDER = INSTANCE_DUE_SORTING; - - - /** - * Get the instances content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - /** - * Available values in Categories. - *

- * Categories are per account. It's up to the front-end to ensure consistency of category colors across accounts. - * - * @author Marten Gajda - */ - public interface CategoriesColumns - { - - String _ID = "_id"; - - String ACCOUNT_NAME = "account_name"; - - String ACCOUNT_TYPE = "account_type"; - - String NAME = "name"; - - String COLOR = "color"; - } - - - public static final class Categories implements CategoriesColumns - { - - public static final String CONTENT_URI_PATH = "categories"; - - public static final String DEFAULT_SORT_ORDER = NAME; - - - /** - * Get the categories content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - public interface AlarmsColumns - { - String ALARM_ID = "alarm_id"; - - String LAST_TRIGGER = "last_trigger"; - - String NEXT_TRIGGER = "next_trigger"; - } - - - public static final class Alarms implements AlarmsColumns - { - - public static final String CONTENT_URI_PATH = "alarms"; - - - /** - * Get the alarms content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - public interface PropertySyncColumns - { - String SYNC1 = "prop_sync1"; - - String SYNC2 = "prop_sync2"; - - String SYNC3 = "prop_sync3"; - - String SYNC4 = "prop_sync4"; - - String SYNC5 = "prop_sync5"; - - String SYNC6 = "prop_sync6"; - - String SYNC7 = "prop_sync7"; - - String SYNC8 = "prop_sync8"; - } - - - public interface PropertyColumns - { - - String PROPERTY_ID = "property_id"; - - String TASK_ID = "task_id"; - - String MIMETYPE = "mimetype"; - - String VERSION = "prop_version"; - - String DATA0 = "data0"; - - String DATA1 = "data1"; - - String DATA2 = "data2"; - - String DATA3 = "data3"; - - String DATA4 = "data4"; - - String DATA5 = "data5"; - - String DATA6 = "data6"; - - String DATA7 = "data7"; - - String DATA8 = "data8"; - - String DATA9 = "data9"; - - String DATA10 = "data10"; - - String DATA11 = "data11"; - - String DATA12 = "data12"; - - String DATA13 = "data13"; - - String DATA14 = "data14"; - - String DATA15 = "data15"; - } - - - public static final class Properties implements PropertySyncColumns, PropertyColumns - { - - public static final String CONTENT_URI_PATH = "properties"; - - public static final String DEFAULT_SORT_ORDER = DATA0; - - - /** - * Get the properties content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * - * @return A {@link Uri}. - */ - public static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - - public interface Property - { - /** - * Attached documents. - *

- * Note: Attachments are write-once. To change an attachment you'll have to remove and re-add it. - *

- * - * @author Marten Gajda - */ - interface Attachment extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attachment"; - - /** - * URL of the attachment. This is the link that points to the attached resource. - *

- * Value: String - *

- */ - String URL = DATA1; - - /** - * The display name of the attachment, if any. - *

- * Value: String - *

- */ - String DISPLAY_NAME = DATA2; - - /** - * Content-type of the attachment. - *

- * Value: String - *

- */ - String FORMAT = DATA3; - - /** - * File size of the attachment or -1 if unknown. - *

- * Value: Long - *

- */ - String SIZE = DATA4; - - /** - * A content {@link Uri} that can be used to retrieve the attachment. Sync adapters can set this field if they know how to download the attachment - * without going through the browser. - *

- * Value: String - *

- */ - String CONTENT_URI = DATA5; - - } - - - interface Attendee extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attendee"; - - /** - * Name of the contact, if known. - *

- * Value: String - *

- */ - String NAME = DATA0; - - /** - * Email address of the contact. - *

- * Value: String - *

- */ - String EMAIL = DATA1; - - String ROLE = DATA2; - - String STATUS = DATA3; - - String RSVP = DATA4; - } - - - /** - * Categories are immutable. For creation is either the category id or name necessary - */ - interface Category extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/category"; - - /** - * Row id of the category. - *

- * Value: Long - *

- */ - String CATEGORY_ID = DATA0; - - /** - * The name of the category - *

- * Value: String - *

- */ - String CATEGORY_NAME = DATA1; - - /** - * The decimal coded color of the category - *

- * Value: Integer - *

- *

- * read-only - *

- */ - String CATEGORY_COLOR = DATA2; - } - - - interface Comment extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/comment"; - - /** - * Comment text. - *

- * Value: String - *

- */ - String COMMENT = DATA0; - - /** - * Language code of the comment as defined in RFC5646 or null. - *

- * Value: String - *

- */ - String LANGUAGE = DATA1; - } - - - interface Contact extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/contact"; - - String NAME = DATA0; - - String LANGUAGE = DATA1; - } - - - /** - * Relations of a task. - *

- * When writing a relation, exactly one of {@link #RELATED_ID} or {@link #RELATED_UID} must be present. The missing value and {@link - * #RELATED_CONTENT_URI} will be populated automatically if possible. - *

- * {@link Tasks#PARENT_ID} is updated automatically if possible. - */ - interface Relation extends PropertyColumns - { - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/relation"; - - /** - * The row id of the related task. May be -1 if the property doesn't refer to a task in this database or if it doesn't refer to a task - * at all. - *

- * Value: long - *

- */ - String RELATED_ID = DATA1; - - /** - * The relation type. This must be one of the {@code RELTYPE_*} values. - *

- * Value: int - *

- */ - String RELATED_TYPE = DATA2; - - /** - * The UID of the related object. - *

- * Value: String - *

- */ - String RELATED_UID = DATA3; - - /** - * The content Uri of a related object in another Android content provider, if found. - *

- * Value: String (URI) - *

- *

- * This field is read-only. - *

- */ - String RELATED_CONTENT_URI = DATA5; - - /** - * The related object is the parent of the object owning this relation. - */ - int RELTYPE_PARENT = 0; - - /** - * The related object is the child of the object owning this relation. - */ - int RELTYPE_CHILD = 1; - - /** - * The related object is a sibling of the object owning this relation. - */ - int RELTYPE_SIBLING = 2; - - } - - - interface Alarm extends PropertyColumns - { - - int ALARM_TYPE_NOTHING = 0; - - int ALARM_TYPE_MESSAGE = 1; - - int ALARM_TYPE_EMAIL = 2; - - int ALARM_TYPE_SMS = 3; - - int ALARM_TYPE_SOUND = 4; - - int ALARM_REFERENCE_DUE_DATE = 1; - - int ALARM_REFERENCE_START_DATE = 2; - - /** - * The mime-type of this property. - */ - String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/alarm"; - - /** - * Number of minutes from the reference date when the alarm goes off. If the value is < 0 the alarm will go off after the reference date. - *

- * Value: Integer - *

- */ - String MINUTES_BEFORE = DATA0; - - /** - * The reference date for the alarm. Either {@link #ALARM_REFERENCE_DUE_DATE} or {@link #ALARM_REFERENCE_START_DATE}. - *

- * Value: Integer - *

- */ - String REFERENCE = DATA1; - - /** - * A message that appears with the alarm. - *

- * Value: String - *

- */ - String MESSAGE = DATA2; - - /** - * The type of the alarm. Use the provided alarm types {@link #ALARM_TYPE_MESSAGE}, {@link #ALARM_TYPE_SOUND}, {@link #ALARM_TYPE_NOTHING}, - * {@link #ALARM_TYPE_EMAIL} and {@link #ALARM_TYPE_SMS}. - *

- * Value: Integer - *

- */ - String ALARM_TYPE = DATA3; - } - - } - - - private static synchronized UriFactory getUriFactory(String authority) - { - UriFactory uriFactory = sUriFactories.get(authority); - if (uriFactory == null) - { - uriFactory = new UriFactory(authority); - uriFactory.addUri(SyncState.CONTENT_URI_PATH); - uriFactory.addUri(TaskLists.CONTENT_URI_PATH); - uriFactory.addUri(Tasks.CONTENT_URI_PATH); - uriFactory.addUri(Tasks.SEARCH_URI_PATH); - uriFactory.addUri(Instances.CONTENT_URI_PATH); - uriFactory.addUri(Categories.CONTENT_URI_PATH); - uriFactory.addUri(Alarms.CONTENT_URI_PATH); - uriFactory.addUri(Properties.CONTENT_URI_PATH); - sUriFactories.put(authority, uriFactory); - - } - return uriFactory; - } - -} diff --git a/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java b/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java deleted file mode 100644 index 0092aca..0000000 --- a/provider/src/main/java/org/dmfs/tasks/contract/UriFactory.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.tasks.contract; - -import android.net.Uri; - -import java.util.HashMap; -import java.util.Map; - - -/** - * TODO - */ -public final class UriFactory -{ - private final String mAuthority; - private final Map mUriMap = new HashMap(16); - - - UriFactory(String authority) - { - mAuthority = authority; - mUriMap.put(null, Uri.parse("content://" + authority)); - } - - - void addUri(String path) - { - mUriMap.put(path, Uri.parse("content://" + mAuthority + "/" + path)); - } - - - Uri getUri() - { - return mUriMap.get(null); - } - - - Uri getUri(String path) - { - return mUriMap.get(path); - } -} diff --git a/provider/src/main/res/drawable/ic_24_agendula_tasks.xml b/provider/src/main/res/drawable/ic_24_agendula_tasks.xml deleted file mode 100644 index a5420b4..0000000 --- a/provider/src/main/res/drawable/ic_24_agendula_tasks.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/provider/src/main/res/values-cs/strings.xml b/provider/src/main/res/values-cs/strings.xml deleted file mode 100644 index 9b043e6..0000000 --- a/provider/src/main/res/values-cs/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - přečíst úkoly - Číst úkoly a seznamy úkolů - zapsat úkoly - Vytvořit, změnit a smazat úkoly a seznamy úkolů - Vytvořit, změnit a smazat úkoly a seznamy úkolů - - diff --git a/provider/src/main/res/values-de/strings.xml b/provider/src/main/res/values-de/strings.xml deleted file mode 100644 index 2f16cc7..0000000 --- a/provider/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - Aufgaben lesen - Aufgaben lesen - Aufgaben schreiben - Aufgaben und Aufgabenlisten erstellen, bearbeiten und löschen - Aufgaben lesen und verwalten - - diff --git a/provider/src/main/res/values-es/strings.xml b/provider/src/main/res/values-es/strings.xml deleted file mode 100644 index c2808fb..0000000 --- a/provider/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - leer tareas - Leer tareas y listas de tareas - escribir tareas - Crear, modificar y borrar tareas y listas de tareas - Crear, modificar y borrar tareas y listas de tareas - - diff --git a/provider/src/main/res/values-fr/strings.xml b/provider/src/main/res/values-fr/strings.xml deleted file mode 100644 index 165e07d..0000000 --- a/provider/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - Lire tâches - Autoriser une application à lire les tâches de la listes de tâches - Écrire tâches - Autoriser une application à écrire des tâches dans la listes de tâches - Autoriser une application à écrire des tâches dans la listes de tâches - - diff --git a/provider/src/main/res/values-hu/strings.xml b/provider/src/main/res/values-hu/strings.xml deleted file mode 100644 index 5f73108..0000000 --- a/provider/src/main/res/values-hu/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - feladatok olvasása - Feladatok és feladatlisták olvasása - feladatok írása - Feladatok és feladatlisták létrehozása, módosítása és törlése - Feladatok és feladatlisták létrehozása, módosítása és törlése - - diff --git a/provider/src/main/res/values-it/strings.xml b/provider/src/main/res/values-it/strings.xml deleted file mode 100644 index c321b6f..0000000 --- a/provider/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - lettura attività - lettura attività ed elenchi di attività - scrittura attività - creazione, modifica e eliminazione di attività ed elenchi di attività - accesso e modifica delle attività - - diff --git a/provider/src/main/res/values-ja/strings.xml b/provider/src/main/res/values-ja/strings.xml deleted file mode 100644 index 5a6f8b7..0000000 --- a/provider/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - タスクを読む - タスクとタスクリストを読み込みます。 - タスクを書く - タスクとタスクリストを作成、変更、削除します。 - タスクとタスクリストを作成、変更、削除します。 - - diff --git a/provider/src/main/res/values-nl/strings.xml b/provider/src/main/res/values-nl/strings.xml deleted file mode 100644 index cd41d16..0000000 --- a/provider/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - leestaken - Staat een app toe om taken in je takenlijst te lezen - schrijftaken - Staat een app toe om taken in je takenlijst aan te maken - Staat een app tpe om taken in je takenlijst aan te maken - - diff --git a/provider/src/main/res/values-pl/strings.xml b/provider/src/main/res/values-pl/strings.xml deleted file mode 100644 index 41f57c1..0000000 --- a/provider/src/main/res/values-pl/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - Czytaj zadania - Pozwala aplikacji na czytanie zadań z Twojej listy zadań - Zapisuj zadania - Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań - Pozwala aplikacji na zapisywanie zadań na Twojej liście zadań - - diff --git a/provider/src/main/res/values-pt-rBR/strings.xml b/provider/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index c93660c..0000000 --- a/provider/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - ler tarefas - Permite que um aplicativo leia as tarefas na sua lista de tarefas - escrever tarefas - Permite que um aplicativo escreva tarefas na sua lista de tarefas - Permite que um aplicativo escreva tarefas na sua lista de tarefas - - \ No newline at end of file diff --git a/provider/src/main/res/values-pt-rPT/strings.xml b/provider/src/main/res/values-pt-rPT/strings.xml deleted file mode 100644 index 73ea9e8..0000000 --- a/provider/src/main/res/values-pt-rPT/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - ler tarefas - Ler tarefas e listas de tarefas - escrever tarefas - Criar, modificar e eliminar tarefas e listas de tarefas - Criar, modificar e eliminar tarefas e listas de tarefas - - diff --git a/provider/src/main/res/values-ru/strings.xml b/provider/src/main/res/values-ru/strings.xml deleted file mode 100644 index 4b8d77f..0000000 --- a/provider/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - чтение задач - Разрешить приложению читать задачи из Ваших списков задач - запись задач - Разрешить приложению сохранять задачи в Ваших списках задач - Разрешить приложению сохранять задачи в Ваших списках задач - - diff --git a/provider/src/main/res/values-sr/strings.xml b/provider/src/main/res/values-sr/strings.xml deleted file mode 100644 index 3562f79..0000000 --- a/provider/src/main/res/values-sr/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - читање задатака - Дозвољава апликацији да чита задатке са ваше листе задатака - упис задатака - Дозвољава апликацији да уноси задатке у вашу листу задатака - Дозвољава апликацији да уноси задатке у вашу листу задатака - - diff --git a/provider/src/main/res/values-uk/strings.xml b/provider/src/main/res/values-uk/strings.xml deleted file mode 100644 index 1cdb330..0000000 --- a/provider/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - читання завдань - Дозволити додатку читати ваші завдання з ваших переліків завдань - запис завдань - Дозволити додатку зберігати завдання до ваших переліків завдань - Дозволити додатку зберігати завдання до ваших переліків завдань - - diff --git a/provider/src/main/res/values/agendula_defaults.xml b/provider/src/main/res/values/agendula_defaults.xml deleted file mode 100644 index 816a10b..0000000 --- a/provider/src/main/res/values/agendula_defaults.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - de.jeanlucmakiola.agendula.tasks - - diff --git a/provider/src/main/res/values/agendula_provider_changed_receivers.xml b/provider/src/main/res/values/agendula_provider_changed_receivers.xml deleted file mode 100644 index a4ac2ab..0000000 --- a/provider/src/main/res/values/agendula_provider_changed_receivers.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/provider/src/main/res/values/strings.xml b/provider/src/main/res/values/strings.xml deleted file mode 100644 index a65c33b..0000000 --- a/provider/src/main/res/values/strings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - Agendula tasks - - - - read tasks - read tasks and task lists - write tasks - create, modify and delete tasks and task lists - access and manage tasks - - diff --git a/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java b/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java deleted file mode 100644 index 04c332b..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/ProviderAccountCleanupTest.java +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright 2026 Jean-Luc Makiola - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks; - -import android.accounts.Account; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; - -import org.dmfs.tasks.contract.TaskContract; -import org.dmfs.tasks.contract.TaskContract.TaskLists; -import org.dmfs.tasks.contract.TaskContract.Tasks; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ContentProviderController; -import org.robolectric.RuntimeEnvironment; - -import de.jeanlucmakiola.agendula.provider.R; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeFalse; - - -/** - * Agendula's own test, not upstream's. - *

- * Local-only mode is the default Agendula ships, and it rests on a claim worth pinning down: that the provider works with no account on the device at - * all. That is open question 3 in {@code docs/STORAGE-AND-SYNC.md}, and it is not obvious — we dropped {@code GET_ACCOUNTS} from a provider whose list - * cleanup was written assuming it, so the failure this guards against is the provider quietly deleting the user's task lists. See change 1 in - * {@code provider/PROVENANCE.md}. - *

- * Robolectric, so this is not a substitute for running it on a device — but it does hold the invariant against future edits to the cleanup path. - * - * @author Jean-Luc Makiola - */ -@RunWith(RobolectricTestRunner.class) -public class ProviderAccountCleanupTest -{ - private ContentProviderController mController; - private ContentResolver mResolver; - private String mAuthority; - - - @Before - public void setUp() - { - // Robolectric ships no aarch64 SQLite in either backend: the native runtime refuses outright and the LEGACY sqlite4java shadow throws - // "Architecture 'aarch64' is not supported". Every other test in this module is architecture-independent; this is the only one that opens a - // database. Skipping beats failing on an ARM64 dev machine — but note that means these assertions are only actually exercised on x86_64, which - // CI is. If this ever starts skipping in CI, the invariant has stopped being checked at all. - assumeFalse( - "Robolectric has no SQLite backend for aarch64 — this class only runs on x86_64", - System.getProperty("os.arch", "").contains("aarch64")); - - Context context = RuntimeEnvironment.getApplication(); - Utils.clearOwnAccountTypesCache(); - mAuthority = context.getString(R.string.agendula_tasks_authority); - mController = Robolectric.buildContentProvider(TaskProvider.class).create(mAuthority); - mResolver = context.getContentResolver(); - } - - - @After - public void tearDown() - { - Utils.clearOwnAccountTypesCache(); - // Null when setUp bailed out on the architecture assumption above. - if (mController != null) - { - mController.shutdown(); - } - } - - - /** - * The authority is ours, not dmfs's. Guards against a resource merge or a careless resync quietly handing our provider back to {@code org.dmfs.tasks}, - * which would make Agendula and OpenTasks mutually uninstallable. - */ - @Test - public void authorityIsOurs() - { - assertEquals("de.jeanlucmakiola.agendula.tasks", mAuthority); - } - - - /** - * The whole of Local mode: create a local list and a task in it with no account anywhere on the device, and read both back. - */ - @Test - public void localListAndTaskSurviveWithNoAccount() - { - Uri listUri = insertLocalList("Groceries"); - assertNotNull(listUri); - - long listId = Long.parseLong(listUri.getLastPathSegment()); - ContentValues task = new ContentValues(); - task.put(Tasks.LIST_ID, listId); - task.put(Tasks.TITLE, "Oat milk"); - Uri taskUri = mResolver.insert(Tasks.getContentUri(mAuthority), task); - assertNotNull(taskUri); - - assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); - assertEquals(1, countRows(Tasks.getContentUri(mAuthority))); - } - - - /** - * The bug this exists for. An account-update sweep reporting zero accounts — exactly what we see without {@code GET_ACCOUNTS} — must not take a synced - * list with it. Upstream would delete this list. - */ - @Test - public void cleanUpDoesNotPruneListsOfAccountTypesWeDoNotAuthenticate() - { - insertSyncedList("Shared shopping", "someone@example.org", "bitfire.at.davdroid"); - assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); - - // No authenticator in this package, so nothing is prunable and no account is visible. - Utils.cleanUpLists( - RuntimeEnvironment.getApplication(), - mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), - new Account[0], - mAuthority); - - assertEquals( - "a list whose account type we cannot enumerate must never be pruned", - 1, - countRows(TaskLists.getContentUri(mAuthority))); - } - - - /** - * Local lists are exempt from cleanup regardless — upstream's rule, restated here because Local mode depends on it and it is easy to lose in a resync. - */ - @Test - public void cleanUpNeverPrunesLocalLists() - { - insertLocalList("Groceries"); - - Utils.cleanUpLists( - RuntimeEnvironment.getApplication(), - mController.get().getDatabaseHelper(RuntimeEnvironment.getApplication()).getWritableDatabase(), - new Account[0], - mAuthority); - - assertEquals(1, countRows(TaskLists.getContentUri(mAuthority))); - } - - - /** - * With no authenticator of our own, the prunable set is empty — which is what makes the two tests above hold by construction rather than by luck. - */ - @Test - public void weAuthenticateNoAccountTypesYet() - { - assertTrue(Utils.ownAccountTypes(RuntimeEnvironment.getApplication()).isEmpty()); - } - - - private Uri insertLocalList(String name) - { - return insertSyncedList(name, TaskContract.LOCAL_ACCOUNT_NAME, TaskContract.LOCAL_ACCOUNT_TYPE); - } - - - private Uri insertSyncedList(String name, String accountName, String accountType) - { - ContentValues values = new ContentValues(); - values.put(TaskLists.LIST_NAME, name); - values.put(TaskLists.VISIBLE, 1); - values.put(TaskLists.SYNC_ENABLED, 1); - return mResolver.insert(asSyncAdapter(TaskLists.getContentUri(mAuthority), accountName, accountType), values); - } - - - private static Uri asSyncAdapter(Uri uri, String accountName, String accountType) - { - return uri.buildUpon() - .appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true") - .appendQueryParameter(TaskContract.ACCOUNT_NAME, accountName) - .appendQueryParameter(TaskContract.ACCOUNT_TYPE, accountType) - .build(); - } - - - private int countRows(Uri uri) - { - try (Cursor cursor = mResolver.query(uri, null, null, null, null)) - { - assertNotNull(cursor); - return cursor.getCount(); - } - } -} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java b/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java deleted file mode 100644 index 29ff809..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/model/adapters/DateTimeIterableFieldAdapterTest.java +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright 2018 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.model.adapters; - -import android.content.ContentValues; - -import org.dmfs.iterables.EmptyIterable; -import org.dmfs.iterables.elementary.Seq; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.rfc5545.DateTime; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class DateTimeIterableFieldAdapterTest -{ - @Test - public void testFieldName() - { - assertThat(new DateTimeIterableFieldAdapter<>("x", "y").fieldName(), is("x")); - } - - - @Test - public void testGetFromCVAllDay1() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"))); - } - - - @Test - public void testGetFromCVAllDay2() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109,20180110"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109"), DateTime.parse("20180110"))); - } - - - @Test - public void testGetFromCVFloating1() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109T140000"); - values.putNull("y"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"))); - } - - - @Test - public void testGetFromCVFloating2() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109T140000,20180110T140000"); - values.putNull("y"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("20180109T140000"), DateTime.parse("20180110T140000"))); - } - - - @Test - public void testGetFromCVAbsolute1() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109T140000Z"); - values.put("y", "Europe/Berlin"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"))); - } - - - @Test - public void testGetFromCVAbsolute2() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - values.put("x", "20180109T140000Z,20180110T140000Z"); - values.put("y", "Europe/Berlin"); - assertThat(adapter.getFrom(values), iteratesTo(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); - } - - - @Test - public void testSetInNull() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, null); - assertThat(values.getAsString("x"), nullValue()); - } - - - @Test - public void testSetInEmpty() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, EmptyIterable.instance()); - assertThat(values.getAsString("x"), nullValue()); - } - - - @Test - public void testSetInSingleAllDay() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109"))); - assertThat(values.getAsString("x"), is("20180109")); - } - - - @Test - public void testSetInSingleFloating() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"))); - assertThat(values.getAsString("x"), is("20180109T150000")); - } - - - @Test - public void testSetInSingleAbsolute() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"))); - assertThat(values.getAsString("x"), is("20180109T140000Z")); - } - - - @Test - public void testSetInDoubleAllDay() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"))); - assertThat(values.getAsString("x"), is("20180109,20180110")); - } - - - @Test - public void testSetInDoubleFloating() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"))); - assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000")); - } - - - @Test - public void testSetInDoubleAbsolute() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"))); - assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z")); - } - - - @Test - public void testSetInMultiAllDay() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109"), DateTime.parse("20180110"), DateTime.parse("20180111"))); - assertThat(values.getAsString("x"), is("20180109,20180110,20180111")); - } - - - @Test - public void testSetInMultiFloating() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("20180109T150000"), DateTime.parse("20180110T150000"), DateTime.parse("20180111T150000"))); - assertThat(values.getAsString("x"), is("20180109T150000,20180110T150000,20180111T150000")); - } - - - @Test - public void testSetInMultiAbsolute() - { - ContentValues values = new ContentValues(); - FieldAdapter, ?> adapter = new DateTimeIterableFieldAdapter("x", "y"); - adapter.setIn(values, new Seq<>(DateTime.parse("Europe/Berlin", "20180109T150000"), DateTime.parse("Europe/Berlin", "20180110T150000"), - DateTime.parse("Europe/Berlin", "20180111T150000"))); - assertThat(values.getAsString("x"), is("20180109T140000Z,20180110T140000Z,20180111T140000Z")); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java deleted file mode 100644 index 4d60f5b..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DatedTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.rfc5545.DateTime; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.optional.elementary.Absent.absent; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class DatedTest -{ - - @Test - public void testAbsent() - { - ContentValues instanceData = new Dated(absent(), "ts", "sorting", ContentValues::new).value(); - // this shouldn't really add any values and go by the "defaults" - assertThat(instanceData.size(), is(0)); - } - - - @Test - public void testPresent() - { - DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); - - ContentValues instanceData = new Dated(new Present<>(start), "ts", "sorting", ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong("ts", start.getTimestamp())); - assertThat(instanceData, new ContentValuesWithLong("sorting", start.getInstance())); - assertThat(instanceData.size(), is(2)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java deleted file mode 100644 index c8be962..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DistantTest.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class DistantTest -{ - - @Test - public void test() - { - ContentValues instanceData = new Distant(100, ContentValues::new).value(); - assertThat(instanceData.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(100)); - assertThat(instanceData.size(), is(1)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java deleted file mode 100644 index 25840e9..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/DueDatedTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.util.TimeZone; - -import static org.dmfs.jems.optional.elementary.Absent.absent; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class DueDatedTest -{ - - @Test - public void testNone() - { - ContentValues instanceData = new DueDated(absent(), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, nullValue(Long.class))); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, nullValue(Long.class))); - // this doesn't actually add anything, the ContentValues are expected to contain null values. - assertThat(instanceData.size(), is(0)); - } - - - @Test - public void testStartEurope() - { - DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); - - ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); - assertThat(instanceData, - new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); - assertThat(instanceData.size(), is(2)); - } - - - @Test - public void testStartAmerica() - { - DateTime start = DateTime.parse("America/New_York", "20171208T125500"); - - ContentValues instanceData = new DueDated(new Present<>(start), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE, start.getTimestamp())); - assertThat(instanceData, - new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DUE_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); - assertThat(instanceData.size(), is(2)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java deleted file mode 100644 index 4fbd8f5..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/EnduringTest.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class EnduringTest -{ - @Test - public void testNoValue() - { - assertThat(new Enduring(ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); - assertThat(new Enduring(ContentValues::new).value().size(), is(1)); - } - - - @Test - public void testStartValue() - { - ContentValues values = new ContentValues(1); - values.put(TaskContract.Instances.INSTANCE_START, 10); - assertThat(new Enduring(() -> new ContentValues(values)), - hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); - assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); - } - - - @Test - public void testDueValue() - { - ContentValues values = new ContentValues(1); - values.put(TaskContract.Instances.INSTANCE_DUE, 10); - assertThat(new Enduring(() -> new ContentValues(values)), - hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, nullValue(Long.class)))); - assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(2)); - } - - - @Test - public void testStartDueValue() - { - ContentValues values = new ContentValues(2); - values.put(TaskContract.Instances.INSTANCE_START, 1); - values.put(TaskContract.Instances.INSTANCE_DUE, 10); - assertThat(new Enduring(() -> new ContentValues(values)), hasValue(new ContentValuesWithLong(TaskContract.Instances.INSTANCE_DURATION, 9))); - assertThat(new Enduring(() -> new ContentValues(values)).value().size(), is(3)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java deleted file mode 100644 index e56d8a4..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/OverriddenTest.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.optional.Absent.absent; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class OverriddenTest -{ - @Test - public void testAbsent() - { - ContentValues instanceData = new Overridden(absent(), ContentValues::new).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); - assertThat(instanceData.size(), is(0)); - } - - - @Test - public void testAbsentWithStart() - { - ContentValues values = new ContentValues(); - values.put(TaskContract.Instances.INSTANCE_START, 10); - - ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); - assertThat(instanceData.size(), is(1)); - } - - - @Test - public void testAbsentWithDue() - { - ContentValues values = new ContentValues(); - values.put(TaskContract.Instances.INSTANCE_DUE, 20); - - ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); - assertThat(instanceData.size(), is(1)); - } - - - @Test - public void testAbsentWithStartAndDue() - { - ContentValues values = new ContentValues(); - values.put(TaskContract.Instances.INSTANCE_START, 10); - values.put(TaskContract.Instances.INSTANCE_DUE, 20); - - ContentValues instanceData = new Overridden(absent(), () -> new ContentValues(values)).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, nullValue(Long.class))); - assertThat(instanceData.size(), is(2)); - } - - - @Test - public void testPresent() - { - - ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), ContentValues::new).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); - assertThat(instanceData.size(), is(1)); - } - - - @Test - public void testPresentWithStartAndDue() - { - ContentValues values = new ContentValues(); - values.put(TaskContract.Instances.INSTANCE_START, 10); - values.put(TaskContract.Instances.INSTANCE_DUE, 20); - - ContentValues instanceData = new Overridden(new Present<>(new DateTime(40)), () -> new ContentValues(values)).value(); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, 40)); - assertThat(instanceData.size(), is(3)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java deleted file mode 100644 index 85676d1..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/StartDatedTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.util.TimeZone; - -import static org.dmfs.optional.Absent.absent; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class StartDatedTest -{ - - @Test - public void testNone() - { - ContentValues instanceData = new StartDated(absent(), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, nullValue(Long.class))); - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, nullValue(Long.class))); - // this doesn't actually add anything, the ContentValues are expected to contain null values. - assertThat(instanceData.size(), is(0)); - } - - - @Test - public void testStartEurope() - { - DateTime start = DateTime.parse("Europe/Berlin", "20171208T125500"); - - ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); - assertThat(instanceData, - new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); - assertThat(instanceData.size(), is(2)); - } - - - @Test - public void testStartAmerica() - { - DateTime start = DateTime.parse("America/New_York", "20171208T125500"); - - ContentValues instanceData = new StartDated(new Present<>(start), ContentValues::new).value(); - - assertThat(instanceData, new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START, start.getTimestamp())); - assertThat(instanceData, - new ContentValuesWithLong(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance())); - assertThat(instanceData.size(), is(2)); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java deleted file mode 100644 index 760ca34..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/TaskRelatedTest.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.provider.tasks.utils.ContentValuesWithLong; -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class TaskRelatedTest -{ - @Test - public void testValue() - { - assertThat(new TaskRelated(123, ContentValues::new), hasValue(new ContentValuesWithLong(TaskContract.Instances.TASK_ID, 123))); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java b/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java deleted file mode 100644 index f78f8bf..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/processors/tasks/instancedata/VanillaInstanceDataTest.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.processors.tasks.instancedata; - -import android.content.ContentValues; - -import org.dmfs.tasks.contract.TaskContract; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class VanillaInstanceDataTest -{ - @Test - public void testValue() - { - ContentValues values = new VanillaInstanceData().value(); - assertThat(values.get(TaskContract.Instances.INSTANCE_START), nullValue()); - assertThat(values.get(TaskContract.Instances.INSTANCE_START_SORTING), nullValue()); - assertThat(values.get(TaskContract.Instances.INSTANCE_DUE), nullValue()); - assertThat(values.get(TaskContract.Instances.INSTANCE_DUE_SORTING), nullValue()); - assertThat(values.get(TaskContract.Instances.INSTANCE_DURATION), nullValue()); - assertThat(values.get(TaskContract.Instances.DISTANCE_FROM_CURRENT), is(0)); - assertThat(values.get(TaskContract.Instances.INSTANCE_ORIGINAL_TIME), nullValue()); - assertThat(values.size(), is(7)); - } - -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java deleted file mode 100644 index 750f477..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2019 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; -import android.database.MatrixCursor; - -import org.dmfs.iterables.elementary.Seq; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.hamcrest.matchers.predicate.PredicateMatcher.satisfiedBy; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class ContainsValuesTest -{ - @Test - public void test() - { - ContentValues values = new ContentValues(); - values.put("a", 123); - values.put("b", "stringValue"); - values.put("c", new byte[] { 3, 2, 1 }); - values.putNull("d"); - - MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b", "a", "d", "f" }); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, null, "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", "123", null, "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2 }, "stringValue", 123, null, "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValueX", 123, null, "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 1234, null, "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, "123", "xyz")); - cursor.addRow(new Seq<>(321, "stringValueX", "1234", "123", "xyz")); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1, 0 }, "stringValueX", 1234, "123", "xyz")); - - cursor.moveToFirst(); - assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - cursor.moveToNext(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - } - - - @Test - public void testMissingColumns() - { - ContentValues values = new ContentValues(); - values.put("a", 123); - values.put("b", "stringValue"); - values.put("c", new byte[] { 3, 2, 1 }); - values.putNull("d"); - - MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b" }); - cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue")); - - cursor.moveToFirst(); - assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java deleted file mode 100644 index 6dafc00..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/utils/ContentValuesWithLong.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; - -import org.hamcrest.FeatureMatcher; -import org.hamcrest.Matcher; - -import static org.hamcrest.Matchers.is; - - -/** - * A {@link Matcher} to test if {@link ContentValues} contain a specific Long value. - *

- * TODO: can we convert that into a more generic {@link ContentValues} matcher? It might be useful in other places. - *

- * TODO: also consider moving this to "Test-Bolts" - */ -public final class ContentValuesWithLong extends FeatureMatcher -{ - private final String mKey; - - - public ContentValuesWithLong(String valueKey, long value) - { - this(valueKey, is(value)); - } - - - public ContentValuesWithLong(String valueKey, Matcher matcher) - { - super(matcher, "Long value " + valueKey, "Long value " + valueKey); - mKey = valueKey; - } - - - @Override - protected Long featureValueOf(ContentValues actual) - { - return actual.getAsLong(mKey); - } -} diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java deleted file mode 100644 index 81eff9c..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import android.content.ContentValues; - -import org.dmfs.iterables.elementary.Seq; -import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; -import org.dmfs.provider.tasks.model.TaskAdapter; -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.recur.RecurrenceRule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.dmfs.jems.hamcrest.matchers.IterableMatcher.iteratesTo; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -@RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE) -public class TaskInstanceIterableTest -{ - @Test - public void testAbsolute() throws Exception - { - TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); - taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); - taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); - - assertThat(new TaskInstanceIterable(taskAdapter), - iteratesTo( - DateTime.parse("Europe/Berlin", "20170606T121314"), - DateTime.parse("Europe/Berlin", "20170608T121314"), - DateTime.parse("Europe/Berlin", "20170610T121314"), - DateTime.parse("Europe/Berlin", "20170612T121314"), - DateTime.parse("Europe/Berlin", "20170614T121314"), - DateTime.parse("Europe/Berlin", "20170616T121314"), - DateTime.parse("Europe/Berlin", "20170618T121314"), - DateTime.parse("Europe/Berlin", "20170620T121314"), - DateTime.parse("Europe/Berlin", "20170622T121314"), - DateTime.parse("Europe/Berlin", "20170624T121314") - )); - } - - - @Test - public void testAllDay() throws Exception - { - TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); - taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606")); - taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); - - assertThat(new TaskInstanceIterable(taskAdapter), - iteratesTo( - DateTime.parse("20170606"), - DateTime.parse("20170608"), - DateTime.parse("20170610"), - DateTime.parse("20170612"), - DateTime.parse("20170614"), - DateTime.parse("20170616"), - DateTime.parse("20170618"), - DateTime.parse("20170620"), - DateTime.parse("20170622"), - DateTime.parse("20170624") - )); - } - - - @Test - public void testFloating() throws Exception - { - TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); - taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("20170606T121314")); - taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); - - assertThat(new TaskInstanceIterable(taskAdapter), - iteratesTo( - DateTime.parse("20170606T121314"), - DateTime.parse("20170608T121314"), - DateTime.parse("20170610T121314"), - DateTime.parse("20170612T121314"), - DateTime.parse("20170614T121314"), - DateTime.parse("20170616T121314"), - DateTime.parse("20170618T121314"), - DateTime.parse("20170620T121314"), - DateTime.parse("20170622T121314"), - DateTime.parse("20170624T121314") - )); - } - - - @Test - public void testRDate() throws Exception - { - TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); - taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); - taskAdapter.set(TaskAdapter.RDATE, new Seq<>( - DateTime.parse("Europe/Berlin", "20170606T121314"), - DateTime.parse("Europe/Berlin", "20170608T121314"), - DateTime.parse("Europe/Berlin", "20170610T121314"), - DateTime.parse("Europe/Berlin", "20170612T121314"), - DateTime.parse("Europe/Berlin", "20170614T121314"), - DateTime.parse("Europe/Berlin", "20170616T121314"), - DateTime.parse("Europe/Berlin", "20170618T121314"), - DateTime.parse("Europe/Berlin", "20170620T121314"), - DateTime.parse("Europe/Berlin", "20170622T121314"), - DateTime.parse("Europe/Berlin", "20170624T121314") - )); - - assertThat(new TaskInstanceIterable(taskAdapter), - iteratesTo( - DateTime.parse("Europe/Berlin", "20170606T121314"), - DateTime.parse("Europe/Berlin", "20170608T121314"), - DateTime.parse("Europe/Berlin", "20170610T121314"), - DateTime.parse("Europe/Berlin", "20170612T121314"), - DateTime.parse("Europe/Berlin", "20170614T121314"), - DateTime.parse("Europe/Berlin", "20170616T121314"), - DateTime.parse("Europe/Berlin", "20170618T121314"), - DateTime.parse("Europe/Berlin", "20170620T121314"), - DateTime.parse("Europe/Berlin", "20170622T121314"), - DateTime.parse("Europe/Berlin", "20170624T121314") - )); - } - - - @Test - public void testRDateAndRRule() throws Exception - { - TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); - taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); - taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); - taskAdapter.set(TaskAdapter.RDATE, new Seq<>( - DateTime.parse("Europe/Berlin", "20170606T121313"), - DateTime.parse("Europe/Berlin", "20170608T121313"), - DateTime.parse("Europe/Berlin", "20170610T121313"), - DateTime.parse("Europe/Berlin", "20170612T121313"), - DateTime.parse("Europe/Berlin", "20170614T121313"), - DateTime.parse("Europe/Berlin", "20170616T121313"), - DateTime.parse("Europe/Berlin", "20170618T121313"), - DateTime.parse("Europe/Berlin", "20170620T121313"), - DateTime.parse("Europe/Berlin", "20170622T121313"), - DateTime.parse("Europe/Berlin", "20170624T121313") - )); - - assertThat(new TaskInstanceIterable(taskAdapter), - iteratesTo( - DateTime.parse("Europe/Berlin", "20170606T121313"), - DateTime.parse("Europe/Berlin", "20170606T121314"), - DateTime.parse("Europe/Berlin", "20170608T121313"), - DateTime.parse("Europe/Berlin", "20170608T121314"), - DateTime.parse("Europe/Berlin", "20170610T121313"), - DateTime.parse("Europe/Berlin", "20170610T121314"), - DateTime.parse("Europe/Berlin", "20170612T121313"), - DateTime.parse("Europe/Berlin", "20170612T121314"), - DateTime.parse("Europe/Berlin", "20170614T121313"), - DateTime.parse("Europe/Berlin", "20170614T121314"), - DateTime.parse("Europe/Berlin", "20170616T121313"), - DateTime.parse("Europe/Berlin", "20170616T121314"), - DateTime.parse("Europe/Berlin", "20170618T121313"), - DateTime.parse("Europe/Berlin", "20170618T121314"), - DateTime.parse("Europe/Berlin", "20170620T121313"), - DateTime.parse("Europe/Berlin", "20170620T121314"), - DateTime.parse("Europe/Berlin", "20170622T121313"), - DateTime.parse("Europe/Berlin", "20170622T121314"), - DateTime.parse("Europe/Berlin", "20170624T121313"), - DateTime.parse("Europe/Berlin", "20170624T121314") - )); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java deleted file mode 100644 index 552f43d..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIteratorTest.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2021 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.rfc5545.DateTime; -import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; -import org.dmfs.rfc5545.recur.RecurrenceRule; -import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; -import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; -import org.junit.Test; - -import java.util.TimeZone; - -import static org.dmfs.jems.hamcrest.matchers.iterator.IteratorMatcher.iteratorOf; -import static org.junit.Assert.assertThat; - - -/** - * @author Marten Gajda - */ -public class TaskInstanceIteratorTest -{ - private final static String TIMEZONE = "Europe/Berlin"; - - - @Test - public void testAbsolute() throws InvalidRecurrenceRuleException - { - RecurrenceSet recurrenceSet = new RecurrenceSet(); - recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); - DateTime start = DateTime.parse(TIMEZONE, "20210201T120000"); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet.iterator(TimeZone.getTimeZone(TIMEZONE), start.getTimestamp()), TIMEZONE), - iteratorOf( - DateTime.parse(TIMEZONE, "20210201T120000"), - DateTime.parse(TIMEZONE, "20210202T120000"), - DateTime.parse(TIMEZONE, "20210203T120000") - ) - ); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet), - iteratorOf( - DateTime.parse(TIMEZONE, "20210201T120000"), - DateTime.parse(TIMEZONE, "20210202T120000"), - DateTime.parse(TIMEZONE, "20210203T120000") - ) - ); - } - - - @Test - public void testFloating() throws InvalidRecurrenceRuleException - { - - RecurrenceSet recurrenceSet = new RecurrenceSet(); - recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); - DateTime start = DateTime.parse("20210201T120000"); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), - iteratorOf( - DateTime.parse("20210201T120000"), - DateTime.parse("20210202T120000"), - DateTime.parse("20210203T120000") - ) - ); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet), - iteratorOf( - DateTime.parse("20210201T120000"), - DateTime.parse("20210202T120000"), - DateTime.parse("20210203T120000") - ) - ); - } - - - @Test - public void testAllDay() throws InvalidRecurrenceRuleException - { - - RecurrenceSet recurrenceSet = new RecurrenceSet(); - recurrenceSet.addInstances(new RecurrenceRuleAdapter(new RecurrenceRule("FREQ=DAILY;COUNT=3"))); - DateTime start = DateTime.parse("20210201"); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet.iterator(null, start.getTimestamp()), null), - iteratorOf( - DateTime.parse("20210201"), - DateTime.parse("20210202"), - DateTime.parse("20210203") - ) - ); - - assertThat( - () -> new TaskInstanceIterator(start, recurrenceSet), - iteratorOf( - DateTime.parse("20210201"), - DateTime.parse("20210202"), - DateTime.parse("20210203") - ) - ); - } -} \ No newline at end of file diff --git a/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java b/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java deleted file mode 100644 index 843b94a..0000000 --- a/provider/src/test/java/org/dmfs/provider/tasks/utils/ZippedTest.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2017 dmfs GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.dmfs.provider.tasks.utils; - -import org.dmfs.jems.function.BiFunction; -import org.dmfs.jems.optional.elementary.Present; -import org.dmfs.jems.single.elementary.ValueSingle; -import org.junit.Test; - -import static org.dmfs.jems.hamcrest.matchers.SingleMatcher.hasValue; -import static org.dmfs.jems.mockito.doubles.TestDoubles.dummy; -import static org.dmfs.jems.mockito.doubles.TestDoubles.failingMock; -import static org.dmfs.jems.optional.elementary.Absent.absent; -import static org.hamcrest.Matchers.sameInstance; -import static org.junit.Assert.assertThat; -import static org.mockito.Mockito.doReturn; - - -/** - * @author Marten Gajda - */ -public class ZippedTest -{ - @Test - public void testPresent() - { - Object dummyPresentValue = new Object(); - Object dummySingleValue = new Object(); - Object dummyResult = new Object(); - BiFunction mockFunction = failingMock(BiFunction.class); - doReturn(dummyResult).when(mockFunction).value(dummyPresentValue, dummySingleValue); - assertThat(new Zipped<>(new Present<>(dummyPresentValue), new ValueSingle<>(dummySingleValue), mockFunction), hasValue(sameInstance(dummyResult))); - } - - - @Test - public void testAbsent() - { - Object dummyObject = new Object(); - // AGENDULA CHANGE: the diamond was `new Zipped<>(...)`. `absent()` pins nothing, so with no - // target type javac infers Zipped for the argument and Matcher> for the - // matcher, and the two don't meet. Java 8 let it through; we compile at 17. Naming the type - // is the whole fix — the assertion is upstream's. - assertThat(new Zipped(absent(), new ValueSingle<>(dummyObject), dummy(BiFunction.class)), hasValue(sameInstance(dummyObject))); - } -} \ No newline at end of file diff --git a/provider/src/test/resources/robolectric.properties b/provider/src/test/resources/robolectric.properties deleted file mode 100644 index d5e7bba..0000000 --- a/provider/src/test/resources/robolectric.properties +++ /dev/null @@ -1,26 +0,0 @@ -# Robolectric normally takes its SDK level from the module's targetSdk, but a -# library module has none to read — only :app sets one — so without this every -# @RunWith(RobolectricTestRunner.class) class fails at DefaultSdkPicker before a -# single assertion runs. -# -# 34 rather than :app's targetSdk 36: these tests exercise the provider's pure -# data plumbing (ContentValues, MatrixCursor, instance-data processors), none of -# which changed across those levels, and every level listed here costs a separate -# android-all jar download on a cold test run. -sdk=34 - -# Robolectric installs the Conscrypt security provider during environment setup -# whether a test needs TLS or not, and conscrypt-openjdk-uber ships no -# linux-aarch_64 native, so on an ARM64 machine every Robolectric test dies in -# setUpApplicationState with UnsatisfiedLinkError before reaching an assertion. -# Nothing here opens a socket — these are ContentValues and cursor tests — so the -# provider is pure overhead. Turning it off also keeps the suite arch-portable -# rather than passing on x86_64 CI and failing on an ARM laptop. -conscryptMode=OFF - -# Note for anyone running the suite on ARM64: Robolectric has no aarch64 SQLite -# in *either* backend — the native runtime refuses outright and the LEGACY -# sqlite4java shadow throws "Architecture 'aarch64' is not supported". So the one -# test class that needs a database, ProviderAccountCleanupTest, skips itself -# there rather than failing; see the assumption at the top of that class. Every -# other test in this module is architecture-independent and runs everywhere. diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f9d68d..f522deb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,9 +26,4 @@ dependencyResolutionManagement { rootProject.name = "Agendula" include(":app") -// Agendula's own task store — the dmfs task provider vendored under our -// authority. In-tree rather than a submodule or a Maven artifact: F-Droid -// requires from-source, and the upstream AAR hardcodes dmfs's permission names -// in its manifest where they cannot be renamed. See provider/PROVENANCE.md. -include(":provider") includeBuild("floret-kit") From 5abcbfc95640fa95b6f04a6556aedef1dc3ed7fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:43:13 +0200 Subject: [PATCH 14/21] test(store): migration harness, restore path and a 5k-task check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of docs/OWN-STORE.md. MigrationTestHelper is wired against the committed v1 schema, so the first real migration only has to add its own case; the class KDoc says where it goes. app/schemas/ is added to the androidTest assets — the schema location comes from the KSP arg, not the Room Gradle plugin, so nothing wired the test assets automatically. The restore tests state the WAL premise directly rather than around it: a backup of the .db alone must lose whatever is still in the -wal, carrying the sidecars must keep it, and checkpointing first must make the .db alone sufficient. If the premise is wrong the first test fails instead of passing vacuously. Performance: 5,000 tasks and 20 FREQ=DAILY series — daily on purpose, so the per-series occurrence cap is the case being measured — through one full smart-list read. The ceiling is loose and the numbers are printed, because nobody has run this on hardware yet. Also fixes a lint error I introduced in the backup rules two commits ago. Naming any makes everything else excluded by default, so the for tasks.db.imported sat under no included path and FullBackupContent rejected it — lintDebug has been failing at HEAD since, and CI runs it. The same defect had a second, quieter half: those explicit includes had silently stopped DataStore being backed up at all, since it was only ever covered by the old file's "everything by default". Settings are listed back in explicitly. --- app/build.gradle.kts | 5 + .../tasks/room/TasksDatabaseMigrationTest.kt | 68 ++++++++ .../room/TasksDatabasePerformanceTest.kt | 107 ++++++++++++ .../tasks/room/TasksDatabaseRestoreTest.kt | 155 ++++++++++++++++++ app/src/main/res/xml/backup_rules.xml | 19 ++- .../main/res/xml/data_extraction_rules.xml | 11 +- 6 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b846f2f..732b234 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,6 +129,10 @@ android { isReturnDefaultValues = true } } + + // MigrationTestHelper reads the exported schemas out of the test APK's + // assets, so app/schemas/ has to ship with the instrumented tests. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") } kotlin { @@ -203,6 +207,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt new file mode 100644 index 0000000..cf4b272 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The migration harness, proven against the committed schema in `app/schemas/`. + * + * There is one schema version today, so all there is to assert is that the helper + * can build v1 from the exported JSON, seed it, and validate it back — i.e. the + * export, the assets wiring and the identity hash all line up. That is the point: + * the first real migration only has to add its own case. + * + * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s + * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the + * database, and add a test here shaped like this: + * + * ``` + * helper.createDatabase(TEST_DB, 1).use { db -> + * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") + * } + * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> + * // read the seeded rows back — validation proves the shape, not the data + * } + * ``` + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + TasksDatabase::class.java, + ) + + @Test + fun buildsV1FromTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)") + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')") + + db.query("SELECT title FROM tasks").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(0)).isEqualTo("Buy milk") + } + } + } + + @Test + fun validatesV1AgainstTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).close() + + // No migrations to run: v1 is opened and checked against 1.json, which is + // what proves the harness rather than the schema. + helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db -> + assertThat(db.version).isEqualTo(1) + } + } + + private companion object { + const val TEST_DB = "migration-test.db" + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt new file mode 100644 index 0000000..594e500 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import android.content.Context +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 org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.measureTime +import kotlin.time.measureTimedValue + +/** + * The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a + * smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which + * includes expanding every series in memory. + * + * The assertion is a deliberately loose ceiling, so it catches a real regression + * rather than CI jitter; the printed numbers are what the check is actually for. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabasePerformanceTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + + @Before + fun setUp() { + delete() + db = Room.databaseBuilder(context, TasksDatabase::class.java, DB) + .allowMainThreadQueries() + .build() + source = RoomTasksDataSource(db) + listId = source.createLocalList("Everything", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + db.close() + delete() + } + + @Test + fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() { + val seeded = measureTime { seed() } + + // Discard the first read: it pays for statement compilation and page cache + // warming, which a running app has already paid. + source.tasks(TaskQuery(includeCompleted = true)) + val (tasks, elapsed) = measureTimedValue { + source.tasks(TaskQuery(includeCompleted = true)) + } + + println( + "[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " + + "in $elapsed (seed $seeded)", + ) + // Expansion is bounded twice over: the read window is 1 year back and 2 + // forward, and each series stops at ExpansionWindow.maxOccurrences (500), + // so the occurrence count cannot grow with the age of the series. + assertThat(tasks.size).isAtLeast(TASK_COUNT) + assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS) + } + + private fun seed() { + val anchor = Clock.System.now() - 30.days + val ids = ArrayList(TASK_COUNT) + db.runInTransaction { + repeat(TASK_COUNT) { index -> + ids += source.insertTask( + TaskForm(title = "Task $index", listId = listId, due = anchor + index.days), + ) + } + } + db.runInTransaction { + ids.take(SERIES_COUNT).forEach { id -> + val entity = db.tasks().entity(id)!! + db.tasks().update( + entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"), + ) + } + } + } + + private fun delete() { + val base = context.getDatabasePath(DB) + base.delete() + listOf("-wal", "-shm").forEach { File(base.path + it).delete() } + } + + private companion object { + const val DB = "performance-test.db" + const val TASK_COUNT = 5_000 + const val SERIES_COUNT = 20 + const val CEILING_MILLIS = 8_000L + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt new file mode 100644 index 0000000..962ddde --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt @@ -0,0 +1,155 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import android.content.Context +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 org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * The Auto Backup restore path, on disk. + * + * Auto Backup copies database files without checkpointing, and Room runs in WAL + * mode — so `.db` alone can be a *stale* copy of a database whose recent writes + * are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three + * files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts + * that both of those actually do what they claim, and that neither alone is an + * assumption. + * + * A file copy of a live database stands in for the backup transport — the + * transport is what Auto Backup does to these files, and it is not what is under + * test here. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseRestoreTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + private var restored: TasksDatabase? = null + + @Before + fun setUp() { + delete(LIVE) + delete(BACKUP) + db = open(LIVE) + source = RoomTasksDataSource(db) + listId = source.createLocalList("Personal", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + restored?.close() + db.close() + delete(LIVE) + delete(BACKUP) + } + + @Test + fun roomRunsInWalMode() { + // Everything below is only interesting because of this. + assertThat(journalMode()).isEqualTo("wal") + } + + @Test + fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed") + } + + @Test + fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = true) + + assertThat(restore()).containsExactly("checkpointed", "only in the wal") + } + + @Test + fun checkpointingFirstMakesTheDbFileAloneEnough() { + write("checkpointed") + checkpoint() + write("last write") + + // What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore + // that arrives without the sidecars. + checkpoint() + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed", "last write") + } + + // --- the moving parts ----------------------------------------------------- + + private fun open(name: String): TasksDatabase = + Room.databaseBuilder(context, TasksDatabase::class.java, name) + .allowMainThreadQueries() + .build() + + private fun write(title: String) { + source.insertTask(TaskForm(title = title, listId = listId)) + } + + private fun journalMode(): String = + db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor -> + cursor.moveToFirst() + cursor.getString(0).lowercase() + } + + /** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */ + private fun checkpoint() { + db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor -> + cursor.moveToFirst() + assertThat(cursor.getInt(0)).isEqualTo(0) + } + } + + /** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */ + private fun backUp(withSidecars: Boolean) { + delete(BACKUP) + val live = context.getDatabasePath(LIVE) + val backup = context.getDatabasePath(BACKUP) + live.copyTo(backup, overwrite = true) + if (!withSidecars) return + SIDECARS.forEach { suffix -> + val from = File(live.path + suffix) + if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true) + } + } + + /** Opens the copy as a fresh install would and reports the task titles that survived. */ + private fun restore(): List { + restored?.close() + val database = open(BACKUP).also { restored = it } + return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title } + } + + private fun delete(name: String) { + val base = context.getDatabasePath(name) + base.delete() + SIDECARS.forEach { File(base.path + it).delete() } + } + + private companion object { + const val LIVE = "restore-live.db" + const val BACKUP = "restore-backup.db" + val SIDECARS = listOf("-wal", "-shm") + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index dbfacf5..8d670da 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -5,17 +5,18 @@ files without checkpointing, so the `-wal` sidecar can hold writes the `.db` alone does not — all three go in together, and the app checkpoints on ON_STOP so a restore is consistent either way. + + Naming any makes everything else excluded by default, so the + archived dmfs database (`tasks.db.imported`, kept one release as the + import's rollback path) is already left out. An explicit for it + would be redundant *and* rejected — lint's FullBackupContent check errors + on an exclude that sits under no included path. + + Settings live in DataStore, which this exclusion now also covers, so its + sharedpref file is listed back in. --> - - - - - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 81ba3d0..c7530b1 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,16 +1,21 @@ + - - + - + From 1d4fe5b301ba6f4b3e6f269f6c03eda69226edc3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:46:31 +0200 Subject: [PATCH 15/21] fix(reminders): arm reminders again in our own store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from deleting the provider. sync() gated on providerResolver.resolve() != null, and OWN resolves to no provider by design — so from that commit no due reminder was ever armed in what had just become the default mode, and clearAll() cancelled any that survived the upgrade. The gate is now ProviderResolver.canReadStore(): OWN is always readable, and only EXTERNAL can fail, for the two reasons it ever could. Putting the decision on the resolver rather than inside the scheduler is what makes it testable at all — ReminderScheduler needs Context and AlarmManager, which is why nothing caught this. Also brings ARCHITECTURE.md and ROADMAP.md in line with the branch: one module, OWN/EXTERNAL, the four Room tables, expansion at read time, the import and startup gate, and the manifest surface that no longer declares a provider or any permission of its own. --- .../data/reminders/ReminderScheduler.kt | 22 +- .../agendula/data/tasks/ProviderResolver.kt | 13 + .../data/tasks/ProviderResolverTest.kt | 23 + docs/ARCHITECTURE.md | 438 ++++++++++++------ docs/ROADMAP.md | 145 ++++-- 5 files changed, 455 insertions(+), 186 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 8f1cb58..ec63c3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -17,10 +17,11 @@ import javax.inject.Inject import javax.inject.Singleton /** - * The self-scheduled due-reminder engine. Tasks providers don't deliver - * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager] - * alarm each, within a rolling window. Re-run on app start, boot and provider - * change; it diffs against [ScheduledReminderStore] so only changed alarms move. + * The self-scheduled due-reminder engine. Nothing else delivers task reminders — + * not the platform, not a tasks provider — so Agendula reads upcoming due tasks + * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run + * on app start, on boot, and on an external provider change; it diffs against + * [ScheduledReminderStore] so only changed alarms move. */ @Singleton class ReminderScheduler @Inject constructor( @@ -32,9 +33,11 @@ class ReminderScheduler @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) { suspend fun sync() = withContext(io) { - val provider = providerResolver.resolve() val settings = settingsPrefs.settings.first() - if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) { + // Gate on whether the store is readable, not on whether a provider + // resolves: our own store deliberately resolves to no provider, so the + // latter clears every reminder in the default mode. + if (!settings.remindersEnabled || !providerResolver.canReadStore()) { clearAll() return@withContext } @@ -43,12 +46,11 @@ class ReminderScheduler @Inject constructor( val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } .getOrElse { return@withContext } - // One reminder per *occurrence*: the instances view yields a row per + // One reminder per *occurrence*: a recurring series yields a row per // occurrence, all sharing a taskId, so this is a Set rather than a // taskId-keyed Map — keying by task would collapse a daily recurring task - // down to one arbitrary reminder (the query is unsorted, so which one - // survived was provider-defined). - // Per-task leads, stored as Alarm property rows. One query for all of them. + // down to one arbitrary reminder. + // Per-task leads. One query for all of them. val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() } val desired = tasks diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index 207755f..a8ebe11 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -89,6 +89,19 @@ class ProviderResolver @Inject constructor( fun hasPermission(provider: TaskProvider): Boolean = environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission) + /** + * Whether the active store can be read at all. + * + * [StorageMode.OWN] always can — it is our own database, with nothing to + * install and nothing to grant. Only [StorageMode.EXTERNAL] can be + * unreadable. Callers that gate on `resolve() != null` instead get this wrong + * the moment OWN is active, because OWN resolves to no provider by design. + */ + fun canReadStore(): Boolean = when (mode()) { + StorageMode.OWN -> true + StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true + } + companion object { /** * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index da2eab5..6f4e129 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -39,6 +39,14 @@ class ProviderResolverTest { @Nested inner class OwnStore { + @Test + fun `is readable without anything installed or granted`() { + // Guards a real regression: the reminder engine used to gate on + // resolve() != null, which is exactly what OWN returns, so every + // reminder was cleared the moment our own store became the default. + assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue() + } + @Test fun `resolves to no provider at all`() { // Room has no authority and no ContentResolver, so there is nothing @@ -108,6 +116,21 @@ class ProviderResolverTest { assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() } + @Test + fun `external is unreadable until a provider is installed and granted`() { + assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse() + assertThat( + resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(), + ).isFalse() + assertThat( + resolver( + installed = openTasksInstalled, + granted = openTasksGranted, + mode = StorageMode.EXTERNAL, + ).canReadStore(), + ).isTrue() + } + @Test fun `external still requires the runtime permission`() { val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36d125e..c216fb7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,11 +8,10 @@ This document describes how Agendula is built **as it stands today**. For the ## 1. The thesis in one sentence -Agendula is a Material 3 Expressive task app over the dmfs `TaskContract` — it -reads, writes, and reminds against a task store the user chooses: **its own -bundled provider** (the default) or an external provider app already on the -device (OpenTasks, tasks.org) synced by DAVx5, SmoothSync, DecSync CC and the -like. It is the task-list sibling to +Agendula is a Material 3 Expressive task app that reads, writes and reminds +against a task store the user chooses: **its own database** (the default) or an +external provider app already on the device (OpenTasks, tasks.org) synced by +DAVx5, SmoothSync, DecSync CC and the like. It is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula), which does the same thing for `CalendarContract`. @@ -20,19 +19,25 @@ for `CalendarContract`. the original "owns no database" thesis, settled in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md): depending on a provider app being installed made someone else's roadmap a gate on the app working at all. The -database is the vendored dmfs provider under *our* authority — our namespace, not -a schema written from scratch — so every CalDAV engine still understands it. A -sync adapter of our own is the 1.x arc, designed in [`SYNC.md`](SYNC.md). +store is a **Room database of our own**, designed against RFC 5545's `VTODO` and +against what a CalDAV sync adapter will need — the reasoning is in +[`STORAGE-DECISION.md`](STORAGE-DECISION.md), the architecture and the plan it +implements in [`OWN-STORE.md`](OWN-STORE.md). It replaces a vendored copy of the +dmfs task provider, which was deleted along with the `:provider` module it lived +in. External mode is untouched by that and still speaks the dmfs +`TaskContract`. A sync adapter of our own is the 1.x arc, designed in +[`SYNC.md`](SYNC.md). The whole design hangs off one rule: > The entire app talks to a `TasksRepository`. Only the data layer knows there -> is a `ContentResolver`, a `TaskContract`, or an authority string behind it. -> **Provider column names and the authority string never leak above the data -> layer.** +> is a Room database, a `ContentResolver`, a `TaskContract` or an authority +> string behind it. **Table and column names and the authority string never leak +> above the data layer.** -That rule is what let Posture B land as an addition rather than a rewrite: the -UI, the ViewModels and the domain were untouched by it. See §7. +That rule is what let the entire store be swapped without a rewrite: replacing +the provider with Room changed no UI, no ViewModel and exactly one domain field +(`Task.id` → `Task.occurrenceStart`, §4.8). See §7. --- @@ -46,35 +51,36 @@ UI, the ViewModels and the domain were untouched by it. See §7. │ domain models + Flows only ┌───────────────▼──────────────────────────────┐ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ - │ DayWindow (pure Kotlin, no Android) │ + │ TaskSections, RecurrenceExpander │ + │ (pure Kotlin, no Android) │ └───────────────┬──────────────────────────────┘ │ TasksRepository (interface) ┌───────────────▼──────────────────────────────┐ Data │ TasksRepositoryImpl │ │ └ TasksDataSource (interface) │ - │ └ AndroidTasksDataSource │ - │ └ ContentResolver / TaskContract / │ - │ ProviderResolver / ContentObserver│ + │ └ ModeRoutingTasksDataSource │ + │ ├ RoomTasksDataSource (OWN) │ + │ └ AndroidTasksDataSource (EXTERNAL)│ │ reminders/ prefs/ di/ demo/ │ - └───────────────┬──────────────────────────────┘ - │ content:// - ┌───────────────▼──────────────────────────────┐ - Storage │ Local mode (default): │ - │ :provider — our own bundled task provider │ - │ same uid, no permission grant needed │ - │ External mode: │ - │ OpenTasks / tasks.org ←sync← DAVx5 / … │ - │ dangerous perms, requested at point of use │ - └──────────────────────────────────────────────┘ + └────────┬────────────────────────┬────────────┘ + │ Room DAOs │ content:// + ┌─────────────▼──────────┐ ┌──────────▼─────────────┐ + Sto- │ Own mode (default): │ │ External mode: │ + rage │ agendula-tasks.db — │ │ OpenTasks / tasks.org │ + │ four tables in our │ │ ←sync← DAVx5 / … │ + │ own data directory, │ │ dangerous perms, │ + │ nothing to permit │ │ asked at point of use │ + └────────────────────────┘ └────────────────────────┘ ``` The seam that matters is the pair of interfaces in the data layer: - **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend writes. (`data/tasks/TasksRepository.kt`) -- **`TasksDataSource`** — the JVM-testable interface that does the actual - provider work; `AndroidTasksDataSource` is the only Android-coupled - implementation. +- **`TasksDataSource`** — the JVM-testable, domain-shaped interface that does the + actual store work. Two implementations: `RoomTasksDataSource` for our own + store and `AndroidTasksDataSource` for an external provider, picked per call by + `ModeRoutingTasksDataSource` (§4.1). Both are bound in Hilt in `data/di/DataModule.kt`. @@ -82,49 +88,57 @@ Both are bound in Hilt in `data/di/DataModule.kt`. ## 3. Module & package layout -Two modules: `:app` and `:provider`. Package root `de.jeanlucmakiola.agendula`. - -| Module | Contents | -|---|---| -| `:provider` | Agendula's own task store — the Apache-2.0 dmfs task provider 1.4.2, vendored in-tree under our authority and permission namespace. Not our code; see [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) for the upstream commit and every deviation. No app code imports from it except `ProviderResolver`, which reads the authority out of its resources. | +One module, `:app`, plus the `floret-kit` included build (`includeBuild` in +`settings.gradle.kts`, consumed as `de.jeanlucmakiola.floret:*`). The +`:provider` module — the vendored dmfs task provider — was deleted with the own +store; `provider/PROVENANCE.md` went with it, its content preserved as a +postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root +`de.jeanlucmakiola.agendula`. | Package (`:app`) | Contents | |---|---| -| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `AllDayTime` (the two date conventions), `DayWindow` (local-midnight maths). No Android imports. | +| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskConstants` (status/priority/local-account constants), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `TaskSections` (due-date sectioning), `AllDayTime` (the two date conventions). No Android imports; local-midnight maths comes from floret-kit's `DayWindow`. | +| `domain/recurrence/` | `RecurrenceExpander` — a stored rule set → its occurrences, over `lib-recur`. Pure Kotlin. | | `domain/export/` | `ExportModels` + `ICalendarWriter` — VTODO serialization. Pure Kotlin, so the format is JVM-testable. | -| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` + `ProviderEnvironment` + `StorageMode` + `StorageModeHolder` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | +| `data/tasks/` | `StorageMode` + `StorageModeHolder` + `ProviderResolver` + `ProviderEnvironment` (which store, §4.1), `ModeRoutingTasksDataSource`, `StartupGate`, `TasksDataSource`, `TasksRepository` + `Impl`, `Failures`; and the External-mode half — `TasksContract` (vendored subset), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `AndroidTasksDataSource`. | +| `data/tasks/room/` | Agendula's own store: `Entities` (the four tables), a DAO per table, `TasksDatabase`, `Converters`, `RoomTasksDataSource`, `RoomTaskMapper` (row→domain), `TaskFormWriter` (form→entity), `DatabaseCheckpoint`. | +| `data/tasks/legacy/` | `OneShotImport` — a v0.3.x install's tasks out of the dmfs provider's file and into Room, once (§4.4). | | `data/export/` | `TaskExporter` (lists → `.ics` documents), `ExportWriter` (SAF plumbing; a floret-kit candidate). | | `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | | `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | +| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. | --- ## 4. The data layer (the heart) -### 4.1 Provider targeting — `ProviderResolver` +### 4.1 Which store — `StorageMode` and `ProviderResolver` -`ProviderResolver.resolve()` returns the active `TaskProvider(authority, -readPermission, writePermission, packageName, isOwn)` for the selected -`StorageMode`. +`StorageMode` has two values, `OWN` and `EXTERNAL`, and +`ModeRoutingTasksDataSource` picks the implementation **per call** — the mode is +a setting the user can change while the process lives, so binding it once would +mean rebuilding the object graph to honour a change. -| Mode | Provider | Authority | Permissions | +| Mode | Store | Authority | Permissions | |---|---|---|---| -| **Local** (default) | ours, bundled | `de.jeanlucmakiola.agendula.tasks` | **none** — same uid | +| **Own** (default) | our Room database, `agendula-tasks.db` | — none, it is not a provider | **none** | | External | OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | | External | tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | -All three are backed by the same dmfs `TaskProvider` — ours *is* that provider, -vendored — so the **same `TaskContract` columns apply** throughout. +`ProviderResolver` is now only about the second and third rows: it discovers the +*external* providers a device has. `resolve()` returns `null` in `OWN` mode — +there is no authority to resolve — and callers that need to tell that apart from +"External, and nothing installed" ask `mode()`. `TaskProvider` no longer carries +an `isOwn` flag; there is no own provider to flag. -`hasPermission()` short-circuits to `true` for our own provider: a same-uid -caller bypasses a provider's permission checks outright, so -`ProviderStatus.NEEDS_PERMISSION` can never fire in Local mode. In External mode -it checks both runtime perms, and `null` from `resolve()` drives the "install a -tasks provider" gate. +`providerStatus()` is unconditionally `READY` in `OWN` mode. The permission gate +only ever applied to External, and that is now visibly true rather than a +same-uid special case inside `hasPermission()`. In External mode `null` from +`resolve()` is `NO_PROVIDER` and a missing runtime permission is +`NEEDS_PERMISSION`, which is what drives onboarding. **Choosing the mode.** An explicit choice is stored in `SettingsPrefs` and mirrored into the resolver by `StorageModeHolder` — the resolver is consulted @@ -132,71 +146,194 @@ synchronously on every query and cannot read DataStore itself. When there is no explicit choice (the normal case), `autoMode()` decides: > **External if we already hold an external provider's runtime permission, -> otherwise Local.** +> otherwise Own.** That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — the signature of an existing Posture A user, who must not be dropped onto an empty store and left to conclude their tasks were -deleted. A fresh install holds nothing and gets local-first storage. +deleted. A fresh install holds nothing and gets our own store. + +A stored `LOCAL` — the old name for the bundled provider — is read as `OWN` +(`SettingsPrefs.kt:104`) rather than as an unparseable value. Left to fall +through to `autoMode()`, someone who had explicitly chosen local storage *and* +holds an OpenTasks grant would be sent to OpenTasks, away from the data the +import just moved. The platform calls sit behind `ProviderEnvironment` so this decision is unit tested on the JVM (`ProviderResolverTest`) rather than only on a device. -**Storage modes** are `LOCAL` and `EXTERNAL` only. `STORAGE-AND-SYNC.md` -describes three, but *Synced* is not a third store — it is the same store with an -account attached, so it stays derived state. +**Synced is still not a third mode.** `STORAGE-AND-SYNC.md` describes three; a +synced list is `OWN` with an account attached, which is derived state rather than +something the user picks. Attaching one is a plain `UPDATE task_lists SET +account_id = ?` — `account_id` is a nullable FK from v1, so turning sync on for +an existing list is not a migration. (Under the dmfs provider it was: +`ACCOUNT_NAME`/`ACCOUNT_TYPE` were write-once, so tasks had to be moved into new +lists. That constraint left with the provider.) -⚠️ **What this used to say — "so turning sync on is not a migration" — is wrong.** -A task list's `ACCOUNT_NAME`/`ACCOUNT_TYPE` are write-once in the provider -(`processors/lists/Validating.java:68-76`), so local lists cannot be re-pointed at -an account; tasks have to be moved into new lists. Not modelling `SYNCED` as a -mode is still right, but the migration it implied away is real. See -[`SYNC.md`](SYNC.md). +### 4.2 The own store — four Room tables -### 4.2 `TasksContract` +`TasksDatabase` (v1, schema exported to `app/schemas/` and committed) holds +`task_lists`, `tasks`, `task_alarms` and `accounts`. The columns and the +reasoning behind each are in [`OWN-STORE.md`](OWN-STORE.md); what matters +structurally: + +- **`accounts` is empty until sync lands**, but the nullable `task_lists + .account_id` FK exists from v1 — that is what makes §4.1's "attaching an + account is an `UPDATE`" true. Deleting an account `SET NULL`s its lists rather + than deleting them. +- **Masters and `RECURRENCE-ID` overrides share the `tasks` table.** An override + is a row with `recurrence_id` set and `master_id` pointing at its master, + sharing the master's `uid`. The unique index is therefore + `(list_id, uid, recurrence_id)`; SQLite treats NULLs as distinct, so it + enforces the override half and states the master half as intent. +- **`uid` is `NOT NULL`**, minted at creation in either mode, synced or not — so + a task has a stable identity before a server ever sees it. +- `priority` is stored as the raw iCalendar integer (0 none, 1 highest, 9 + lowest). Bucketing into `Priority` on the way *in* would rewrite a server's + `PRIORITY:3` as `1`; the bucketing belongs to the mapper. +- Cascades: deleting a list takes its tasks, deleting a series takes its + overrides (`master_id`), deleting a parent promotes its subtasks + (`parent_id` → `SET NULL`). + +`RoomTaskMapper` maps a row to a domain `Task`; `TaskFormWriter` applies a +validated `TaskForm` to an entity. `TaskFormWriter` is the Room counterpart of +External mode's `TaskWriteMapper`, and deliberately not shared with it: most of +what that mapper does is work around the provider (clearing `DURATION` because +it validates a merged row; writing `STATUS` explicitly in both directions because +it auto-completes at 100% but will not reopen below it). Here the completion +rules are stated +directly — progress and status move together in both directions, so a task can +no longer strand itself "done at 75%". + +Deletes are hard when the list has no account and tombstones (`is_deleted`) when +it does — a row a server still knows about has to survive long enough to be +withdrawn from it. + +### 4.3 Recurrence — expanded at read, not materialised + +There is **no instances table**. The dmfs provider maintained one, recomputed on +every write, and still only ever materialised one upcoming occurrence. Agendula +expands a series in memory instead: + +``` +tasks (masters + overrides) ──► RecurrenceExpander ──► List + rrule/rdate/exdate (lib-recur, in memory) occurrences +``` + +This costs nothing because `TasksRepositoryImpl` already filters and sorts in +Kotlin, not SQL — nothing depended on the database ordering by instance time — +and the whole class of staleness bugs a cached table brings never exists. + +Expansion is bounded twice over: a window of **1 year back, 2 years forward** +(`RoomTasksDataSource.WINDOW_BACK`/`WINDOW_FORWARD`) and a hard per-series +occurrence ceiling, so an unbounded `RRULE` terminates. The iterator is +fast-forwarded to the window start first, so a `FREQ=MINUTELY` series anchored +years back does not scan millions of instances to emit one. A malformed +`RRULE`/`RDATE`/`EXDATE` is dropped rather than thrown: a task whose stored rule +cannot be parsed still has to appear. + +`RecurrenceExpander` returns each occurrence as its **`RECURRENCE-ID` anchor**. +`RoomTasksDataSource.occurrencesOf` then substitutes any override for the +occurrence it replaces; a timed series carries each occurrence's length across, +while 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. + +`lib-recur` is pinned at **0.12.2** (0.16.0 removed `RecurrenceSet`) and is a +direct `:app` dependency now rather than something `:provider` dragged in. It is +still Apache-2.0 dmfs code, so the attribution is still owed — as a normal +third-party dependency. + +**Editing one occurrence writes a `RECURRENCE-ID` override** — RFC 5545's model +(a): a second `tasks` row with the master's `uid`, a `recurrence_id` naming the +occurrence, `master_id` pointing at the series, and the edit applied. 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 +choosing it, and this is the choice. + +### 4.4 Startup — the import and the gate + +`OneShotImport` moves a v0.3.x install's tasks out of the bundled provider's +`databases/tasks.db` and into Room, once. The file is opened read-only and +directly — no provider, no `ContentResolver` — so it keeps working now that +`:provider` is gone, and everything lands in one verified Room transaction, so a +failure leaves both Room and the source file exactly as they were. dmfs row ids +are remapped in two passes, because a parent can carry a higher `_id` than its +child, and `RECURRENCE-ID` overrides are carried across as `master_id` / +`recurrence_id` rather than imported as second masters, which would collide on +the unique index. The source is archived to `tasks.db.imported` **before** the +import, and the import always replaces: that ordering is what makes every kill +point re-enter correctly. + +`StartupGate` holds the first store read until the stored mode has reached +`ProviderResolver` *and* the import has run — `TasksRepositoryImpl.observing()` +awaits it before its first load, and `AgendulaApp` awaits it before the launch +reminder re-sync. Both are the same race: reading early answers from +`autoMode()` instead of the user's choice, or shows an upgrading user an empty +app. + +### 4.5 `TasksContract` — External mode only A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, table paths, status/priority constants, the local-account type. Agendula does **not** take a runtime dependency on OpenTasks; the authority is injected from -`ProviderResolver`, never hardcoded in the contract. +`ProviderResolver`, never hardcoded in the contract. Nothing in `OWN` mode +touches it: the domain's own status/priority/local-account constants live in +`domain/TaskConstants.kt`, so the domain layer never reaches into the data layer +to map its own enums. -### 4.3 Reads — Instances + `ContentObserver` +### 4.6 Reads and reactivity -`AndroidTasksDataSource` queries the denormalized **instances** view (so each -occurrence is a row with the joined list colour, account, etc.), maps each -cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes -the result as a `Flow`. A `ContentObserver` on the active authority's -Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change -re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks) -update the UI live, and multiple sync sources coexist in one list. +In `OWN` mode `RoomTasksDataSource` reads through the DAOs and expands +recurrences (§4.3). In `EXTERNAL` mode `AndroidTasksDataSource` queries the +denormalized **instances** view (so each occurrence is a row with the joined list +colour, account, etc.) and maps each cursor row through `ColumnReader` → +`TaskMapper` → domain `Task`. -### 4.4 Writes — repository API +`TasksDataSource.registerObserver(onChange): AutoCloseable` is unchanged and +backed differently on each side: Room's `InvalidationTracker` over the four +tables in `OWN` mode, a `ContentObserver` on the active authority's +Tasks/TaskLists URIs in `EXTERNAL`. Either way `TasksRepositoryImpl.observing()` +bridges it into a `Flow` via `callbackFlow`, so **any change re-emits** — +Agendula's own writes *and*, in External mode, DAVx5 pulling new tasks. + +### 4.7 Writes — repository API ```kotlin interface TasksRepository { fun taskLists(): Flow> fun tasks(filter: TaskFilter): Flow> + fun subtasks(parentId: Long): Flow> fun taskDetail(taskId: Long): Flow suspend fun createTask(form: TaskForm): Long - suspend fun updateTask(taskId: Long, form: TaskForm) + suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture suspend fun deleteTask(taskId: Long) + suspend fun reminderFor(taskId: Long): Int? suspend fun createLocalList(name: String, color: Int): Long fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER } ``` -`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion -sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back -out as a normal VTODO status change. Writes to local/unsynced lists use the -sync-adapter URI form where the provider requires it. +`updateTask` on an occurrence of a recurring task routes to +`TasksDataSource.updateInstance(taskId, occurrenceStart, form)` rather than +moving the series anchor. `expectedLastModified` re-checks the stored timestamp +first and throws `TaskConflictException` when something changed underneath the +form. -### 4.5 Domain model notes +Completion sets `STATUS = COMPLETED` (+ percent/completed timestamp); in +External mode DAVx5 syncs that back out as a normal VTODO status change. -- `Task.id` is the **instance** row id; `Task.taskId` is the underlying - `tasks._id` and the stable target for edits/completion. +### 4.8 Domain model notes + +- `Task.taskId` is the task row and the stable target for edits, completion and + navigation. There is no `Task.id` any more — it was the materialised instance + row id, and materialised instances are gone. +- `Task.occurrenceStart` is the occurrence's `RECURRENCE-ID` anchor, `null` for a + non-recurring task; `Task.occurrenceKey` (`"$taskId@$millis"`) is what lazy + lists key by. Two occurrences of one series can appear in the same list, so + `taskId` alone would collide there — as a Compose key that is a visible bug. - Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`); `TaskDetail` bundles a task with its direct children. - `effectiveColor` = the task's own colour, else the list colour. @@ -210,7 +347,7 @@ sync-adapter URI form where the provider requires it. `TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — `ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking -`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it +`todayStart`/`todayEnd` (local-midnight bounds from floret-kit's `DayWindow`), so it unit-tests with a fixed clock. `TaskSorting` orders within a list (due / priority / etc.). None of this touches Android, which is why it's all in `domain/`. @@ -231,10 +368,24 @@ providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`) `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. - **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, `POST_NOTIFICATIONS` gate, dedupe-by-tag). -- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external - sync changed the data). The store lets each run diff like Calendula diffs - reminder rows. +- **Re-sync triggers:** app start (after `StartupGate`), **`BootReceiver`** + (re-arm after reboot), and **`ProviderChangeReceiver`**. The store lets each + run diff like Calendula diffs reminder rows. + +`sync()` gates on **`ProviderResolver.canReadStore()`**, not on a provider +resolving. `OWN` is always readable; only `EXTERNAL` can fail, and only for the +two reasons it ever could (nothing installed, or no grant). Gating on +`resolve() != null` — as it did briefly — clears every alarm the moment `OWN` is +active, because `OWN` resolves to no provider by design. `ProviderResolverTest` +covers both directions. + +`ProviderChangeReceiver`'s manifest filter now lists **only the two external +authorities**: Agendula publishes no provider and broadcasts no +`ACTION_PROVIDER_CHANGED`, so there is nothing of ours to listen for. In `OWN` +mode Room's `InvalidationTracker` covers foreground changes and nothing outside +the app can change our data. When `SYNC.md` phase 3 lands, the sync worker calls +`ReminderScheduler.sync()` itself — that is the replacement for the broadcast, +and it belongs in the sync work. This is the single largest piece of genuinely-new code in Agendula. @@ -248,28 +399,40 @@ They no longer mean what earlier drafts of this document said. - **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). Still fully supported; it stopped being the only option and became a user choice, `StorageMode.EXTERNAL`. -- **Posture B (shipped)** — the `:provider` module: the Apache-2.0 dmfs provider - vendored under **our own** authority `de.jeanlucmakiola.agendula.tasks` and our - own permission namespace. It **coexists with everything and replaces nothing**. +- **Posture B (shipped, then rebuilt)** — a store of our own, `StorageMode.OWN`. + It first shipped as the `:provider` module, the Apache-2.0 dmfs provider + vendored under our own authority; it is now a Room database and the module is + deleted. What made the vendored provider worth keeping was the sync bookkeeping + it appeared to hand us for free, and the phase-1 sync audit measured that + bookkeeping and found most of it broken, absent or unusable — the argument and + the costing are in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). -> **Dead end, do not revisit:** bundling the provider under dmfs's *own* +> **Dead end, do not revisit:** publishing a task provider under dmfs's *own* > authority so DAVx5 would sync into it unwittingly. Two apps cannot declare the > same authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same > `` name (`INSTALL_FAILED_DUPLICATE_PERMISSION`), so anyone with > OpenTasks installed simply could not have installed Agendula. Account -> visibility is also keyed by *package*, not authority, which would have left the -> bundled provider seeing zero accounts and pruning synced lists as orphaned. -> Full reasoning in `STORAGE-AND-SYNC.md`. +> visibility is also keyed by *package*, not authority, which would have left such +> a provider seeing zero accounts and pruning synced lists as orphaned. Full +> reasoning in `STORAGE-AND-SYNC.md`. -The seam earned its keep: `ProviderResolver` is still the only thing that knows -an authority exists and `AndroidTasksDataSource` the only thing that touches a -resolver, so vendoring an entire content provider **changed no UI, no ViewModel, -no domain type, and not one line of `TasksRepository`.** +The seam earned its keep twice over. `ProviderResolver` is still the only thing +that knows an authority exists and `AndroidTasksDataSource` the only thing that +touches a resolver, so vendoring an entire content provider changed nothing above +the data layer — and **replacing** it with a database of our own changed no UI, +no ViewModel and exactly one domain field (`Task.id` → `occurrenceStart`), which +was forced by dropping materialised instances rather than by the store swap +itself. -Bundling the provider bundles **storage, not sync**. Our own sync adapter is a -separate, later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands -*underneath* this same seam: it writes through `TaskContract` with -`CALLER_IS_SYNCADAPTER`, so the layers above it stay untouched a second time. +⚠️ **The authority is gone, and that is a breaking change.** +`de.jeanlucmakiola.agendula.tasks` and both custom permissions no longer exist, +so anyone who had pointed DAVx5 or another app at that authority loses it. +External mode is the answer for them; it needs saying in the release notes. + +Owning the store owns **storage, not sync**. Our own sync adapter is a separate, +later piece of work — designed in [`SYNC.md`](SYNC.md), and it lands *underneath* +this same seam: it writes through the DAOs, so the layers above stay untouched a +third time. --- @@ -283,9 +446,9 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, `RootScreen` is the entry composable: it gates on `ProviderStatus` (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → -`ListsScreen`). The remaining screens are being built one at a time — their -ViewModels exist and are tested against the real data layer; navigation -callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the +`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is +only ever seen in External mode. Routes are the `Dest` table in +`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the `material-3` skill for component choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion). @@ -293,11 +456,17 @@ checkbox/FAB/swipe motion). ## 9. Dependency injection -Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module -(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` → -`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, -the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; -`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. +Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module (`TasksRepository` +→ `TasksRepositoryImpl`, `ProviderEnvironment` → `AndroidProviderEnvironment`) +and a `@Provides` module (the `agendula_prefs` DataStore, `TasksDatabase`, the +`@IoDispatcher`, the `@ApplicationScope`). `TasksDataSource` is `@Provides` +rather than `@Binds`, because it is a `ModeRoutingTasksDataSource` over +`Provider` and `Provider` — both +singletons, so it picks between two long-lived objects rather than building +either. `AgendulaApp` is the `@HiltAndroidApp` entry point and pulls +`StartupGate`, `DatabaseCheckpoint` and `ReminderScheduler` through an +`@EntryPoint`; `MainActivity` is `@AndroidEntryPoint`. ViewModels get the +repository injected. --- @@ -308,9 +477,9 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; | Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 | | SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | -| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines | -| `:provider` | Java 17, `org.dmfs` jems / rfc5545-datetime / lib-recur (all Maven Central — no new repository; `settings.gradle.kts` stays `google()` + `mavenCentral()` under `FAIL_ON_PROJECT_REPOS`) | -| Tests | `:app` is JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source and `ProviderEnvironment` as the JVM-testable seams. **`:provider` is JUnit4 + Robolectric** — upstream's own suite, kept as written rather than rewritten, since that coverage is what makes vendoring safe. Don't add `useJUnitPlatform()` there. | +| Store | Room 2.8.4 (KSP, `room.schemaLocation = app/schemas`, WAL), `org.dmfs:lib-recur` 0.12.2 pinned (0.16.0 removed `RecurrenceSet`; `rfc5545-datetime` arrives with it as part of its API surface) | +| Other | DataStore, DocumentFile (SAF export), kotlinx-datetime, kotlinx-coroutines, the `floret-kit` included build | +| Tests | JVM: JUnit5 (Jupiter) + Truth + Turbine + coroutines-test, with the data source, `ProviderEnvironment`, `TaskFormWriter` and `RecurrenceExpander` as the JVM-testable seams. Instrumented (`app/src/androidTest`, AndroidJUnitRunner + Truth): the Room schema, `RoomTasksDataSource` and `OneShotImport` — the last against `assets/tasks-v23.db`, a fixture written by `scripts/make_import_fixture.py` in the provider's DATABASE_VERSION 23 schema, since the provider it came from no longer exists to test against. | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | | CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). | | Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs | @@ -319,25 +488,28 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; ## 11. Manifest surface -- **Declared by `:app`:** both `org.dmfs.*` and `org.tasks.*` read/write tasks - perms (static manifest, so they are always declared; requested at runtime only - in External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm - (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). -- **Declared by `:provider`:** the `` itself plus our own - `de.jeanlucmakiola.agendula.permission.READ_TASKS` / `WRITE_TASKS` and their - permission group. These exist for **other** apps — Agendula reaches its own - provider same-uid and neither declares a `uses-permission` for them nor asks. - The provider is `exported="true"` on purpose: that is what would let DAVx5 - write into it once it knows our authority. -- **Deliberately absent:** `GET_ACCOUNTS` (stripped from the vendored provider — - see change 1 in `PROVENANCE.md`) and `INTERNET`, which stays undeclared until - sync actually ships. Export needs no storage permission at all; SAF hands us a - `Uri` the user picked. -- **``** for package visibility: both *external* provider authorities + +- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms + (static manifest, so they are always declared; requested at runtime only in + External mode); `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm + (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). `OWN` mode needs + nothing here at all: it is a database in our own data directory. +- **No `` and no custom permissions.** Agendula publishes no content + provider; `de.jeanlucmakiola.agendula.tasks`, the + `de.jeanlucmakiola.agendula.permission.*` pair and their permission group all + went with the `:provider` module. +- **Deliberately absent:** `GET_ACCOUNTS`, and `INTERNET`, which stays undeclared + until sync actually ships. Export needs no storage permission at all; SAF hands + us a `Uri` the user picked. +- **``** for package visibility: both external provider authorities + a LAUNCHER intent (so `resolveContentProvider` works and onboarding can open - the provider / a store listing). Our own provider needs no entry. -- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - `ProviderChangeReceiver` (all three authorities, ours first — an intent-filter - host must be a literal), and the vendored provider's own - `TaskProviderBroadcastReceiver`. No `EVENT_REMINDER` receiver — that's a - Calendula thing that doesn't apply here. + the provider / a store listing). +- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, and + `ProviderChangeReceiver` — the latter filtering on the two *external* + authorities only (an intent-filter host must be a literal). No `EVENT_REMINDER` + receiver — that's a Calendula thing that doesn't apply here. +- **Backup:** `agendula-tasks.db` plus its `-wal` and `-shm` sidecars are + included in both `backup_rules.xml` and `data_extraction_rules.xml`; + `tasks.db.imported` is excluded, since it is a copy of data already imported. + Room runs in WAL mode and Auto Backup copies files without checkpointing, so + `DatabaseCheckpoint` runs `PRAGMA wal_checkpoint(TRUNCATE)` on `ON_STOP` to + keep the `.db` alone current for a restore that drops the sidecars. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 564f999..4e55669 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,18 +10,19 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started ## Current state (one line) -Agendula now **carries its own task store**: the `:provider` module ships the -vendored dmfs provider under our authority, so the app is complete and local-first -with nothing else installed, and an external provider (OpenTasks / tasks.org) is a -user choice rather than a requirement. The non-visual stack over `TaskContract` is -done and unit-tested, export to iCalendar has landed, and the Material 3 -Expressive UI is built through **M5**: lists → task list (swipe gestures, inline -add, smart-list section headers) → detail / edit with full CRUD, date-time -pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and -subtask create + reparent — plus a one-time reminder onboarding step and a -Settings screen. Remaining work is the **frontend surfaces for what just landed** -(a storage-mode picker, an export screen), then M6 (Glance widget, translations, -F-Droid release) and the sync adapter. +Agendula now **carries its own task store, and it is one we wrote**: a Room +database designed against `VTODO`, with recurrence expanded at read time. The +vendored dmfs provider and the `:provider` module that held it are deleted, a +v0.3.x install's tasks are imported on first launch, and an external provider +(OpenTasks / tasks.org) is a user choice rather than a requirement. Export to +iCalendar has landed, and the Material 3 Expressive UI is built through **M5**: +lists → task list (swipe gestures, inline add, smart-list section headers) → +detail / edit with full CRUD, date-time pickers, priority, percent-complete, +conflict-safe saves, per-task reminders, and subtask create + reparent — plus a +one-time reminder onboarding step and a Settings screen. Remaining work is +hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for +what has landed** (a storage-mode picker, an export screen), then M6 (Glance +widget, translations, F-Droid release) and the sync adapter. --- @@ -133,33 +134,86 @@ The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync, ### ✅ Posture B — our own task store Agendula stopped depending on a provider app being installed. Direction and reasoning in [`STORAGE-AND-SYNC.md`](STORAGE-AND-SYNC.md); note it **redefined** -what Posture B means (our own authority, coexisting with everything — *not* -squatting `org.dmfs.tasks`, which is a dead end). +what Posture B means (our own store, coexisting with everything — *not* squatting +`org.dmfs.tasks`, which is a dead end). - ✅ `fix/provider-interaction-review` merged (step 1). -- ✅ `:provider` — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored in-tree - under `de.jeanlucmakiola.agendula.tasks` and our own permission namespace. - `GET_ACCOUNTS` dropped, with the account-cleanup path reworked so it can only - prune account types we authenticate ourselves — the deletion is unsafe without - that rework. Modernized to minSdk 29 / targetSdk 36 / Java 17. - [`provider/PROVENANCE.md`](../provider/PROVENANCE.md) records every deviation, - each also marked `AGENDULA CHANGE` at the site. Upstream's 56 JVM tests pass. +- ✅ Step 2, first pass — the Apache-2.0 dmfs provider 1.4.2 (DB 23) vendored + in-tree as `:provider`, under `de.jeanlucmakiola.agendula.tasks` and our own + permission namespace. Shipped in v0.3.x and **since superseded**: see "our own + store" below. - ✅ Storage modes + the permission-gate bypass — `ProviderStatus.NEEDS_PERMISSION` - can no longer fire in Local mode, and an upgrading Posture A user stays on the + can no longer fire in our own mode, and an upgrading Posture A user stays on the provider that holds their data (`ProviderResolver.autoMode`). -- ✅ Export to iCalendar (step 3) — a v1 feature now that Local-mode data lives +- ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives only in our app's private storage. One `.ics` per list, to a folder or a zip, via SAF. Backend only. - ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and an export screen. The backend is done and unused until these exist. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. + Note it now means "sync into an app that has no provider", so the ask has + changed shape. - ⬜ Sync adapter (step 5) — the 1.x arc. **Designed in [`SYNC.md`](SYNC.md)**, - not started: mapper → auth → engine → hardening, ~8–9 weeks. The account model - is settled (`AccountManager`) and `ical4android` is closed out (superseded by - `synctools`, GPLv3, so we write the mapper in-house); what's still open is - dav4jvm's JitPack-only distribution, conflict policy, and whether External mode - survives the milestone. -- ⬜ Verify on a device: the local path with no account, and the vendored - provider's timezone-change behaviour (change 3 in `PROVENANCE.md`). + not started: mapper → auth → engine → hardening, ~8–9 weeks, minus the 2.5–4 + weeks owning the store deletes from it (`OWN-STORE.md`, "Effects on the sync + plan"). The account model is settled (`AccountManager`) and `ical4android` is + closed out (superseded by `synctools`, GPLv3, so we write the mapper + in-house); what's still open is dav4jvm's JitPack-only distribution, conflict + policy, and whether External mode survives the milestone. + +### ✅ Our own store — Room, and the provider deleted +The vendored provider was kept because it appeared to hand us the sync +bookkeeping for free; the phase-1 sync audit measured that bookkeeping and found +most of it broken, absent or unusable. Reasoning in +[`STORAGE-DECISION.md`](STORAGE-DECISION.md), architecture and six-phase plan in +[`OWN-STORE.md`](OWN-STORE.md). +- ✅ Phase 0 — occurrences addressed by `(taskId, occurrenceStart)`. `Task.id` + (the materialised instance row id) dropped for `occurrenceStart`, lazy-list + keys moved to `Task.occurrenceKey`, `updateInstance` re-signed, `domain/` + stopped importing `TasksContract`. Done while the provider was still the store, + so the External path exercised the new seam first. +- ✅ Phase 1 — the schema: `task_lists`, `tasks`, `task_alarms`, `accounts`, a + DAO per table, the v1 schema JSON committed for migration testing. Masters and + `RECURRENCE-ID` overrides share the `tasks` table, so the unique index is + `(list_id, uid, recurrence_id)`. +- ✅ Phase 2 — `RecurrenceExpander` over `lib-recur` 0.12.2: a series expanded in + memory at read time, bounded by a window (1 year back, 2 forward) and a hard + per-series ceiling. No materialised instances table, so none of its staleness + bugs. 38 tests against RFC 5545 directly, since the provider only ever + materialised one occurrence to compare against. +- ✅ Phase 3 — `RoomTasksDataSource` implements all 14 seam methods, picked per + call by `ModeRoutingTasksDataSource`. Editing one occurrence writes a + `RECURRENCE-ID` override sharing the master's UID (RFC 5545 model (a)), where + the provider forked a new task with a new UID (model (d)). Completion rules are + stated directly rather than worked around, so a task can no longer strand + itself "done at 75%". +- ✅ Phase 4 — `OneShotImport` moves a v0.3.x install's `databases/tasks.db` into + Room on first launch, archiving the source as `tasks.db.imported`; `OWN` is the + default; `StartupGate` holds the first store read until the mode has landed and + the import has run; backup rules take the database with its WAL sidecars and + the app checkpoints on `ON_STOP`. +- ✅ Phase 5 — `:provider` deleted: 84 Java files, 14,555 lines, its ``, + its two custom permissions and its three dmfs runtime dependencies. + `StorageMode.LOCAL` is gone (a stored `LOCAL` reads as `OWN`); `ProviderResolver` + narrows to discovering external providers; `ProviderChangeReceiver` filters on + the two external authorities only. `provider/PROVENANCE.md` is replaced by a + postscript in `STORAGE-DECISION.md`. **Breaking:** the + `de.jeanlucmakiola.agendula.tasks` authority and both custom permissions no + longer exist — anyone who pointed DAVx5 at that authority loses it, and the + release notes have to say so. +- ✅ Phase 6 — harden: `MigrationTestHelper` wired against the committed v1 + schema so v1 → v2 is cheap when sync adds columns, an Auto Backup restore test + covering the WAL case in both directions, and a performance check at 5,000 + tasks with 20 recurring series. +- ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is + what `OWN` returns, so no due reminder armed in the default mode. It now gates + on `ProviderResolver.canReadStore()`, with tests. +- ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the + DAOs, the import, the migration harness, the restore path and the performance + check — all compile and none has ever executed. Everything load-bearing about + this migration is verified only by tests that have not run. +- ⬜ Verify on a device: a fresh install on the Room store, and an upgrade from a + v0.3.2 APK with seeded data landing every task, list and reminder. +- ⬜ Per-locale release notes for the dropped authority and permissions. --- @@ -174,15 +228,15 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through (Not in the candidate list today.) Note this is now downstream of [`SYNC.md`](SYNC.md) open question 3: if External mode is retired once we sync ourselves, the question disappears with it. -4. ~~**Posture B authority choice**~~ resolved: **our own** - `de.jeanlucmakiola.agendula.tasks`. Squatting `org.dmfs.tasks` is a dead end, - not merely a trade-off — two apps cannot declare the same authority or - permission name, so anyone with OpenTasks installed could not have installed - Agendula at all. -5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ stale: - `fix/provider-interaction-review` routes edits to a recurring task through the - instances URI, so the provider forks an override instead of re-anchoring the - series. +4. ~~**Posture B authority choice**~~ moot: Agendula publishes no provider and + holds no authority at all. The question was live while the store was a + vendored provider, and squatting `org.dmfs.tasks` was a dead end even then — + two apps cannot declare the same authority or permission name, so anyone with + OpenTasks installed could not have installed Agendula at all. +5. ~~**Recurring tasks** — recurrence-aware editing out of scope for v1~~ + resolved: our own store expands a series at read time and writes an edit to + one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in + External mode the edit still goes through the instances URI. 6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it assumes is not built yet. @@ -196,7 +250,12 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through - Build: `./gradlew :app:assembleDebug` - Unit tests: `./gradlew :app:testDebugUnitTest` -- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real - data. Debug builds use `DemoSeeder` for sample data when no provider data is - present. +- Instrumented tests: `./gradlew :app:connectedDebugAndroidTest` — the Room + schema, `RoomTasksDataSource` and `OneShotImport` (the last against + `app/src/androidTest/assets/tasks-v23.db`, regenerated by + `scripts/make_import_fixture.py`). +- Any device or emulator will do for the default path: the store is ours and + needs nothing installed. Debug builds seed an "Agendula Demo" list via + `DemoSeeder` unless it already exists. To exercise **External** mode, use a + device with **OpenTasks** + or **tasks.org** installed, and ideally DAVx5 syncing a CalDAV task list. From 90140112bb791465e45d77deb56082266c804b22 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 17:23:31 +0200 Subject: [PATCH 16/21] fix(store): three defects the instrumented suite found on first run - forking an occurrence copied the master's alarm row id and hit the primary key; replaceForTask now clears it - a sub-second DTSTART made lib-recur emit the anchor and its truncated self, doubling a series' first occurrence; floor to the second, which is all RFC 5545 DATE-TIME carries - room-testing needs kotlinx-serialization 1.8+, but consistent resolution pinned androidTest to the app's 1.7.3 Tests: 52 pass on device. --- app/build.gradle.kts | 8 +++++ .../tasks/room/RoomTasksDataSourceTest.kt | 33 ++++++++++++++++--- .../agendula/data/tasks/room/TaskAlarmDao.kt | 8 +++-- .../domain/recurrence/RecurrenceExpander.kt | 12 +++++-- .../recurrence/RecurrenceExpanderTest.kt | 13 ++++++++ gradle/libs.versions.toml | 2 ++ 6 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 732b234..2115c52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,6 +149,14 @@ ksp { } dependencies { + // Not a dependency we use directly — lifecycle already drags it in at 1.7.3. + // AGP's consistent resolution then pins androidTest to the app classpath, and + // room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported + // schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place. + constraints { + implementation(libs.kotlinx.serialization.json) + } + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) 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 index 9731019..98ce04d 100644 --- 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 @@ -27,7 +27,8 @@ class RoomTasksDataSourceTest { private lateinit var source: RoomTasksDataSource private var listId = 0L - private val now get() = Clock.System.now() + /** Truncated to the store's granularity: instants are columns of epoch millis. */ + private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) @Before fun setUp() { @@ -146,14 +147,38 @@ class RoomTasksDataSourceTest { 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 } + // The list holds this series alone, so no filter is needed — and none can + // be written on taskId, since the override reports its own row id. + val before = source.tasks(TaskQuery(listId = listId)) 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 } + val after = source.tasks(TaskQuery(listId = listId)) assertThat(after).hasSize(before.size) - assertThat(after.filter { it.title == "Water them twice" }).hasSize(1) + val edited = after.single { it.title == "Water them twice" } + assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart) + assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1) + } + + /** + * An edited occurrence addresses its own row, not the master's. That is what + * sends the *next* edit down `updateTask` rather than forking a second time: + * an override carries no rule, so it reads back as non-recurring. + */ + @Test + fun anEditedOccurrenceReportsTheOverridesOwnId() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 } + + source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice")) + + val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" } + val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id + assertThat(edited.taskId).isEqualTo(overrideId) + assertThat(edited.taskId).isNotEqualTo(id) + assertThat(source.task(overrideId)!!.isRecurring).isFalse() } @Test diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt index d77d15f..bd52710 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -22,10 +22,14 @@ interface TaskAlarmDao { @Query("DELETE FROM task_alarms WHERE task_id = :taskId") fun deleteForTask(taskId: Long): Int - /** Set the task's only reminder, or clear it with `null`. */ + /** + * Set the task's only reminder, or clear it with `null`. The row that lands is + * always a new one — the id is cleared so an alarm lifted off another task + * (forking an occurrence copies the master's) inserts instead of colliding. + */ @Transaction fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) { deleteForTask(taskId) - alarm?.let { insert(it.copy(taskId = taskId)) } + alarm?.let { insert(it.copy(id = 0, taskId = taskId)) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt index 1fa97e9..31e9a13 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -9,6 +9,7 @@ import java.time.ZoneId import java.util.TimeZone import kotlin.time.Instant +private const val MILLIS_PER_SECOND = 1000L private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 /** The rule set of one task series, as stored. All strings are raw iCalendar values. */ @@ -117,10 +118,17 @@ object RecurrenceExpander { return TimeZone.getTimeZone(stored ?: floatingZone) } - /** All-day series are date-anchored: pin the anchor to UTC midnight, as it is stored. */ + /** + * All-day series are date-anchored: pin the anchor to UTC midnight, as it is + * stored. A timed one is floored to the second, because RFC 5545 DATE-TIME + * has no sub-second field — carrying millis in makes lib-recur emit the raw + * anchor *and* its truncated self, doubling the first occurrence, and mints + * `RECURRENCE-ID`s no other client could address. + */ private fun anchorMillis(spec: RecurrenceSpec): Long { val millis = spec.anchor.toEpochMilliseconds() - return if (!spec.isAllDay) millis else Math.floorDiv(millis, MILLIS_PER_DAY) * MILLIS_PER_DAY + val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND + return Math.floorDiv(millis, unit) * unit } private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching { diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt index 98baa1e..2c08ac6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -391,6 +391,19 @@ class RecurrenceExpanderTest { ).inOrder() } + @Test + fun `a sub-second anchor does not double the first occurrence`() { + // RFC 5545 DATE-TIME has second precision, but a task created from + // Clock.now() carries millis. Left un-floored, lib-recur emits the raw + // anchor *and* its truncated self, so the series starts twice. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00.081Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + @Test fun `a window that ends before the anchor yields nothing`() { val result = expand( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd55c5d..c57fbae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ material3 = "1.5.0-alpha21" datastore = "1.2.1" # Room — Agendula's own task store (docs/OWN-STORE.md). room = "2.8.4" +kotlinxSerialization = "1.8.1" # SAF directory writing for export/backup (DocumentFile). documentfile = "1.1.0" junit = "6.1.0" @@ -78,6 +79,7 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio 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" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } # DataStore androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } From faee90f8b1c918baea2b7a0e7923823b78e88862 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 17:33:36 +0200 Subject: [PATCH 17/21] docs: note the ARM64 box64 aapt setup and the exit-code trap it causes --- CLAUDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index cb898b3..62fd097 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,19 @@ +## Build host (ARM64) + +The primary dev machine is ARM64 (aarch64) Linux, and Google ships no +linux-aarch64 `aapt`/`aapt2` — both run under **box64** via wrappers that live +outside the repo (`~/.gradle/box64-aapt2/aapt2` behind +`android.aapt2FromMavenOverride`, and an `aapt` wrapper installed straight into +`~/Android/Sdk/build-tools/*/` because AGP's test-APK installer hardcodes that +path). Nothing in the project tree depends on this; x86-64 machines and CI are +unaffected. + +The trap: without the `aapt` wrapper, `connectedDebugAndroidTest` **exits +non-zero even when every test passes** — teardown fails, and Gradle reports it as +"There were failing tests". If that shows up, read +`app/build/outputs/androidTest-results/connected/debug/*.xml` for the real +result before believing the exit code. + ## On-device work (USB-connected phone) A physical phone is connected over USB. Rules: From fcee1d1736ae984fa8b76a4fbc92183e8a2378cd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 13:46:05 +0200 Subject: [PATCH 18/21] feat(lists): manage lists in the app, and fix four store defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owning the store left a fresh install with no lists and no way to make one, so no way to save a task. The seam gains updateList/deleteList beside createLocalList on both paths — the External one addresses the row as its own account's sync adapter, the only caller the provider lets write tasklists. ListEditorSheet is the family's full-screen sheet: name field, a 12-colour palette, and a destructive row behind a confirm when editing. Entry points are a "New list" row under the home Lists section, an empty state with a create button, and the home FAB switching to "New list" while there are none. Deleting takes the list's tasks with it and is offered only for device-only lists. Four defects a review of the branch turned up: - completing one occurrence closed the whole series — setCompleted wrote the master, the row TaskDao.tasks filters on. setCompletedInstance forks a RECURRENCE-ID override the way updateInstance does; phase 2 always specified this, only the edit half had it - the expansion ceiling was spent on the past, so a sub-daily series stopped expanding months before today and never reached Today or Upcoming - an imported START-referenced reminder fired off DUE, because the seam collapsed alarms to a bare minute count. TaskReminder carries the anchor now - registerObserver bound a live flow to whichever store was active at subscription, so a Settings store switch left every screen listening to the store it had stopped reading --- .../tasks/room/RoomTasksDataSourceTest.kt | 78 +++++ .../agendula/data/demo/DemoSeeder.kt | 2 +- .../data/reminders/ReminderScheduler.kt | 10 +- .../data/tasks/AndroidTasksDataSource.kt | 58 +++- .../data/tasks/ModeRoutingTasksDataSource.kt | 42 ++- .../agendula/data/tasks/ProviderResolver.kt | 21 ++ .../agendula/data/tasks/TaskWriteMapper.kt | 6 +- .../agendula/data/tasks/TasksContract.kt | 2 + .../agendula/data/tasks/TasksDataSource.kt | 40 ++- .../agendula/data/tasks/TasksRepository.kt | 11 +- .../data/tasks/TasksRepositoryImpl.kt | 18 +- .../data/tasks/room/RoomTasksDataSource.kt | 127 ++++++-- .../domain/recurrence/RecurrenceExpander.kt | 29 +- .../agendula/ui/common/ListColors.kt | 29 ++ .../agendula/ui/common/ShapedActionButton.kt | 3 + .../agendula/ui/detail/TaskDetailViewModel.kt | 2 +- .../agendula/ui/lists/ListEditorSheet.kt | 295 ++++++++++++++++++ .../agendula/ui/lists/ListsScreen.kt | 113 ++++++- .../agendula/ui/lists/ListsViewModel.kt | 29 +- .../agendula/ui/tasklist/TaskListScreen.kt | 72 ++++- .../agendula/ui/tasklist/TaskListViewModel.kt | 47 ++- app/src/main/res/values/strings.xml | 27 +- .../data/tasks/ProviderResolverTest.kt | 19 ++ .../recurrence/RecurrenceExpanderTest.kt | 39 +++ docs/ROADMAP.md | 27 ++ 25 files changed, 1067 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt 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 index 98ce04d..69f4e33 100644 --- 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 @@ -67,6 +67,31 @@ class RoomTasksDataSourceTest { assertThat(lists.single().accountName).isEqualTo("Local") } + @Test + fun renamesAndRecoloursAList() { + source.updateList(listId, " Errands ", 0xFF445566.toInt()) + + val list = source.taskLists().single() + assertThat(list.name).isEqualTo("Errands") + assertThat(list.color).isEqualTo(0xFF445566.toInt()) + // Nothing to sync a device-only list to, so the edit leaves it clean. + assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse() + } + + @Test + fun deletingAListTakesItsTasksWithIt() { + source.insertTask(form(title = "Buy milk")) + source.insertTask(form(title = "Call the bank")) + val other = source.createLocalList("Work", 0xFF778899.toInt()) + val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other)) + + source.deleteList(listId) + + assertThat(source.taskLists().map { it.id }).containsExactly(other) + assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId }) + .containsExactly(keeper) + } + @Test fun createsAndReadsBackANonRecurringTask() { val due = now + 1.days @@ -143,6 +168,59 @@ class RoomTasksDataSourceTest { assertThat(override.title).isEqualTo("Water them twice") } + @Test + fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } } + val before = open() + val target = before.first { it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + + // Writing the status onto the master would close the series: the master is + // the row the task query filters on, so every occurrence would vanish. + val after = open() + assertThat(after).hasSize(before.size - 1) + assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart) + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + + val override = db.tasks().override(id, target.occurrenceStart)!! + assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid) + assertThat(override.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(override.rrule).isNull() + // The override stands for *that* occurrence, so it carries the + // occurrence's resolved times, not the master's anchor. + assertThat(override.dtstart).isEqualTo(target.occurrenceStart) + } + + @Test + fun reopeningACompletedOccurrenceReusesItsOverride() { + val id = source.insertTask(form(title = "Water the plants")) + makeRecurring(id, now) + val target = source.tasks(TaskQuery(listId = listId)) + .first { it.taskId == id && it.distanceFromCurrent == 0 } + + source.setCompletedInstance(id, target.occurrenceStart!!, completed = true) + source.setCompletedInstance(id, target.occurrenceStart, completed = false) + + assertThat(db.tasks().overrides(id)).hasSize(1) + assertThat(db.tasks().override(id, target.occurrenceStart)!!.status) + .isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart }) + .contains(target.occurrenceStart) + } + + @Test + fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() { + val id = source.insertTask(form(title = "Buy milk", due = now + 1.days)) + + source.setCompletedInstance(id, now, completed = true) + + assertThat(db.tasks().overrides(id)).isEmpty() + assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + @Test fun anOverrideReplacesOnlyItsOwnOccurrence() { val id = source.insertTask(form(title = "Water the plants")) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt index f3234ff..a9b8f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt @@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor( repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId)) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) - repository.setCompleted(done, completed = true) + repository.setCompleted(done, occurrenceStart = null, completed = true) } private companion object { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index ec63c3a..089ee9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -59,12 +59,18 @@ class ReminderScheduler @Inject constructor( // A reminder set on the task itself wins; otherwise the task's list // may override the global lead, or opt out entirely (override = // null), in which case it gets no reminder at all. - val lead = perTask[task.taskId] + val reminder = perTask[task.taskId] + val lead = reminder?.minutesBefore ?: settings.reminderLeadFor(task.listId) ?: return@mapNotNull null + // A stored reminder says what it counts back from. Ours are always + // before due, but an imported dmfs alarm or another client's can be + // before *start* — firing those off the due date is silently wrong + // for every task whose start and due differ. + val anchor = if (reminder?.fromStart == true) task.start ?: task.due!! else task.due!! ScheduledReminder( taskId = task.taskId, - triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, + triggerAt = anchor.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L, ) } // The lower bound trails `now` so a reminder missed while the device was diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 4ef3fad..ca7077d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -170,9 +170,13 @@ class AndroidTasksDataSource @Inject constructor( } } - override fun alarms(): Map { + override fun alarms(): Map { val uri = TasksContract.propertiesUri(authority()) - val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE) + val projection = arrayOf( + Properties.TASK_ID, + TasksContract.Alarm.MINUTES_BEFORE, + TasksContract.Alarm.REFERENCE, + ) return resolver.query( uri, projection, @@ -185,7 +189,16 @@ class AndroidTasksDataSource @Inject constructor( while (c.moveToNext()) { val id = reader.getLong(Properties.TASK_ID) val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE) - if (id != null && minutes != null) put(id, minutes) + val reference = reader.getInt(TasksContract.Alarm.REFERENCE) + if (id != null && minutes != null) { + put( + id, + TaskReminder( + minutesBefore = minutes, + fromStart = reference == TasksContract.Alarm.REFERENCE_START, + ), + ) + } } } } ?: emptyMap() @@ -197,6 +210,20 @@ class AndroidTasksDataSource @Inject constructor( if (rows == 0) throw TaskWriteFailedException("complete task $taskId") } + /** + * Through the instances URI, which is what makes the provider fork an override + * rather than close the series. No instance row for the anchor means the task + * is not a series after all — the plain write is then the right one. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: return setCompleted(taskId, completed) + val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId") + } + override fun deleteTask(taskId: Long) { resolver.delete(taskUri(authority(), taskId), null, null) } @@ -213,6 +240,31 @@ class AndroidTasksDataSource @Inject constructor( return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id") } + override fun updateList(listId: Long, name: String, color: Int) { + val values = TaskWriteMapper.listValues(name, color) + val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update list $listId") + } + + override fun deleteList(listId: Long) { + val rows = resolver.delete(listSyncUri(listId), null, null) + if (rows == 0) throw TaskWriteFailedException("delete list $listId") + } + + /** + * A list row addressed as its own account's sync adapter — the provider only + * lets that caller write the `tasklists` table, and the account has to be the + * row's own (the params are matched against it, not merely accepted). + */ + private fun listSyncUri(listId: Long): Uri { + val authority = authority() + val uri = TasksContract.listUri(authority, listId) + val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null } + ?: throw TaskWriteFailedException("list $listId not found") + return TasksContract.asSyncAdapter(uri, account.first, account.second) + } + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { 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 index 22120ac..ae5ef6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -41,10 +41,48 @@ class ModeRoutingTasksDataSource( active().updateInstance(taskId, occurrenceStart, form) override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) - override fun alarms(): Map = active().alarms() + 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 setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = + active().setCompletedInstance(taskId, occurrenceStart, 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) + override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color) + override fun deleteList(listId: Long) = active().deleteList(listId) + /** + * Unlike every other method here, an observer is registered once and then + * *held* — so it cannot be routed per call, and would otherwise stay bound to + * whichever store was active when the flow started. Switching stores in + * Settings would then leave every open screen listening to the store it is no + * longer reading from. + * + * So the registration moves with the mode, and the switch itself counts as a + * change: the data underneath every live flow has just been replaced. + */ + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val lock = Any() + var closed = false + var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull() + + val modeHandle = resolver.onModeChanged { + synchronized(lock) { + if (!closed) { + handle?.let { runCatching { it.close() } } + handle = runCatching { active().registerObserver(onChange) }.getOrNull() + } + } + onChange() + } + return AutoCloseable { + synchronized(lock) { + closed = true + modeHandle.close() + handle?.let { runCatching { it.close() } } + handle = null + } + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index a8ebe11..7f752c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton @@ -31,15 +32,35 @@ class ProviderResolver @Inject constructor( private val environment: ProviderEnvironment, ) { + private val modeListeners = CopyOnWriteArrayList<() -> Unit>() + /** * The user's explicit choice, or `null` while they have not made one (which is * the normal state — most people never open Settings). Kept as a plain field * rather than read from DataStore on demand because [resolve] is called from * synchronous data-source code on every query, including from the main thread * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + * + * Assigning a *different* mode notifies [onModeChanged]: every live store + * observer is bound to one store and has to be moved across. */ @Volatile var storageMode: StorageMode? = null + set(value) { + val changed = field != value + field = value + if (changed) modeListeners.forEach { it() } + } + + /** + * Observe switches between stores. Fires on the thread that set [storageMode] + * — [StorageModeHolder]'s collector — so listeners must be cheap and must not + * block. + */ + fun onModeChanged(listener: () -> Unit): AutoCloseable { + modeListeners += listener + return AutoCloseable { modeListeners -= listener } + } /** The active store, resolving the undecided case through [autoMode]. */ fun mode(): StorageMode = storageMode ?: autoMode() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 494e78d..f54dddd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -107,9 +107,13 @@ object TaskWriteMapper { Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, ) - fun localListValues(name: String, color: Int): Map = mapOf( + /** The user-owned columns of a list — what an edit is allowed to change. */ + fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), Lists.COLOR to color, + ) + + fun localListValues(name: String, color: Int): Map = listValues(name, color) + mapOf( Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME, Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE, Lists.VISIBLE to 1, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index 674bc94..63e1ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -163,6 +163,8 @@ object TasksContract { fun authorityUri(authority: String): Uri = Uri.parse("content://$authority") fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}") + fun listUri(authority: String, listId: Long): Uri = + Uri.parse("content://$authority/${Lists.PATH}/$listId") fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index a8885e5..49b50bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -5,6 +5,16 @@ import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import kotlin.time.Instant +/** + * A stored reminder: how long before, and what it counts back from. + * + * [fromStart] matters because both stores can hold a `START`-referenced alarm — + * the dmfs import preserves one, and an external provider's other clients write + * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it + * to a number here is what silently fired those reminders off the wrong anchor. + */ +data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false) + /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( val listId: Long? = null, @@ -45,8 +55,8 @@ interface TasksDataSource { */ fun setAlarm(taskId: Long, minutesBeforeDue: Int?) - /** Every task's reminder lead, by task id. One query, for the scheduler. */ - fun alarms(): Map + /** Every task's reminder, by task id. One query, for the scheduler. */ + fun alarms(): Map /** * Every task in [listId] read from the **`tasks` table**, for export. Masters, @@ -56,9 +66,35 @@ interface TasksDataSource { fun exportTasks(listId: Long): List fun setCompleted(taskId: Long, completed: Boolean) + + /** + * Complete (or reopen) **one occurrence** of a recurring task, addressed the + * same way [updateInstance] is. Ticking a series through [setCompleted] would + * close the master row, which takes every past and future occurrence out of + * every list at once. + * + * Implementations fall back to [setCompleted] when the row turns out not to + * be a series master — an override, or a plain task the caller happened to + * hand an anchor for — so the routing above cannot get this wrong. + */ + fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long + /** Rename and recolour [listId]. */ + fun updateList(listId: Long, name: String, color: Int) + + /** + * Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the + * Room path, and the provider does the same on the External one. + * + * Only ever called for a local, device-only list: a collection that belongs + * to an account is the server's to remove, and neither store expresses a + * collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam + * does not re-check it. + */ + fun deleteList(listId: Long) + /** Observe any change to tasks/lists; [onChange] fires on a background thread. */ fun registerObserver(onChange: () -> Unit): AutoCloseable } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index 3333e6c..4601732 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -37,7 +37,12 @@ interface TasksRepository { * since the form loaded. Pass `null` to force the write (overwrite-anyway). */ suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) - suspend fun setCompleted(taskId: Long, completed: Boolean) + /** + * Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a + * recurring series forks a `RECURRENCE-ID` override for that one occurrence + * instead of closing the whole series; `null` completes the row itself. + */ + suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) suspend fun deleteTask(taskId: Long) /** @@ -47,6 +52,10 @@ interface TasksRepository { */ suspend fun reminderFor(taskId: Long): Int? suspend fun createLocalList(name: String, color: Int): Long + suspend fun updateList(listId: Long, name: String, color: Int) + + /** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */ + suspend fun deleteList(listId: Long) /** Synchronous snapshot for the permission/onboarding gate. */ fun providerStatus(): ProviderStatus diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 03850ad..ab2ef75 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -88,7 +88,7 @@ class TasksRepositoryImpl @Inject constructor( } override suspend fun reminderFor(taskId: Long): Int? = - withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() } + withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = withContext(io) { @@ -117,8 +117,14 @@ class TasksRepositoryImpl @Inject constructor( } } - override suspend fun setCompleted(taskId: Long, completed: Boolean) = - withContext(io) { dataSource.setCompleted(taskId, completed) } + override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) = + withContext(io) { + if (occurrenceStart != null) { + dataSource.setCompletedInstance(taskId, occurrenceStart, completed) + } else { + dataSource.setCompleted(taskId, completed) + } + } override suspend fun deleteTask(taskId: Long) = withContext(io) { dataSource.deleteTask(taskId) } @@ -126,6 +132,12 @@ class TasksRepositoryImpl @Inject constructor( override suspend fun createLocalList(name: String, color: Int): Long = withContext(io) { dataSource.createLocalList(name, color) } + override suspend fun updateList(listId: Long, name: String, color: Int) = + withContext(io) { dataSource.updateList(listId, name, color) } + + override suspend fun deleteList(listId: Long) = + withContext(io) { dataSource.deleteList(listId) } + override fun providerStatus(): ProviderStatus { // Our own store is always ready: it ships with the app, needs no provider // and no grant. The permission gate only ever applied to External mode — 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 index d482877..a496df0 100644 --- 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.InvalidationTracker import de.jeanlucmakiola.agendula.data.tasks.TaskQuery +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.domain.Task @@ -74,8 +75,13 @@ class RoomTasksDataSource @Inject constructor( override fun exportTasks(listId: Long): List = tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) - override fun alarms(): Map = - alarms.all().associate { it.taskId to it.minutesBefore } + override fun alarms(): Map = + alarms.all().associate { + it.taskId to TaskReminder( + minutesBefore = it.minutesBefore, + fromStart = it.reference == AlarmReference.START, + ) + } /** * Every occurrence of [row] inside the expansion window, with any @@ -86,46 +92,51 @@ class RoomTasksDataSource @Inject constructor( */ 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 window = ExpansionWindow( + from = now - WINDOW_BACK, + until = now + WINDOW_FORWARD, + pivot = now, + ) 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( + if (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( + } else { + val (start, due) = occurrenceTimes(row.task, anchor) + 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, + start = start, + due = due, distanceFromCurrent = distances[index], ) } } } + /** + * One occurrence's resolved start and due. 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. + */ + private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { + if (master.dtstart == null) return null to anchor + val length = master.due?.let { it - master.dtstart } + return anchor to length?.let { anchor + it } + } + // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { @@ -154,17 +165,9 @@ class RoomTasksDataSource @Inject constructor( tasks.update(TaskFormWriter.apply(existing, form, now, zone())) return } + val (start, due) = occurrenceTimes(master, occurrenceStart) val fork = TaskFormWriter.apply( - master.copy( - id = 0, - masterId = taskId, - recurrenceId = occurrenceStart, - rrule = null, - rdate = null, - exdate = null, - href = null, - etag = null, - ), + newOverride(master, taskId, occurrenceStart, start, due), form, now, zone(), @@ -187,6 +190,54 @@ class RoomTasksDataSource @Inject constructor( tasks.update(TaskFormWriter.completed(current, completed, clock.now())) } + /** + * Ticking one occurrence forks a `RECURRENCE-ID` override carrying the + * completion — the same model [updateInstance] writes. Writing the status onto + * the master instead would close the series: the master is what + * [TaskDao.tasks] filters on, so every occurrence, past and future, would + * leave every list at once. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete instance $taskId") + // Not a series master — an override, or a plain task the caller handed an + // anchor for. Either way this row *is* the occurrence. + if (master.recurrenceSpec() == null) return setCompleted(taskId, completed) + + val now = clock.now() + tasks.override(taskId, occurrenceStart)?.let { + tasks.update(TaskFormWriter.completed(it, completed, now)) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val fork = TaskFormWriter.completed(newOverride(master, taskId, occurrenceStart, start, due), completed, now) + val id = tasks.insert(fork) + alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) } + } + + /** + * A blank override row for one occurrence of [master]: same UID (that is what + * makes it an override rather than a separate task), the series fields + * stripped, and no `href`/`etag` because the server has never seen it. + */ + private fun newOverride( + master: TaskEntity, + masterId: Long, + occurrenceStart: Instant, + start: Instant?, + due: Instant?, + ): TaskEntity = master.copy( + id = 0, + masterId = masterId, + recurrenceId = occurrenceStart, + dtstart = start, + due = due, + rrule = null, + rdate = null, + exdate = null, + href = null, + etag = null, + ) + /** * 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 @@ -201,6 +252,22 @@ class RoomTasksDataSource @Inject constructor( override fun createLocalList(name: String, color: Int): Long = lists.insert(TaskListEntity(name = name.trim(), color = color)) + override fun updateList(listId: Long, name: String, color: Int) { + val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId") + // Only an account-backed collection owes a server a PROPPATCH; a + // device-only list has nothing to be dirty for. + lists.update( + current.copy( + name = name.trim(), + color = color, + isDirty = current.accountId != null, + ), + ) + } + + /** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */ + override fun deleteList(listId: Long) = lists.delete(listId) + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt index 31e9a13..29f65e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -27,11 +27,19 @@ data class RecurrenceSpec( /** * The window expansion is bounded to: [from] inclusive, [until] exclusive, and * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + * + * [pivot] is where "now" sits inside the window, and it is what the occurrence + * budget is spent around. Without it a series firing more often than about + * once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly + * task would stop expanding months before today, so it would never appear in + * Today or Upcoming at all. At most a quarter of the budget goes to occurrences + * before [pivot], and the most recent of those are the ones kept. */ data class ExpansionWindow( val from: Instant, val until: Instant, val maxOccurrences: Int = 500, + val pivot: Instant = from, ) /** @@ -74,15 +82,28 @@ object RecurrenceExpander { val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) iterator.fastForward(window.from.toEpochMilliseconds()) - val occurrences = ArrayList() + // Occurrences arrive ascending, so everything before the pivot lands first + // and `past` is final by the time the first future one appears. Past is a + // sliding window (the newest are the ones worth keeping); the rest of the + // budget then goes to the future, undiminished when there is no past. + val pastCap = window.maxOccurrences / 4 + val past = ArrayDeque() + val future = ArrayList() var previous = Long.MIN_VALUE - while (occurrences.size < window.maxOccurrences && iterator.hasNext()) { + while (iterator.hasNext()) { val millis = iterator.next() if (millis == previous) continue previous = millis - occurrences += Instant.fromEpochMilliseconds(millis) + val at = Instant.fromEpochMilliseconds(millis) + if (at < window.pivot) { + if (past.size == pastCap) past.removeFirst() + if (pastCap > 0) past.addLast(at) + } else { + future += at + if (past.size + future.size >= window.maxOccurrences) break + } } - return occurrences + return past + future } /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt new file mode 100644 index 0000000..0ccf9fd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +/** + * The colours offered when creating or editing a task list. + * + * Raw ARGB, the way a CalDAV server sends one — every surface that draws a list + * colour runs it through + * [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues + * rather than final fills, chosen to stay distinguishable after that pass. A + * list can still carry any colour a server gives it; this is only the set the + * app hands out. + */ +val ListPalette: List = listOf( + 0xFF7A5C6B.toInt(), // mauve — Agendula's own seed + 0xFFD7484A.toInt(), // red + 0xFFE8743B.toInt(), // orange + 0xFFE0A32E.toInt(), // amber + 0xFF7CA83E.toInt(), // olive + 0xFF35A06A.toInt(), // green + 0xFF19938C.toInt(), // teal + 0xFF2A9BC4.toInt(), // cyan + 0xFF3C74C8.toInt(), // blue + 0xFF6A5CC0.toInt(), // indigo + 0xFF9455B8.toInt(), // purple + 0xFFC94F8E.toInt(), // pink +) + +/** What a new list gets before the user picks anything. */ +val DefaultListColor: Int = ListPalette.first() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index f7ddf99..6efdaba 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -98,4 +98,7 @@ object ActionShapes { /** Search — a 6-sided cookie, the same family as [Settings] but distinct. */ val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided + + /** New list — a sunny burst beside the Lists header. */ + val AddList: RoundedPolygon get() = MaterialShapes.Sunny } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index 98db1a4..fff9af0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -49,7 +49,7 @@ class TaskDetailViewModel @Inject constructor( fun bind(id: Long) { taskId.value = id } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } fun delete(task: Task) = viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt new file mode 100644 index 0000000..7ce564a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -0,0 +1,295 @@ +package de.jeanlucmakiola.agendula.ui.lists + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.ListPalette +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize + +private const val SWATCHES_PER_ROW = 6 + +/** + * Create or edit a task list: a name field over the palette of list colours, on + * the family's full-screen sheet with the commit in its title bar. + * + * [initial] null is the create case. [onDelete] is null for a list the app must + * not remove — an account's collection belongs to its server — which is also why + * the destructive row only appears when it is non-null. + */ +@Composable +fun ListEditorSheet( + initial: TaskList?, + onSave: (name: String, color: Int) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null, +) { + var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } + var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + + val valid = name.isNotBlank() + val commit = { + if (valid) { + onSave(name.trim(), color) + onDismiss() + } + } + + FullScreenPicker( + title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title), + onDismiss = onDismiss, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + }, + ) { + NameField( + name = name, + color = color, + // A new list opens with the keyboard up: naming it is the whole task. + autoFocus = initial == null, + onNameChange = { name = it }, + onImeAction = commit, + ) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.list_color)) + ColorGrid(selected = color, onSelect = { color = it }) + + if (onDelete != null) { + Spacer(Modifier.height(24.dp)) + DeleteRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) + } + + if (confirmDelete && onDelete != null) { + DeleteListDialog( + listName = initial?.name.orEmpty(), + onConfirm = { + confirmDelete = false + onDelete() + onDismiss() + }, + onDismiss = { confirmDelete = false }, + ) + } +} + +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +private fun NameField( + name: String, + color: Int, + autoFocus: Boolean, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ListColorChip(color) + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.list_name_hint), + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** The palette as two rows of round swatches; the chosen one carries a check. */ +@Composable +private fun ColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ListPalette.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + Swatch( + color = swatch, + dark = dark, + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches at the same size as a full + // one's rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun Swatch( + color: Int, + dark: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fill = pastelize(color, dark) + val label = stringResource(colorLabel(color)) + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + // selectable, not clickable: the swatch carries its chosen state in + // semantics, so the check below is decoration rather than the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = if (fill.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun DeleteRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.list_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun DeleteListDialog(listName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.list_delete_confirm_title)) }, + text = { Text(stringResource(R.string.list_delete_confirm_message, listName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + +/** Names the palette entries for screen readers; anything else is just "colour". */ +private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) { + 0 -> R.string.list_color_mauve + 1 -> R.string.list_color_red + 2 -> R.string.list_color_orange + 3 -> R.string.list_color_amber + 4 -> R.string.list_color_olive + 5 -> R.string.list_color_green + 6 -> R.string.list_color_teal + 7 -> R.string.list_color_cyan + 8 -> R.string.list_color_blue + 9 -> R.string.list_color_indigo + 10 -> R.string.list_color_purple + 11 -> R.string.list_color_pink + else -> R.string.list_color +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index b3f7439..fa5862a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -80,6 +81,10 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.priorityAccent @@ -108,10 +113,22 @@ fun ListsScreen( val state by viewModel.state.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } + var newList by rememberSaveable { mutableStateOf(false) } val closeSearch = { query = "" searchActive = false } + // With no lists there is nowhere to put a task, so the primary action becomes + // making one — otherwise a fresh install's FAB opens a form that cannot save. + val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true + // The sheet closes on save, so a refused write has to report itself here. + val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle() + LaunchedEffect(writeFailure) { + if (writeFailure != null) { + delay(4_000) + viewModel.clearWriteFailure() + } + } // System back closes search before leaving the screen. BackHandler(enabled = searchActive, onBack = closeSearch) @@ -130,9 +147,11 @@ fun ListsScreen( // The FAB would otherwise float over the search results. if (!searchActive) { ExtendedFloatingActionButton( - onClick = onNewTask, + onClick = if (noLists) ({ newList = true }) else onNewTask, icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, + text = { + Text(stringResource(if (noLists) R.string.list_add else R.string.new_task)) + }, ) } }, @@ -147,6 +166,7 @@ fun ListsScreen( state = s, onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, + onNewList = { newList = true }, topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, ) @@ -166,8 +186,33 @@ fun ListsScreen( } } } + // Anchored beside the FAB, at its height — the same receipt placement + // the task list uses. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + start = SnackChipMargin, + bottom = inner.calculateBottomPadding() + SnackChipMargin, + ) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + SnackChip( + visible = writeFailure != null, + message = stringResource(R.string.list_save_failed), + ) + } } } + + if (newList) { + ListEditorSheet( + initial = null, + onSave = viewModel::createList, + onDismiss = { newList = false }, + ) + } } @Composable @@ -175,6 +220,7 @@ private fun ListsContent( state: ListsUiState.Content, onOpenFilter: (TaskFilter) -> Unit, onOpenTask: (Long) -> Unit, + onNewList: () -> Unit, topPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp, ) { @@ -215,9 +261,21 @@ private fun ListsContent( } if (state.groups.isEmpty()) { - item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } + item { EmptyLists(onNewList = onNewList) } } else { - item { SectionHeader(stringResource(R.string.lists_header)) } + item { + SectionHeader( + text = stringResource(R.string.lists_header), + action = { + ShapedActionButton( + shape = ActionShapes.AddList, + icon = Icons.Rounded.Add, + contentDescription = stringResource(R.string.list_add), + onClick = onNewList, + ) + }, + ) + } state.groups.forEach { group -> item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> @@ -243,6 +301,28 @@ private fun ListsContent( } } +/** No lists at all — a fresh install, where nothing else on this screen works yet. */ +@Composable +private fun EmptyLists(onNewList: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.lists_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = onNewList) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.lists_empty_action)) + } + } +} + /** * The home top bar. There is no title — the launcher icon already says which app * this is. Settings is pinned at the right; the search action sits just left of it @@ -655,12 +735,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), - ) +private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 28.dp, + end = if (action == null) 28.dp else 20.dp, + top = if (action == null) 16.dp else 12.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index d6b8aeb..0215035 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -11,14 +11,23 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.ZoneId import javax.inject.Inject import kotlin.time.Clock +/** + * What a list write failed at. The screens turn this into wording; the view + * models stay free of resources. + */ +enum class ListWriteFailure { SAVE, DELETE } + data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) @@ -44,7 +53,7 @@ private const val UPCOMING_PREVIEW = 3 /** The home overview: smart lists with live counts, then user lists by account. */ @HiltViewModel class ListsViewModel @Inject constructor( - repository: TasksRepository, + private val repository: TasksRepository, ) : ViewModel() { val state: StateFlow = @@ -112,4 +121,22 @@ class ListsViewModel @Inject constructor( allTasks = openTasks + completedTasks, ) } + + private val _writeFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val writeFailure: StateFlow = _writeFailure.asStateFlow() + + fun clearWriteFailure() { _writeFailure.value = null } + + /** + * Create a device-only list. The lists flow picks it up on the store change; + * a refusal (External mode, a provider that says no) surfaces through + * [writeFailure] rather than vanishing, because the sheet has already closed. + */ + fun createList(name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.createLocalList(name.trim(), color) } + .onFailure { _writeFailure.value = ListWriteFailure.SAVE } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 4294383..5bcca82 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material3.Checkbox @@ -97,6 +98,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.ui.common.priorityAccent +import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight @@ -126,8 +129,22 @@ fun TaskListScreen( val state by viewModel.state.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content - val listName = content?.listName + val list = content?.list + val listName = list?.name val listId = (filter as? TaskFilter.OfList)?.listId + // Editing is offered for a device-only list. A collection that belongs to an + // account is the server's to rename or remove, not ours. + var editingList by rememberSaveable { mutableStateOf(false) } + val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() + val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() + // The list this screen is about is gone; there is nothing left to show. + LaunchedEffect(listDeleted) { if (listDeleted) onBack() } + LaunchedEffect(listWriteFailure) { + if (listWriteFailure != null) { + delay(4_000) + viewModel.clearListWriteFailure() + } + } // One add affordance, never two: a real list with the setting on gets a pinned // bottom quick-add bar; everything else (incl. smart lists, which have no single // target list) gets the floating "New task" button. @@ -162,6 +179,16 @@ fun TaskListScreen( ) } }, + actions = { + if (list != null && list.isLocal) { + IconButton(onClick = { editingList = true }) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.list_edit_title), + ) + } + } + }, scrollBehavior = scrollBehavior, ) }, @@ -199,18 +226,43 @@ fun TaskListScreen( .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - SnackChip( - visible = undoTarget != null, - message = stringResource(R.string.task_deleted), - actionLabel = stringResource(R.string.undo), - onAction = { - undoTarget?.let { viewModel.undoDelete(it.taskId) } - undoTarget = null - }, - ) + // One chip, one anchor: the undo receipt takes precedence, and a + // refused list write reports itself once the undo window is clear. + val failure = listWriteFailure + if (undoTarget != null || failure == null) { + SnackChip( + visible = undoTarget != null, + message = stringResource(R.string.task_deleted), + actionLabel = stringResource(R.string.undo), + onAction = { + undoTarget?.let { viewModel.undoDelete(it.taskId) } + undoTarget = null + }, + ) + } else { + SnackChip( + visible = true, + message = stringResource(listWriteFailureMessage(failure)), + ) + } } } } + + if (editingList && list != null) { + ListEditorSheet( + initial = list, + onSave = { name, color -> viewModel.updateList(list.id, name, color) }, + onDismiss = { editingList = false }, + onDelete = { viewModel.deleteList(list.id) }, + ) + } +} + +/** Wording for a refused list write. */ +private fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) { + ListWriteFailure.SAVE -> R.string.list_save_failed + ListWriteFailure.DELETE -> R.string.list_delete_failed } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 4287b7e..3729555 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -9,10 +9,13 @@ import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -28,13 +31,13 @@ sealed interface TaskListUiState { data object Failure : TaskListUiState /** - * [listName] is the real list's name when the filter is a - * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists — - * the screen falls back to the smart label in that case. + * [list] is the real list when the filter is a [TaskFilter.OfList] — it + * titles the bar and backs the edit action — and `null` for smart lists, + * where the screen falls back to the smart label. */ data class Content( val tasks: List, - val listName: String? = null, + val list: TaskList? = null, /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ val showAddSubtaskRow: Boolean = true, /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ @@ -65,8 +68,8 @@ class TaskListViewModel @Inject constructor( val tasks = repository.tasks(f) val content: kotlinx.coroutines.flow.Flow = when (f) { is TaskFilter.OfList -> - combine(tasks, repository.taskLists()) { list, lists -> - TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) + combine(tasks, repository.taskLists()) { rows, lists -> + TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } is TaskFilter.Smart -> tasks.map { TaskListUiState.Content(it) } @@ -126,7 +129,7 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */ @@ -155,6 +158,36 @@ class TaskListViewModel @Inject constructor( runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } } + private val _listWriteFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val listWriteFailure: StateFlow = _listWriteFailure.asStateFlow() + + private val _listDeleted = MutableStateFlow(false) + + /** Flips once the list this screen shows is really gone, so it can leave. */ + val listDeleted: StateFlow = _listDeleted.asStateFlow() + + fun clearListWriteFailure() { _listWriteFailure.value = null } + + /** Rename / recolour the list this screen is showing. */ + fun updateList(listId: Long, name: String, color: Int) = viewModelScope.launch { + if (name.isBlank()) return@launch + runCatching { repository.updateList(listId, name.trim(), color) } + .onFailure { _listWriteFailure.value = ListWriteFailure.SAVE } + } + + /** + * Delete the list **and its tasks**. The screen navigates away on + * [listDeleted], not on the call — leaving first would strand a refusal on a + * screen that no longer exists. + */ + fun deleteList(listId: Long) = viewModelScope.launch { + runCatching { repository.deleteList(listId) } + .onSuccess { _listDeleted.value = true } + .onFailure { _listWriteFailure.value = ListWriteFailure.DELETE } + } + /** Inline "add subtask" from an expanded list group — files it under [parent]. */ fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch { if (title.isBlank() || parent.listId <= 0L) return@launch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8b7cd..1a6beca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,32 @@ Lists New task Could not read your tasks. - No task lists yet. Add one in your tasks app or with the + button. + No task lists yet. + Create a list + + + New list + New list + Edit list + List name + Colour + Delete list + Could not save the list. + Could not delete the list. + Delete list? + “%1$s” and all of its tasks will be deleted. This can\'t be undone. + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Today Overdue Upcoming diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index 6f4e129..9d7a988 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -164,6 +164,25 @@ class ProviderResolverTest { assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") } + @Test + fun `a mode change notifies listeners once, and only on a real change`() { + // What store observers hang off: a live flow is bound to one store, so + // it has to be told when the store underneath it is swapped. + val resolver = resolver(mode = StorageMode.OWN) + var fired = 0 + val handle = resolver.onModeChanged { fired++ } + + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(0) + + resolver.storageMode = StorageMode.EXTERNAL + assertThat(fired).isEqualTo(1) + + handle.close() + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(1) + } + @Test fun `never name an authority of ours`() { // EXTERNAL must mean "somebody else's store", and Agendula publishes no diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt index 2c08ac6..bb98ffc 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -164,6 +164,45 @@ class RecurrenceExpanderTest { assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") } + @Test + fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() { + // The real shape: an eight-hourly task, a year of window behind now and a + // 500-occurrence ceiling. Filling the budget from the window start would + // exhaust it ~166 days before now, so the task would never appear in Today + // or Upcoming at all. + val result = expand( + spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"), + ExpansionWindow( + from = at("2024-06-01T00:00:00Z"), + until = at("2026-06-01T00:00:00Z"), + maxOccurrences = 500, + pivot = at("2025-06-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(500) + // A quarter of the budget looks back, and it keeps the *most recent* of + // the past — not the oldest, which is what filling from the window start + // would have kept. (ISO-8601 UTC sorts lexicographically.) + assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125) + assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z") + assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z") + } + + @Test + fun `the ceiling goes entirely to the future when nothing precedes the pivot`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + ExpansionWindow( + from = at("2025-01-01T00:00:00Z"), + until = at("2030-01-01T00:00:00Z"), + maxOccurrences = 4, + pivot = at("2025-01-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + @Test fun `an unbounded rule stops at the window end`() { val result = expand( diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e55669..a73b09f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -207,6 +207,18 @@ most of it broken, absent or unusable. Reasoning in - ✅ Fallout: `ReminderScheduler.sync()` gated on `resolve() != null`, which is what `OWN` returns, so no due reminder armed in the default mode. It now gates on `ProviderResolver.canReadStore()`, with tests. +- ✅ Fallout, second pass — four defects a review of the branch turned up: + **completing one occurrence closed the whole series** (`setCompleted` wrote the + master, which is the row `TaskDao.tasks` filters on, so every occurrence left + every list); the **expansion ceiling was spent on the past**, so a sub-daily + series stopped expanding months before today and never reached Today or + Upcoming; an imported **`START`-referenced reminder fired off `DUE`**, because + the seam collapsed alarms to a bare minute count; and `registerObserver` bound + a live flow to whichever store was active at subscription, so a Settings + store switch would have left every screen listening to the store it had + stopped reading. `setCompletedInstance` now forks a `RECURRENCE-ID` override + the way `updateInstance` does — phase 2 always specified this, only the edit + half had it. - ⬜ **Run the instrumented suite on a device.** Six classes — the Room seam, the DAOs, the import, the migration harness, the restore path and the performance check — all compile and none has ever executed. Everything load-bearing about @@ -215,6 +227,21 @@ most of it broken, absent or unusable. Reasoning in v0.3.2 APK with seeded data landing every task, list and reminder. - ⬜ Per-locale release notes for the dropped authority and permissions. +### ✅ Managing lists in the app +Owning the store made this mandatory: there is no longer a provider app to +create a list in, so a fresh install had no lists, no way to make one, and +therefore no way to save a task. The seam gained `updateList` / `deleteList` +beside the existing `createLocalList`, implemented on both the Room and the +External path (which addresses the row as its own account's sync adapter, the +only caller the provider lets write `tasklists`). +- ✅ `ListEditorSheet` — the family's full-screen sheet with a name field, the + 12-colour palette and, when editing, a destructive row behind a confirm. +- ✅ Entry points: a "New list" row under the home Lists section, a real empty + state with a create button, and the home FAB switching to "New list" while + there are none. Editing is the pencil in a list's own top bar. +- ✅ Deleting a list deletes its tasks — `tasks.list_id` cascades — and is + offered only for device-only lists; an account's collection is its server's. + --- ## Open decisions / to verify From 9ff6027e506c4114633d3a2dfe3310c70724f25f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 14:09:19 +0200 Subject: [PATCH 19/21] chore: stop tracking CLAUDE.md The file is machine-specific rather than anything the project depends on: the ARM64 box64 `aapt`/`aapt2` wrappers it documents live outside the repo, and the rest is on-device working rules. Nothing in the tree links to it. It stays on disk and is now ignored, so it keeps working locally without riding along in the branch. --- .gitignore | 4 ++ CLAUDE.md | 124 ----------------------------------------------------- 2 files changed, 4 insertions(+), 124 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index db92862..632e80e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ Thumbs.db # KSP .ksp/ + +# Local agent notes: machine-specific build setup and on-device rules, not +# anything the project itself depends on. +/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 62fd097..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,124 +0,0 @@ -## Build host (ARM64) - -The primary dev machine is ARM64 (aarch64) Linux, and Google ships no -linux-aarch64 `aapt`/`aapt2` — both run under **box64** via wrappers that live -outside the repo (`~/.gradle/box64-aapt2/aapt2` behind -`android.aapt2FromMavenOverride`, and an `aapt` wrapper installed straight into -`~/Android/Sdk/build-tools/*/` because AGP's test-APK installer hardcodes that -path). Nothing in the project tree depends on this; x86-64 machines and CI are -unaffected. - -The trap: without the `aapt` wrapper, `connectedDebugAndroidTest` **exits -non-zero even when every test passes** — teardown fails, and Gradle reports it as -"There were failing tests". If that shows up, read -`app/build/outputs/androidTest-results/connected/debug/*.xml` for the real -result before believing the exit code. - -## On-device work (USB-connected phone) - -A physical phone is connected over USB. Rules: - -- **Install when asked** — if the user says to install/deploy on device, do it - (build + `adb install`). That's the one action you may take on your own. -- **Do nothing else on the device unprompted.** Do not launch the app, take - screenshots, dump/read logcat, poke UI, or otherwise "test" or verify on the - device on your own initiative — even to confirm a change works. -- Read logs, capture screenshots, and inspect on-device behaviour **only when the - user explicitly asks for it, each time.** The user drives when it's time to - look; wait for that instruction. - -## UI / design conventions - -- Material 3 Expressive throughout. Consult the `material-3` skill before - designing anything new; prefer M3 tokens/components over hardcoded colours. -- **Selection pickers are full-screen** — browse-style "choose one" surfaces - (visibility, reminder, recurrence rule, colour, calendar, add-field, plus the - Settings pickers) use floret-kit's `FullScreenPicker` / `OptionPicker` (a - full-bleed sheet with a pinned title bar and connected grouped rows); a picker - that needs a commit/extra action passes it via the picker's `actions` slot. - The exception is the **recurring scope choosers** — the "this / this & following - / all" prompts shown when you *save an edit to*, *drag* or *delete* a recurring - event — which stay compact `OptionCard`-in-`AlertDialog` popups (a quick 2–3 option - decision reads better as a popup than a near-empty full screen). `AlertDialog` - is otherwise only for plain confirmations. Radio/text-list dialogs are banned. - -## Releases - -The committed `versionCode` / `versionName` in `app/build.gradle.kts` **are the -release trigger**: merging a bumped `versionName` into `main` runs -`.gitea/workflows/release.yaml`, which builds, signs, publishes to the -self-hosted F-Droid repo, then mints the `vX.Y.Z` tag + release. `versionCode` is -pinned to `MAJOR*10000 + MINOR*100 + PATCH` (e.g. 2.13.0 → 21300). Full process -in `docs/RELEASING.md`. **Never tag/release UI changes before on-device review -and explicit go-ahead.** - -Release builds are kept **F-Droid reproducible** — `vcsInfo`, `dependenciesInfo`, -and the AGP metadata block are deliberately disabled in `build.gradle.kts`; don't -re-enable them. Use the `releaseTest` build type (R8-shrunk twin, debug-signed, -own applicationId suffix) to smoke-test a release candidate on-device. - -### Per-version changelogs are written by hand, in every locale - -`fastlane/metadata/android//changelogs/.txt` is the -"What's New" both F-Droid and Play publish. **There is no auto-translation -layer** — Weblate owns `values-*/strings.xml` only, not the fastlane tree — so -when cutting a release you write these files yourself, one per locale, each a -short summary under **500 characters** (Play's hard cap, applied per locale; -F-Droid truncates in-client). - -Write one for **every language the app ships** (`app/src/main/res/values-*`), -using store locale codes: `en-US`, `en-GB`, `de-DE`, `es-ES`, `fr-FR`, `it-IT`, -`pl-PL`, `pt-PT`, `ru-RU`, `zh-CN`, `ar`. Missing locales aren't fatal — both -stores fall back — but see the `en-GB` trap below. - -**`en-GB` is the Play Console's default language.** Play's fallback is the -*default locale*, not `en-US`, so a release with only an `en-US` changelog ships -with **no "What's New" at all** — this is why the latest release had none. -`en-GB` must exist. - -The rest of the plumbing is already locale-agnostic: `sync_changelog_to_fastlane.sh` -seeds `en-US` only and never overwrites a committed file, while -`fastlane_to_fdroid_localized.sh` and `supply` pick up every locale that has a -`changelogs/` dir. So the files are the whole job. - -## Forge / `tea` CLI - -**Codeberg is canonical** (`codeberg.org/jlmakiola/calendula`) for git, issues, -PRs, tags and releases — including the `floret-kit` submodule. The self-hosted -Gitea instance is **build infrastructure only**: signing key, F-Droid publishing, -release pipeline. - -Use the **`tea` CLI** for forge interaction — not raw API calls. Note the flag is -a *subcommand* flag, not global: `tea pulls list --login codeberg`, never -`tea --login codeberg pulls list`. Two accounts, neither default: - -- **Everything → `codeberg`** (user `jlmakiola`). PRs, issues, releases, repo - settings. `tea pulls create --login codeberg ...` -- **Build infra only → `jeanluc`** (`gitea.jeanlucmakiola.de`, user `makiolaj`). - Release-pipeline runs, Actions secrets. `tea ... --login jeanluc` - -Workflows are split by directory and this is load-bearing — Forgejo's lookup is -first-match-wins across `.forgejo/` → `.gitea/` → `.github/`: - -- `.forgejo/workflows/` runs on **Codeberg** (`ci.yaml`, `translations.yaml`) and - must reference **no secrets** — that's what makes fork PRs safe. -- `.gitea/workflows/` runs on **Gitea** (`release.yaml`, `renovate.yml`) and is - where every secret lives. - -Don't add a workflow without deciding which side it belongs on. - -## Translations - -Community translations are managed on a self-hosted **Weblate**, which owns all -`values-*` files (including German — API only, never hand-edit). Partial -translations are expected (`MissingTranslation` is informational, not fatal); -extra/stale keys stay fatal. - - -## Git Operations - -Use a commit format which references the issues, dont add any Co-Authered by Claude lines, and don't write extensive commit and merge messages, simple human ones suffice, for prs, stuff like testing etc, isnt interesting write what has chnaged, and if deviated from the underling issues pls explain why, add a closes issue line at the end of all prs. - -## Comments - -Don't add extensive code comments, methode discription, so as a Java Doc is fine, but no extensive explanbanitory conmments From ec2e2eb59d1331d4d7c6ed692e8679cc58c61f1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 15:37:48 +0200 Subject: [PATCH 20/21] feat(settings): storage picker and export screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store picker and the export screen were the two frontend surfaces the own-store work left unbuilt, so both backends shipped unreachable. Settings gains a Storage section holding them: a full-screen picker over Own / an installed external provider (dimmed when none is present, named after the provider's own app), and an export screen with a per-list tick and the two SAF destinations, a folder or a single zip. The picker asks for the provider's runtime permission before writing the mode, so a denial leaves the readable store in place instead of dropping the user on the gate; a refusal is reported with a route to app settings. Making the mode switchable at runtime had two consequences: - reminders are armed off whichever store was active when they were scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is now serialised — it is a read-modify-write over ScheduledReminderStore, and overlapping runs each wrote their own set as the whole truth - the permission gate is the only screen an External user can reach once their provider app stops answering, so it offers the way back to our own store ExportWriter no longer deletes a previous export before recreating it (a failure in between lost both), lists the target directory once instead of per document, and carries a typed ExportFailure so the screen can report in the user's language rather than an exception message. --- .../de/jeanlucmakiola/agendula/AgendulaApp.kt | 20 +- .../agendula/data/export/ExportWriter.kt | 59 +++-- .../data/reminders/ReminderScheduler.kt | 25 ++- .../data/tasks/ProviderEnvironment.kt | 8 + .../jeanlucmakiola/agendula/ui/RootScreen.kt | 27 +-- .../agendula/ui/common/OnResume.kt | 29 +++ .../agendula/ui/export/ExportScreen.kt | 182 ++++++++++++++++ .../agendula/ui/export/ExportViewModel.kt | 122 +++++++++++ .../ui/permission/PermissionViewModel.kt | 53 ++++- .../agendula/ui/settings/SettingsScreen.kt | 53 +++-- .../agendula/ui/settings/SettingsViewModel.kt | 53 +++++ .../agendula/ui/settings/StorageScreen.kt | 203 ++++++++++++++++++ app/src/main/res/values/strings.xml | 31 +++ .../data/tasks/ProviderResolverTest.kt | 1 + docs/ARCHITECTURE.md | 21 +- docs/ROADMAP.md | 28 ++- docs/STORAGE-AND-SYNC.md | 9 +- 17 files changed, 845 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 1b8c87f..517bfc3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -7,15 +7,16 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean /** * Application entry point. Registered as android:name=".AgendulaApp". Besides @@ -43,14 +44,23 @@ class AgendulaApp : Application() { // Mirror the stored storage mode into ProviderResolver and import a // v0.3.x install's tasks, both before anything reads a store. val startupGate = entryPoint.startupGate() + val scope = entryPoint.applicationScope() + // An alarm is armed off whichever store was active when it was scheduled, + // so a switch has to rebuild the set. Armed only once startup's own + // null -> stored transition is past, which the launch sync below covers. + val started = AtomicBoolean(false) + entryPoint.providerResolver().onModeChanged { + if (started.get()) scope.launch { runCatching { scheduler.sync() } } + } startupGate.start() ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) - CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + scope.launch { // Wait for the stored mode and the import to land first. Rescheduling // alarms against whichever store autoMode happens to pick would arm // them off the wrong one — or off an empty one, mid-import. runCatching { startupGate.awaitReady() + started.set(true) scheduler.sync() } } @@ -61,6 +71,10 @@ class AgendulaApp : Application() { interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler fun startupGate(): StartupGate + fun providerResolver(): ProviderResolver + + @ApplicationScope + fun applicationScope(): CoroutineScope fun databaseCheckpoint(): DatabaseCheckpoint } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt index 0376c58..03f43d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -17,8 +17,23 @@ import javax.inject.Singleton /** Where an export ended up, for the UI to report. */ data class ExportResult(val fileCount: Int, val taskListNames: List) -/** The export could not be written. Carries a cause worth showing a user. */ -class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause) +/** + * Why an export failed, as a value rather than a message: the UI ships in eleven + * locales, so the wording has to come from a string resource. + */ +enum class ExportFailure { + FOLDER_UNAVAILABLE, + FOLDER_NOT_WRITABLE, + CANNOT_CREATE_FILE, + LOST_ACCESS, + WRITE_FAILED, +} + +/** The export could not be written. */ +class ExportFailedException( + val failure: ExportFailure, + cause: Throwable? = null, +) : IOException(failure.name, cause) /** * Writes [ExportDocument]s to a user-chosen location through the Storage Access @@ -44,22 +59,30 @@ class ExportWriter @Inject constructor( /** * Writes every document into [treeUri], a directory the user picked. * - * Overwrites same-named files rather than letting SAF append " (1)" — an - * export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes - * the folder useless as a backup. + * A same-named file is truncated and rewritten in place rather than deleted + * and recreated: SAF would otherwise append " (1)" and turn the folder into + * an unusable pile of snapshots, and a delete that is not followed by a + * successful create loses the previous export outright. + * + * The directory is listed once. `DocumentFile.findFile` queries the whole + * tree per call, so looking each name up in the loop is one full + * cross-process directory scan per list. */ suspend fun writeToTree(treeUri: Uri, documents: List): ExportResult = withContext(io) { - val tree = DocumentFile.fromTreeUri(context, treeUri) - ?: throw ExportFailedException("Cannot open the chosen folder") - if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable") + runCatching { + val tree = DocumentFile.fromTreeUri(context, treeUri) + ?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE) + if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE) + val existing = tree.listFiles().associateBy { it.name } - documents.forEach { document -> - tree.findFile(document.fileName)?.delete() - val file = tree.createFile(MIME_ICALENDAR, document.fileName) - ?: throw ExportFailedException("Cannot create ${document.fileName}") - write(file.uri, document.content) - } + documents.forEach { document -> + val file = existing[document.fileName] + ?: tree.createFile(MIME_ICALENDAR, document.fileName) + ?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE) + write(file.uri, document.content) + } + }.getOrElse { throw asExportFailure(it) } ExportResult(documents.size, documents.map { it.fileName }) } @@ -80,7 +103,7 @@ class ExportWriter @Inject constructor( zip.closeEntry() } } - } ?: throw ExportFailedException("Cannot write to the chosen file") + } ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) }.getOrElse { throw asExportFailure(it) } ExportResult(documents.size, documents.map { it.fileName }) } @@ -90,7 +113,7 @@ class ExportWriter @Inject constructor( // "wt" truncates. Without it a shorter export leaves the tail of the // previous, longer one behind and produces a corrupt file. context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) } - ?: throw ExportFailedException("Cannot write to the chosen file") + ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) }.getOrElse { throw asExportFailure(it) } } @@ -98,8 +121,8 @@ class ExportWriter @Inject constructor( is ExportFailedException -> cause // A SAF grant can be revoked between the picker and the write (the volume // was unmounted, the provider's process died, the user cleared the grant). - is SecurityException -> ExportFailedException("Lost access to the chosen location", cause) - is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause) + is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause) + is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause) else -> cause } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 089ee9d..58467e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -12,6 +12,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -20,8 +22,8 @@ import javax.inject.Singleton * The self-scheduled due-reminder engine. Nothing else delivers task reminders — * not the platform, not a tasks provider — so Agendula reads upcoming due tasks * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run - * on app start, on boot, and on an external provider change; it diffs against - * [ScheduledReminderStore] so only changed alarms move. + * on app start, on boot, on a store switch, and on an external provider change; + * it diffs against [ScheduledReminderStore] so only changed alarms move. */ @Singleton class ReminderScheduler @Inject constructor( @@ -32,19 +34,32 @@ class ReminderScheduler @Inject constructor( private val providerResolver: ProviderResolver, @IoDispatcher private val io: CoroutineDispatcher, ) { - suspend fun sync() = withContext(io) { + private val syncLock = Mutex() + + /** + * Diff the armed alarms against the store and move only what changed. + * + * Serialised: the diff is a read-modify-write over [ScheduledReminderStore], + * and callers overlap (a store switch fires this while the launch sync may + * still be running). Two interleaved runs would each write their own set as + * the whole truth, leaving the other's alarms armed but unrecorded — never + * cancelled, and firing against the wrong store's task ids. + */ + suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } } + + private suspend fun syncLocked() { val settings = settingsPrefs.settings.first() // Gate on whether the store is readable, not on whether a provider // resolves: our own store deliberately resolves to no provider, so the // latter clears every reminder in the default mode. if (!settings.remindersEnabled || !providerResolver.canReadStore()) { clearAll() - return@withContext + return } val now = System.currentTimeMillis() val horizon = now + WINDOW_MS val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } - .getOrElse { return@withContext } + .getOrElse { return } // One reminder per *occurrence*: a recurring series yields a row per // occurrence, all sharing a taskId, so this is a Set rather than a diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt index 4cfca57..e60193f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -23,6 +23,9 @@ interface ProviderEnvironment { /** Whether this app currently holds [permission]. */ fun isGranted(permission: String): Boolean + + /** [packageName]'s own app name, or null when it cannot be read. */ + fun appLabel(packageName: String): String? } @Singleton @@ -35,4 +38,9 @@ class AndroidProviderEnvironment @Inject constructor( override fun isGranted(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + override fun appLabel(packageName: String): String? = runCatching { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + }.getOrNull() } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index fc41b08..3154095 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -10,19 +10,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel @@ -46,20 +44,20 @@ fun RootScreen( // Re-check on every resume, not just after the in-app request: the user may // have granted the permission (or installed a provider) in system Settings and // come back, and otherwise the gate would hold until the process restarts. - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } + OnResume { permissionViewModel.refresh() } + + // Neither gate can show in OWN mode (that store is always READY), so the way + // out of one is always our own store. Without it a user whose provider app + // went away is held on this screen with Settings behind it. + val fallback = stringResource(R.string.onboarding_use_own_store) when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, title = stringResource(R.string.onboarding_no_provider_title), body = stringResource(R.string.onboarding_no_provider_body), + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.NEEDS_PERMISSION -> Gate( modifier = modifier, @@ -67,6 +65,8 @@ fun RootScreen( body = stringResource(R.string.onboarding_permission_body), action = stringResource(R.string.onboarding_permission_button), onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.READY -> ReadyGate(modifier = modifier) } @@ -103,6 +103,8 @@ private fun Gate( modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}, + secondaryAction: String? = null, + onSecondaryAction: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxSize().padding(24.dp), @@ -112,5 +114,6 @@ private fun Gate( Text(title, style = MaterialTheme.typography.headlineSmall) Text(body, style = MaterialTheme.typography.bodyMedium) if (action != null) Button(onClick = onAction) { Text(action) } + if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt new file mode 100644 index 0000000..b29f473 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Runs [block] on every `ON_RESUME`. + * + * For state the app cannot observe because it is granted, revoked or installed + * outside it — a runtime permission, an exact-alarm allowance, a provider app — + * which otherwise stays stale until the process restarts. + */ +@Composable +fun OnResume(block: () -> Unit) { + val current by rememberUpdatedState(block) + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) current() + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt new file mode 100644 index 0000000..c9ae0f1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -0,0 +1,182 @@ +package de.jeanlucmakiola.agendula.ui.export + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderZip +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +private const val ZIP_MIME = "application/zip" +private const val ZIP_NAME = "agendula-tasks.zip" + +/** + * Export the task lists as iCalendar. Which lists go out is a per-list tick; the + * destination is a folder or a single zip, both picked through SAF so the app + * needs no storage permission. + */ +@Composable +fun ExportScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val dark = isSystemInDarkTheme() + + val folderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::exportToFolder) } + val zipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(ZIP_MIME), + ) { uri -> uri?.let(viewModel::exportToZip) } + + val canExport = !state.running && state.selectedCount > 0 + + CollapsingScaffold( + title = stringResource(R.string.settings_export), + onBack = onBack, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.export_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + + if (state.lists.isEmpty()) { + GroupedRow( + title = stringResource(R.string.export_no_lists), + position = Position.Alone, + dimmed = true, + ) + } else { + state.lists.forEachIndexed { index, list -> + val selected = state.isSelected(list.id) + GroupedRow( + title = list.name, + // The account only says something when it isn't the device itself. + summary = list.accountName.takeIf { !list.isLocal }, + position = positionOf(index, state.lists.size), + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + trailing = { + Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) }) + }, + onClick = { viewModel.toggle(list.id) }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { folderLauncher.launch(null) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_folder)) + } + OutlinedButton( + onClick = { zipLauncher.launch(ZIP_NAME) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_zip)) + } + } + + Spacer(Modifier.height(16.dp)) + ExportStatus(state = state) + Spacer(Modifier.height(24.dp)) + } +} + +/** The running spinner, then whatever the last export ended as — it stays put. */ +@Composable +private fun ExportStatus(state: ExportUiState) { + val outcome = state.outcome + when { + state.running -> Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = stringResource(R.string.export_running), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + outcome is ExportOutcome.Success -> StatusText( + text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is ExportOutcome.Failure -> StatusText( + text = stringResource(failureMessage(outcome.reason)), + color = MaterialTheme.colorScheme.error, + ) + } +} + +private fun failureMessage(reason: ExportFailure): Int = when (reason) { + ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder + ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only + ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create + ExportFailure.LOST_ACCESS -> R.string.export_failed_access + ExportFailure.WRITE_FAILED -> R.string.export_failed +} + +@Composable +private fun StatusText(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt new file mode 100644 index 0000000..f091b9c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.agendula.ui.export + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.export.ExportFailedException +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.agendula.data.export.ExportResult +import de.jeanlucmakiola.agendula.data.export.ExportWriter +import de.jeanlucmakiola.agendula.data.export.TaskExporter +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** How the last export ended, kept on screen rather than flashed past. */ +sealed interface ExportOutcome { + data class Success(val fileCount: Int) : ExportOutcome + data class Failure(val reason: ExportFailure) : ExportOutcome +} + +data class ExportUiState( + val lists: List = emptyList(), + /** Lists the user has ticked *off*; everything else is included. */ + val excluded: Set = emptySet(), + val running: Boolean = false, + val outcome: ExportOutcome? = null, +) { + fun isSelected(listId: Long): Boolean = listId !in excluded + + val selectedCount: Int get() = lists.count { isSelected(it.id) } +} + +/** + * Drives the export screen. Holds the selection as an exclusion set so a list + * that appears while the screen is open is exported too — the natural reading of + * "everything, minus what I unticked". + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + repository: TasksRepository, + resolver: ProviderResolver, + private val exporter: TaskExporter, + private val writer: ExportWriter, +) : ViewModel() { + + private val excluded = MutableStateFlow(emptySet()) + private val running = MutableStateFlow(false) + private val outcome = MutableStateFlow(null) + + private var exportJob: Job? = null + + // List ids are per-store, and Settings can switch stores with this ViewModel + // still alive — so the selection, the receipt and a write already addressing + // the old store's lists all go with it. + private val modeHandle = resolver.onModeChanged { + exportJob?.cancel() + excluded.value = emptySet() + outcome.value = null + } + + override fun onCleared() { + modeHandle.close() + } + + val state: StateFlow = + combine( + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + excluded, + running, + outcome, + ) { lists, excluded, running, outcome -> + ExportUiState(lists, excluded, running, outcome) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState()) + + fun toggle(listId: Long) = excluded.update { current -> + if (listId in current) current - listId else current + listId + } + + /** Writes one `.ics` per list into a folder the user picked through SAF. */ + fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) } + + /** Writes every list into a single zip the user named through SAF. */ + fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) } + + private fun export(write: suspend (List) -> ExportResult) { + if (running.value) return + running.value = true + outcome.value = null + exportJob = viewModelScope.launch { + // Null means "every list" to the exporter, and is what an untouched + // screen should send: the flow may not have emitted a list yet. + val selection = excluded.value.takeIf { it.isNotEmpty() } + ?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped } + try { + val result = write(exporter.export(selection)) + outcome.value = ExportOutcome.Success(result.fileCount) + } catch (cancelled: CancellationException) { + // Leaving the screen mid-write is not a failed export. + throw cancelled + } catch (error: Exception) { + outcome.value = ExportOutcome.Failure( + (error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED, + ) + } finally { + running.value = false + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 4cdd0cf..5c1e89c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -1,13 +1,25 @@ package de.jeanlucmakiola.agendula.ui.permission import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject data class PermissionUiState( @@ -21,25 +33,48 @@ data class PermissionUiState( * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. * - * In the default Local mode this gate never appears at all — Agendula's own - * provider ships in the APK and is reached same-uid, so there is nothing to - * install and nothing to grant. It exists for External mode. + * In the default Own mode this gate never appears at all — the store is our own + * Room database, so there is nothing to install and nothing to grant. It exists + * for External mode, which also makes it the only screen an External user can + * reach once their provider app stops answering: hence [useOwnStore]. */ @HiltViewModel class PermissionViewModel @Inject constructor( private val repository: TasksRepository, private val providerResolver: ProviderResolver, + private val prefs: SettingsPrefs, ) : ViewModel() { - private val _state = MutableStateFlow(PermissionUiState()) - val state: StateFlow = _state.asStateFlow() + private val refreshes = MutableStateFlow(0) - init { refresh() } + // Re-evaluated when the *resolver's* mode lands, not when the preference is + // written: anything read in between still answers for the store we just left. + // Conflated, as everywhere else this signal is bridged: only the latest mode + // matters, and a full buffer drops it rather than the ones it supersedes. + private val modeChanges: Flow = callbackFlow { + trySend(Unit) + val handle = providerResolver.onModeChanged { trySend(Unit) } + awaitClose { handle.close() } + }.buffer(Channel.CONFLATED) + + val state: StateFlow = + combine(refreshes, modeChanges) { _, _ -> currentState() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState()) /** Re-read provider + permission state (call after returning from a request). */ - fun refresh() { + fun refresh() = refreshes.update { it + 1 } + + /** + * Leave a store this device can no longer read. The provider app can be + * uninstalled, or its permission revoked, after External was chosen — and the + * gate is then the only screen reachable, Settings included. Our own store + * always reads, so it is the way out. + */ + fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) } + + private fun currentState(): PermissionUiState { val provider = providerResolver.resolve() - _state.value = PermissionUiState( + return PermissionUiState( status = repository.providerStatus(), // Null in OWN mode, where there is no provider and nothing to grant. permissionsToRequest = provider diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 712a61d..5c6da54 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material.icons.rounded.Storage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -56,7 +57,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.ui.export.ExportScreen import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.CollapsingScaffold @@ -96,10 +94,22 @@ import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.reminderOverrideFor +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, TaskForm, Reminders } +private enum class SettingsSection { + Appearance, + TaskForm, + Reminders, + Storage, + Export, + ; + + /** Where back goes: Export is opened from Storage, not from the hub. */ + val parent: SettingsSection? + get() = if (this == Export) Storage else null +} /** * Token-based accent for a leading icon chip (container / on-container pair), @@ -125,7 +135,7 @@ fun SettingsScreen( // Inside a sub-screen, system back (button or gesture) returns to the hub // rather than popping the whole Settings destination to the lists overview. - BackHandler(enabled = section != null) { section = null } + BackHandler(enabled = section != null) { section = section?.parent } Box( modifier = modifier @@ -143,6 +153,19 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Reminders) { RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + // Storage stays composed under Export, so the deeper screen slides over it. + val storageOpen = section == SettingsSection.Storage || + section?.parent == SettingsSection.Storage + SlideInSection(visible = storageOpen) { + StorageScreen( + viewModel = viewModel, + onOpenExport = { section = SettingsSection.Export }, + onBack = { section = null }, + ) + } + SlideInSection(visible = section == SettingsSection.Export) { + ExportScreen(onBack = { section = SettingsSection.Storage }) + } } } @@ -212,6 +235,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_storage), + summary = stringResource(R.string.settings_storage_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Storage) }, + ) LanguageRow(position = Position.Middle) ReportProblemRow(position = Position.Bottom) @@ -670,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean { context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), ) } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() - } + OnResume { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } } return allowed } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index e8d0614..b607be0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.floret.reminders.ReminderOverride +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,6 +32,22 @@ data class SettingsUiState( val lists: List = emptyList(), ) +/** + * The storage half of Settings: which store is active, and what picking the + * other one would mean on this device. + * + * Kept apart from [SettingsUiState] because that one is collected for the whole + * Activity lifetime to drive the theme, and re-probing PackageManager on every + * theme emission would be work for nothing. + */ +data class StorageUiState( + val mode: StorageMode, + /** The external provider installed here, or null when there is none to pick. */ + val external: TaskProvider? = null, + /** That provider's own app name, for a row that names what it is switching to. */ + val externalLabel: String? = null, +) + /** * Drives both the Settings screen and the app theme (MainActivity collects the * same instance), so a theme change applies app-wide at once. @@ -30,6 +55,9 @@ data class SettingsUiState( @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + private val environment: ProviderEnvironment, + @IoDispatcher io: CoroutineDispatcher, repository: TasksRepository, ) : ViewModel() { @@ -44,6 +72,31 @@ class SettingsViewModel @Inject constructor( SettingsUiState(settings, lists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + // Bumped to re-probe the device; installing a provider or granting its + // permission happens outside the app, so nothing else would emit. + private val providerProbe = MutableStateFlow(0) + + // Null until the first emission lands: the mode comes from DataStore and the + // rest from PackageManager, off the main thread, so any seeded default would + // name the wrong store for the first frames. flowOn, because every field here + // costs a PackageManager lookup or a permission check. + val storage: StateFlow = + combine(prefs.storageMode, providerProbe) { stored, _ -> + val external = resolver.resolveExternal() + StorageUiState( + // No stored choice is the normal state; show what autoMode resolves + // to rather than a default that may not be the store in use. + mode = stored ?: resolver.autoMode(), + external = external, + externalLabel = external?.packageName?.let(environment::appLabel), + ) + }.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Re-read the device's provider state, after a permission request or a resume. */ + fun refreshStorage() = providerProbe.update { it + 1 } + + fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) } + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt new file mode 100644 index 0000000..bc7c494 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck + +/** + * Where the tasks live: the store picker the resolver's `autoMode()` has always + * assumed, plus the way out of a store that lives in our own private storage. + */ +@Composable +internal fun StorageScreen( + viewModel: SettingsViewModel, + onOpenExport: () -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val storage by viewModel.storage.collectAsStateWithLifecycle() + var showPicker by remember { mutableStateOf(false) } + var denied by remember { mutableStateOf(false) } + + // The mode is committed only once the grant is in — switching first drops the + // user on the app-wide permission gate. + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + viewModel.refreshStorage() + val granted = grants.isNotEmpty() && grants.values.all { it } + denied = !granted + if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL) + } + + // A provider can be installed, or its permission revoked, while we're away. + // Deliberately does not clear [denied]: this fires on returning from the + // permission dialog too, and would wipe the refusal before it is read. + OnResume { viewModel.refreshStorage() } + + CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_task_store), + summary = storage?.let { storeLabel(it) }, + position = Position.Top, + // Nothing to pick until the stored mode has landed; opening the picker + // on the seeded state would offer the wrong store as the current one. + onClick = storage?.let { + { + denied = false + showPicker = true + } + }, + ) + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Bottom, + onClick = onOpenExport, + ) + + if (denied) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_store_permission_denied), + summary = stringResource(R.string.settings_store_permission_denied_hint), + position = Position.Alone, + onClick = { context.openAppSettings() }, + ) + } + } + + storage?.let { state -> + if (showPicker) { + StorePicker( + storage = state, + onSelect = { mode -> + val external = state.external + if (mode == StorageMode.EXTERNAL && external != null) { + // Asked even when the grant looks held: an already-granted + // request returns at once, a stale belief would strand them. + permissionLauncher.launch( + arrayOf(external.readPermission, external.writePermission), + ) + } else { + viewModel.setStorageMode(mode) + } + }, + onDismiss = { showPicker = false }, + ) + } + } +} + +/** + * The two stores, as rows. External is offered only when a provider is actually + * installed — dimmed and inert otherwise, because a mode with nothing behind it + * empties the app. + */ +@Composable +private fun StorePicker( + storage: StorageUiState, + onSelect: (StorageMode) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) { + Text( + text = stringResource(R.string.settings_task_store_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + val select: (StorageMode) -> Unit = { chosen -> + onSelect(chosen) + onDismiss() + } + val external = storage.external + GroupedRow( + title = stringResource(R.string.settings_store_own), + summary = stringResource(R.string.settings_store_own_hint), + position = Position.Top, + selected = storage.mode == StorageMode.OWN, + leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.OWN) { + { SelectedCheck() } + } else { + null + }, + onClick = { select(StorageMode.OWN) }, + ) + GroupedRow( + title = externalTitle(external, storage.externalLabel), + summary = stringResource( + if (external == null) { + R.string.settings_store_external_missing + } else { + R.string.settings_store_external_hint + }, + ), + position = Position.Bottom, + selected = storage.mode == StorageMode.EXTERNAL, + dimmed = external == null, + leading = { Icon(Icons.Rounded.Apps, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.EXTERNAL) { + { SelectedCheck() } + } else { + null + }, + onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null, + ) + Spacer(Modifier.height(24.dp)) + } +} + +/** The active store, named the way the picker names it. */ +@Composable +private fun storeLabel(storage: StorageUiState): String = when (storage.mode) { + StorageMode.OWN -> stringResource(R.string.settings_store_own) + StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel) +} + +/** The provider's own app name, its authority, or the generic wording. */ +@Composable +private fun externalTitle(provider: TaskProvider?, label: String?): String = + label ?: provider?.authority ?: stringResource(R.string.settings_store_external) + +private fun Context.openAppSettings() { + runCatching { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a6beca..dd0c5a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,37 @@ Bottom quick-add bar Add tasks from a bar pinned to the bottom of a list, instead of the floating button + Use this device\'s storage instead + + + Storage + Where tasks are kept, and export + Task store + Each store keeps its own tasks. Switching does not move them across — export first if you want a copy. + On this device + Agendula\'s own storage. Nothing else to install. + Another task app + Share tasks with the app that syncs them + No compatible task app is installed + Permission denied + The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + Export tasks + Save your lists as iCalendar files + One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip. + No lists to export + Save to a folder + Save as a zip file + Exporting… + + Exported %1$d list + Exported %1$d lists + + The export could not be written + The chosen folder could not be opened + The chosen folder is not writable + A file could not be created in the chosen folder + Access to the chosen location was lost + %1$d minute before diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index 9d7a988..c73eb3f 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -23,6 +23,7 @@ class ProviderResolverTest { ) : ProviderEnvironment { override fun packageDeclaring(authority: String): String? = installed[authority] override fun isGranted(permission: String): Boolean = permission in granted + override fun appLabel(packageName: String): String? = packageName } private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c216fb7..1d27981 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,7 +108,7 @@ postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root | `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | +| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. | --- @@ -447,10 +447,21 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, `RootScreen` is the entry composable: it gates on `ProviderStatus` (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is -only ever seen in External mode. Routes are the `Dest` table in -`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the -`material-3` skill for component choices (M3 `ListItem` rows, expressive -checkbox/FAB/swipe motion). +only ever seen in External mode — and it offers a way back to our own store, +because it is the only screen an External user can reach once their provider app +stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task +list → detail / edit, plus settings). Follow the `material-3` skill for component +choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion). + +Settings is a hub of sliding sub-screens rather than routes; **Storage** is the +one with teeth. It holds the §4.1 store picker — which asks for an external +provider's runtime permission *before* writing the mode, so a denial leaves the +readable store in place instead of stranding the user on the gate — and the +export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a +folder or a single zip). Because the mode is now switchable while the process +lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an +alarm is scheduled off whichever store was active at the time, so the whole set +has to be rebuilt against the new one. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a73b09f..945d92b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,10 +19,11 @@ iCalendar has landed, and the Material 3 Expressive UI is built through **M5**: lists → task list (swipe gestures, inline add, smart-list section headers) → detail / edit with full CRUD, date-time pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and subtask create + reparent — plus a -one-time reminder onboarding step and a Settings screen. Remaining work is -hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for -what has landed** (a storage-mode picker, an export screen), then M6 (Glance -widget, translations, F-Droid release) and the sync adapter. +one-time reminder onboarding step and a Settings screen. The frontend surfaces +for the new store have landed too: a Settings **Storage** section with the +store picker and an **export screen**. Remaining work is verifying all of it on +a device, then M6 (Glance widget, translations, F-Droid release) and the sync +adapter. --- @@ -147,8 +148,17 @@ what Posture B means (our own store, coexisting with everything — *not* squatt - ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives only in our app's private storage. One `.ics` per list, to a folder or a zip, via SAF. Backend only. -- ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and - an export screen. The backend is done and unused until these exist. +- ✅ **Frontend surfaces for the above** — Settings gained a **Storage** section + holding both: a full-screen store picker (Own / an installed external provider, + which is dimmed when none is present) that asks for the provider's runtime + permission *before* committing the switch, and an export screen with a per-list + tick and the two SAF destinations, a folder or a single zip. Two consequences + of the mode becoming switchable at runtime came with it: reminders are re-armed + against the new store on every switch (`AgendulaApp` listens on + `ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit + resynced them), and the permission gate offers a way back to our own store — + otherwise a user whose provider app went away is held on a gate with Settings + behind it. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. Note it now means "sync into an app that has no provider", so the ask has changed shape. @@ -264,9 +274,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through resolved: our own store expands a series at read time and writes an edit to one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in External mode the edit still goes through the instances URI. -6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default - today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it - assumes is not built yet. +6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the + default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage + → Task store is the override it always assumed. 7. ~~**Sync protocol coverage**, account model, conflict resolution — the next design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens live on that document's list. diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index 4cfb065..7224a45 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -42,7 +42,7 @@ |---|---|---|---| | 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done | | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | -| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | +| 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | | 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ | @@ -202,8 +202,9 @@ than "rank ours first whenever it's non-empty", and needs no database probe: That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — which is exactly what "existing Posture A -user" means. A fresh install holds nothing and gets local-first. ⬜ The Settings -override the rule assumes is not built yet. +user" means. A fresh install holds nothing and gets local-first. ✅ The Settings +override the rule assumes is built: Storage → Task store, which asks for the +external provider's permission before committing the switch rather than after. **Note on the mode vocabulary.** The code has two modes, not three: `StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two @@ -211,7 +212,7 @@ paragraphs up, Synced *is* Local with an account attached — so it is derived state, and giving it its own constant would imply switching sync on is a migration when the whole point is that it isn't. -**Export/backup is a v1 feature.** ✅ Backend built (no UI yet). Not, as +**Export/backup is a v1 feature.** ✅ Built, screen included. Not, as previously framed, a migration safety net for "uninstall OpenTasks" — that scenario no longer exists. It's data portability for Local-mode users, whose tasks otherwise exist in exactly one place with no second copy. On Play, where From fdbc236ab407f6710f8bac84dab37c90e8273168 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 15:53:01 +0200 Subject: [PATCH 21/21] docs(sync): bring SYNC.md in line with owning the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document was written against the vendored provider and still said the storage question was settled that way. Owning the store answered several of its open questions and deleted others outright, so the corrections are marked inline the way the rest of the file marks them, rather than quietly rewritten. Closed: the Local->Synced migration (account_id is a nullable FK, so attaching an account is an UPDATE), the recurring-completion model (the store writes model (a), RECURRENCE-ID overrides sharing the master's UID), the Auto Backup / cleanUpLists data-loss path, and the lib-recur version trap. Phase 0's UIDs-at-creation and backup safety are shipped. The provider-mechanism table is kept as External-mode history rather than deleted — that code still runs in OpenTasks and tasks.org. Effort restated: 11.5-15 weeks minus the 2.5-4 owning the store removes, so roughly 8-11. Also states plainly at the top that no sync code exists and that ICalendarWriter is the export half of the mapper only. --- docs/SYNC.md | 130 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 36 deletions(-) diff --git a/docs/SYNC.md b/docs/SYNC.md index 4938dd8..3a1c685 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -11,18 +11,39 @@ > That document decided **where task data lives**; this one decides **how it gets > to a server**. > -> Status: **draft / decision document.** Nothing here is built. Where a question -> is already answered by shipped code, it is marked ✅ and the code is named. +> Status: **draft / decision document.** No sync code is built — no `dav4jvm`, +> no `ical4j`, no `AccountManager`, no adapter, nothing in the manifest. Where a +> question is already answered by shipped code, it is marked ✅ and the code is +> named. > -> ⚠️ **The storage question this document declared settled was reopened, and the -> answer changed.** Agendula is building its own Room store and deleting the -> vendored provider — see [`STORAGE-DECISION.md`](STORAGE-DECISION.md) and +> ⚠️ **The storage question this document declared settled was reopened, the +> answer changed, and the change has since shipped.** Agendula owns a Room store +> and the vendored provider is deleted — see +> [`STORAGE-DECISION.md`](STORAGE-DECISION.md) and > [`OWN-STORE.md`](OWN-STORE.md). Roughly sixteen of the findings below are -> **provider-imposed** and disappear with it; `OWN-STORE.md` § *Effects on the -> sync plan* lists them item by item. Everything platform-level (the targetSdk 34 -> sync gate, the stub adapter, credential storage, Play compliance) and everything -> protocol-level (discovery, RFC 6578, conditional PUT, conflict policy, the -> server-reality table) is unaffected and remains the plan. +> **provider-imposed** and went with it; `OWN-STORE.md` § *Effects on the sync +> plan* lists them item by item. They are kept here, marked, because the External +> path still runs on that provider and because the next person would otherwise +> re-derive them. Everything platform-level (the targetSdk 34 sync gate, the stub +> adapter, credential storage, Play compliance) and everything protocol-level +> (discovery, RFC 6578, conditional PUT, conflict policy, the server-reality +> table) is unaffected and remains the plan. +> +> **What owning the store already settled, in shipped code** (2026-09): +> +> | Was a sync deliverable | Now | +> |---|---| +> | Phase 0 — UIDs at creation | ✅ `uid` is `NOT NULL`, minted on insert in both modes | +> | Phase 0 — backup / prune safety | ✅ backup rules cover the WAL, `ON_STOP` checkpoint, restore test | +> | Phase 1 — the recurrence representation | ✅ `RRULE`/`RDATE`/`EXDATE` stored raw, expanded at read (`RecurrenceExpander`), `RECURRENCE-ID` overrides sharing the master's UID | +> | Phase 3 — Local→Synced migration | ✅ **gone**: `task_lists.account_id` is a nullable FK, so attaching an account is one `UPDATE` | +> | Phase 3 — recurring-completion model | ✅ model **(a)** is what the store writes; the provider's model (d) left with it | +> | Sync bookkeeping columns | ✅ `href`, `etag`, `sync_token`, `is_dirty`, `is_deleted` exist in the v1 schema | +> +> Still nothing but design: phase 1's mapper (`ICalendarWriter` writes VTODO for +> **export only** — no parser, no unknown-property round-trip), phase 2 auth, +> phase 3's engine, phase 4 hardening. Phase 0's licence-attribution screen is +> also not built. Scope: self-hosted CalDAV first (Nextcloud), F-Droid and Play, MIT license. @@ -32,31 +53,34 @@ Scope: self-hosted CalDAV first (Nextcloud), F-Droid and Play, MIT license. | Question | Direction | |---|---| -| Where does task data live? | Our vendored `:provider` — **settled, shipped** | +| Where does task data live? | ⚠️ **Changed since this table was written.** Our own Room store — `:provider` is deleted (`OWN-STORE.md`) | | Who syncs it? | Agendula, via its own sync adapter | | Account model | `AccountManager` **+ a real (stub) sync adapter** — ⚠️ the hybrid without one does not work | | Scheduling | WorkManager, triggered *through* the sync framework | | Protocol library | `dav4jvm` (MPL-2.0) — ⚠️ costs more than the first draft assumed | | Self-signed certs | `cert4android` — ⚠️ **MPL-2.0, not GPLv3.** The first draft rejected it on a false premise | -| iCalendar | In-house mapper over `ical4j`; **no `synctools`** (GPLv3) | +| iCalendar | In-house mapper over `ical4j`; **no `synctools`** (GPLv3). ⚠️ Now VTODO ↔ **Room entities**, not `TaskContract` | | Recurrence | Read/write `RRULE`/`RDATE`/`EXDATE` directly — ⚠️ **not** the Instances table | | Primary read path | ⚠️ `REPORT calendar-query` (VTODO filter, no time-range); `sync-collection` is the optimisation | -| Recurring completion | ⚠️ Accept all four models; `:provider` has **already chosen model (d)** for us | +| Recurring completion | ⚠️ Accept all four models on read; **we write (a)** — the store forks a `RECURRENCE-ID` override sharing the master's UID. The provider's model (d) left with the provider | | Sign-in | Nextcloud Login Flow v2 + generic CalDAV discovery + Digest | | Conflict policy | `If-Match`; on 412 the server wins, local copy preserved | | Reference implementations | jtx Board and DAVx5 — read, never link against (GPLv3) | | # | Phase | Deliverable | Effort | |---|---|---|---| -| 0 | **Groundwork** | UIDs at creation, backup/prune safety, licence-attribution screen, Java-21 decision | 1 week | -| 1 | Mapper | VTODO ↔ `TaskContract`, unknown-property round-trip, fixture corpus | 2–3 weeks | +| 0 | **Groundwork** | ~~UIDs at creation~~ ✅, ~~backup/prune safety~~ ✅, licence-attribution screen ⬜, Java-21 decision ⬜ | ~1 week → days | +| 1 | Mapper | VTODO ↔ **Room entities**, unknown-property round-trip, fixture corpus. ⚠️ `ICalendarWriter` is the export half only — write-only, and it drops what it does not model | 2–3 weeks | | 2 | Auth | Discovery, Login Flow v2, Digest, credential storage, cert trust | 1.5–2 weeks | -| 3 | Engine | `calendar-query` baseline, `sync-collection` optimisation, full reconciliation, conflicts, scheduling, **Local→Synced migration** | 5–6 weeks | +| 3 | Engine | `calendar-query` baseline, `sync-collection` optimisation, full reconciliation, conflicts, scheduling, ~~**Local→Synced migration**~~ ✅ gone — `account_id` is a nullable FK | 4–5 weeks | | 4 | Hardening | Per-server trap matrix, error UX, re-auth, Play compliance | 2–3 weeks | -⚠️ **Revised upward from the first draft's 8–9 weeks to 11.5–15.** Phase 0 is new; -the migration in phase 3 was previously believed not to exist at all; and the -engine grew a second sync path plus a permanent reconciliation pass. +⚠️ **Revised upward from the first draft's 8–9 weeks to 11.5–15**, then back +down. Phase 0 is new; the migration in phase 3 was previously believed not to +exist at all; and the engine grew a second sync path plus a permanent +reconciliation pass. Owning the store then deleted 2.5–4 weeks of it +(`OWN-STORE.md` § *Effects on the sync plan*) — the migration, the recurrence +representation and phase 0's data work are done — leaving roughly **8–11 weeks**. **Calibration, for sanity:** Evolution shipped RFC 6578 in **June 2026** against a request open since 2019. vdirsyncer has declined to implement it for twelve @@ -98,8 +122,15 @@ machinery we already run), but "for free" was too generous. ## What the provider actually gives us -⚠️ **This section is almost entirely rewritten.** Every line is verified against -`provider/src/main/java/`. +⚠️ **This section is now history for the sync plan.** Every line was verified +against `provider/src/main/java/`, which no longer exists — the vendored provider +is deleted and our own store is what sync will run against. Nothing here +constrains the adapter any more. + +It is kept, not deleted, for two reasons: **External mode still talks to exactly +this code** in OpenTasks and tasks.org, so the app layer still lives with these +rules; and if External mode is ever retired (open question 3), this is the record +of what was being given up. | Mechanism | Reality | |---|---| @@ -148,11 +179,17 @@ machinery we already run), but "for free" was too generous. --- -## ⚠️ The migration that was believed not to exist +## ⚠️ The migration that was believed not to exist — and then stopped existing + +**Resolved: the original assertion is true again, because the constraint that +broke it was the provider's.** Our own `task_lists.account_id` is a nullable FK +from v1, so attaching an account to a list is one `UPDATE` and no task moves. +Phase 3 does not carry this deliverable. The rest of this section is the record +of why it was believed to, and still describes External mode exactly. The first draft, `STORAGE-AND-SYNC.md` and `ARCHITECTURE.md` all asserted: *"Synced is Local with an account attached, so switching on sync is not a -migration."* **That is false.** +migration."* **That was false against the dmfs provider.** `processors/lists/Validating.java:68-76` throws on any attempt to change a task list's `ACCOUNT_NAME` or `ACCOUNT_TYPE` — both are write-once, and the contract @@ -233,6 +270,12 @@ nothing registered to receive it. ### ⚠️ Auto Backup will arm `cleanUpLists` into a data-loss path +✅ **Closed.** Both rule sets are now explicit and name our own database with its +WAL sidecars, the app checkpoints on `ON_STOP`, and a restore test covers the WAL +case in both directions (`OWN-STORE.md` phase 6). `cleanUpLists` was the +provider's, and left with it. The original finding, which still describes what an +External-mode user's provider app does: + `backup_rules.xml` and `data_extraction_rules.xml` are both **empty rule sets**, and `allowBackup="true"`. An empty set means Auto Backup's default: databases included. So the provider's `tasks.db` is backed up and restored — while @@ -420,8 +463,12 @@ phase-4 detail. - Consider tasks.org's escape hatch: a per-account **"let the server schedule recurring tasks"** switch. -Open question 4 — but now with a default: **write (a) if we own the whole path; -accept that `:provider`'s `Detaching` pushes us toward (d) unless we bypass it.** +~~Open question 4~~ — **decided and shipped: we write (a).** We do own the whole +path now, so the `Detaching` caveat is moot. `RoomTasksDataSource`'s +`setCompletedInstance` and `updateInstance` both fork a `RECURRENCE-ID` override +sharing the master's UID, and the master stays open — jtx Board's and +Thunderbird's model, and the one that maps onto CalDAV without invention. The +adapter must still **read** all four models, which is unchanged. > **Process note.** During this research a summarising fetch **fabricated a > verbatim RFC 5545 sentence** ("A 'to-do' calendar component without the @@ -471,6 +518,12 @@ This roughly dissolves the self-signed-cert line item in phase 4. ### ⚠️ lib-recur is a version trap, not a free dependency +✅ **Resolved by deleting the other side of the trap.** `:app` declares lib-recur +0.12.2 directly and `RecurrenceExpander` uses it; the vendored provider whose +iterators would have stopped compiling no longer exists, so the version is ours +alone to move. The `RecurrenceSet` removal in 0.16.0 is now a plain upgrade +question, not a build-breaking one. The original finding: + The first draft said "already in the build at 0.12.2 — no new dependency". Both halves are wrong. `provider/build.gradle.kts:56` declares it `implementation`, not `api`, so it is **not** on `:app`'s compile classpath. And lib-recur **0.16.0 @@ -1143,20 +1196,20 @@ everything about storage modes. target and freezes the API churn — it looks better than it did. Before phase 2. 2. **Conflict policy** — preserve-local-on-412, or documented LWW? 3. **External mode** — survives, or becomes an importer? Before phase 1. -4. **Canonical recurring-completion behaviour** — and specifically, ⚠️ **do we - honour `:provider`'s `Detaching` processor (model d), or bypass it and write - `RECURRENCE-ID` overrides (model a)?** No longer an open-ended taste question: - our storage layer already answered it and we have to ratify or override that. - Moved up to **phase 1**. +4. ~~**Canonical recurring-completion behaviour**~~ **closed:** the store writes + model (a), `RECURRENCE-ID` overrides sharing the master's UID. Read all four. 5. **The DAVx5 enum ask** — worth filing, and what compatibility we owe if it - lands. -6. ⚠️ **New: does Local→Synced migrate, or do synced lists start empty?** See - [the migration section](#-the-migration-that-was-believed-not-to-exist). -7. ⚠️ **New: lib-recur — pin at 0.12.2, or rewrite the provider's iterators?** + lands. ⚠️ Reshaped: it now means "sync into an app that publishes no provider". +6. ~~**Does Local→Synced migrate, or do synced lists start empty?**~~ **closed:** + neither — attaching an account to a list is an `UPDATE`, so there is nothing + to migrate. +7. ~~**lib-recur — pin at 0.12.2, or rewrite the provider's iterators?**~~ + **closed** with the provider's deletion; `:app` owns the version. Answered elsewhere and **not** open: the account model (`AccountManager` **plus a stub sync adapter**), `ical4android` (superseded by `synctools`, GPLv3), and the -storage question. +storage question — which was reopened once, answered the other way, and is now +shipped. --- @@ -1165,7 +1218,12 @@ storage question. - **Depending on DAVx5 for sync.** Settled in `STORAGE-AND-SYNC.md`. - **`synctools` / `ical4android`.** GPLv3. The temptation recurs because it does exactly the right mapping against exactly our schema. -- **Rewriting storage to Room before sync exists.** [See above](#settled--the-storage-question-is-not-reopened-here). +- ~~**Rewriting storage to Room before sync exists.**~~ ⚠️ **This one was + revisited, and it was right to.** The phase-1 audit measured the provider's + sync bookkeeping — the reason it was kept — and found most of it broken, absent + or unusable (the table above). Reasoning in + [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Kept here as a reminder that a + dead end is only dead against the evidence that closed it. - ⚠️ **AccountManager + WorkManager with no registered sync adapter.** Not a design choice — a silent no-op at targetSdk ≥ 34. - ⚠️ **Writing through the `instances` URI as a sync adapter.** The flag is