Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d6038c8b | ||
|
|
c2826d3ab6 | ||
|
|
7c943f4831 | ||
|
|
5004f5213c | ||
|
|
9b7f7f60d2 | ||
|
|
cff6308018 | ||
|
|
e7bf23e276 |
+31
-2
@@ -7,19 +7,48 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- A reminder can count from the task's start as well as from its due date, and
|
||||
tasks with only a start date get reminders too. Default reminders can mix
|
||||
both, e.g. a day before the start and an hour before the due date; each
|
||||
applies to tasks that have its date (#72).
|
||||
- An all-day task's reminders can each have their own time, so one task can
|
||||
remind you at 09:00 and again at 17:00 (#78).
|
||||
- Other apps can read the tasks kept in Agendula's own database through an
|
||||
OpenTasks-compatible provider, once you grant them permission. Apps that
|
||||
already support OpenTasks or tasks.org need only a small change to support
|
||||
Agendula too (#43).
|
||||
- With a second permission, other apps can also add, edit, complete and delete
|
||||
those tasks, including their reminders and tags. They can tick off a single
|
||||
occurrence of a recurring task. Task lists stay read-only for them (#63).
|
||||
|
||||
### Changed
|
||||
- A new task no longer takes a default reminder whose time has already
|
||||
passed, so a start of "today" does not ring the moment the task is saved.
|
||||
- All-day default reminders are whole days; one set in hours rounds to the
|
||||
nearest day.
|
||||
- Switching a task between timed and all-day converts its reminders right
|
||||
away in the editor, so what you see is what gets saved.
|
||||
- The editor shows the default reminders a new task, or a newly added date,
|
||||
will get, before you save. Removing them leaves the task without a reminder
|
||||
instead of falling back to the default.
|
||||
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
|
||||
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
|
||||
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
|
||||
instead of the default reminder. Removing a task's alarm in another app
|
||||
turns it off here too, rather than falling back to the default (#52).
|
||||
- A new task with a due date, or one that gets its first due date, takes the
|
||||
default reminder as its own, so it travels with the task to the server.
|
||||
- A new task with a date, or one that gets a new date, takes the default
|
||||
reminder for that date as its own, so it travels with the task to the server.
|
||||
Changing the default later no longer changes those tasks; tasks that rely on
|
||||
the default today are left as they are.
|
||||
- On upgrade, reminders on open, synced tasks that so far stayed on this
|
||||
device are uploaded once.
|
||||
- Alarms imported from an `.ics` file become reminders on the task.
|
||||
- Screens now open and close the way they do in Calendula: they slide in, and
|
||||
a back swipe previews the screen behind before closing (#66).
|
||||
- Settings now use Calendula's names and layout. Reminders is now
|
||||
Notifications, Task form is now New task form, and export and import moved
|
||||
out of Storage into a new Backup & restore section (#66).
|
||||
|
||||
## [1.1.0] - 2026-10-10
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ Agendula's own store is an ordinary app database, so it **coexists with
|
||||
OpenTasks rather than replacing it**. If you already sync through a provider,
|
||||
Agendula can work on top of it exactly as before.
|
||||
|
||||
Other apps can read and write the tasks in Agendula's own store through an
|
||||
OpenTasks-compatible provider, once you grant them permission. See
|
||||
[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md).
|
||||
|
||||
Switching between the two moves nothing — each store keeps its own tasks — so
|
||||
**Settings → Storage** asks before it switches, and offers to **copy** a
|
||||
provider's tasks into Agendula's own store when you want to move over. The copy
|
||||
@@ -164,7 +168,7 @@ Agendula's own database go with it, so sync them to a CalDAV account first.
|
||||
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||
without CalDAV sync and **without the network permission**, so it cannot
|
||||
connect to anything. It installs alongside the regular app rather than replacing
|
||||
it; move tasks between the two with Settings → Storage → Export / Import. In
|
||||
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
|
||||
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||
`^agendula-offline_v`.
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1010099
|
||||
versionName = "1.1.0"
|
||||
versionCode = 1020001
|
||||
versionName = "1.2.0-beta.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PermissionInfo
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.net.toUri
|
||||
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 org.junit.Assert.assertThrows
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** The provider as another app meets it: manifest, permission, URIs. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AgendulaTaskProviderTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val authority = "${context.packageName}.tasks"
|
||||
private val readPermission = "${context.packageName}.permission.READ_TASKS"
|
||||
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
|
||||
private val resolver = context.contentResolver
|
||||
|
||||
private fun uri(path: String) = "content://$authority/$path".toUri()
|
||||
|
||||
@Test
|
||||
fun isExportedUnderItsPermissions() {
|
||||
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
|
||||
|
||||
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
|
||||
assertThat(info.exported).isTrue()
|
||||
assertThat(info.readPermission).isEqualTo(readPermission)
|
||||
assertThat(info.writePermission).isEqualTo(writePermission)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothPermissionsAreDangerous() {
|
||||
listOf(readPermission, writePermission).forEach { permission ->
|
||||
val info = context.packageManager.getPermissionInfo(permission, 0)
|
||||
|
||||
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
|
||||
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
|
||||
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAppWithoutThePermissionIsDenied() {
|
||||
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
// The shell is another uid that was never granted it.
|
||||
val output = shell("content query --uri content://$authority/tasks")
|
||||
|
||||
assertThat(output).contains("Permission Denial")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAppWithoutTheWritePermissionCannotWrite() {
|
||||
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
|
||||
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
|
||||
|
||||
assertThat(output).contains("Permission Denial")
|
||||
assertThat(output).contains(writePermission)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reportsOpenTasksMimeTypes() {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
|
||||
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTableAnswersWithItsColumns() {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
resolver.query(uri(table.path), null, null, null, null)!!.use {
|
||||
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCursorWatchesTheUriItWasQueriedFor() {
|
||||
val tasks = uri("tasks")
|
||||
|
||||
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRowUriReadsOnlyThatRow() {
|
||||
// Ids start at 1, so 0 names nothing whatever the store holds.
|
||||
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownUriIsRejected() {
|
||||
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
|
||||
// ContentResolver.getType swallows the provider's exception and answers null.
|
||||
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listsAreReadOnly() {
|
||||
val values = ContentValues().apply { put("list_name", "x") }
|
||||
|
||||
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
|
||||
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
|
||||
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRowUriTakesNoInsert() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
||||
// No list has this id, so nothing in the app's real store is touched.
|
||||
val values = ContentValues().apply {
|
||||
put("list_id", Long.MAX_VALUE)
|
||||
put("title", "x")
|
||||
}
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailingBatchWritesNothing() {
|
||||
val missingList = Long.MAX_VALUE
|
||||
val operations = arrayListOf(
|
||||
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
||||
)
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
|
||||
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
|
||||
.use { assertThat(it.count).isEqualTo(0) }
|
||||
}
|
||||
|
||||
/** stdout and stderr of [command]; the denial lands on stderr. */
|
||||
private fun shell(command: String): String {
|
||||
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
.executeShellCommandRwe(command)
|
||||
stdin.close()
|
||||
return listOf(stdout, stderr).joinToString("\n") { pfd ->
|
||||
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
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.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
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 org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProviderSnapshotSourceTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var resolver: ProviderResolver
|
||||
private lateinit var source: ProviderSnapshotSource
|
||||
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
|
||||
private val changes = LinkedBlockingQueue<Uri>()
|
||||
private val observer = object : ContentObserver(null) {
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
changes += uri ?: Uri.EMPTY
|
||||
}
|
||||
}
|
||||
private var listId = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||
resolver = ProviderResolver(NoProviderInstalled)
|
||||
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
|
||||
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
|
||||
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
context.contentResolver.unregisterContentObserver(observer)
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
|
||||
source.query(table, null, selection, args, null)
|
||||
|
||||
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
|
||||
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
|
||||
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
|
||||
}
|
||||
|
||||
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
|
||||
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
|
||||
|
||||
/** Waits for the coalesced notification, then drains any stragglers. */
|
||||
private fun awaitChange() {
|
||||
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||
Thread.sleep(700)
|
||||
changes.clear()
|
||||
}
|
||||
|
||||
private fun assertNoChange() {
|
||||
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun servesTheOwnStore() {
|
||||
val id = insert("Milk")
|
||||
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
|
||||
|
||||
assertThat(titles()).containsExactly("Milk")
|
||||
query(ProviderTable.LISTS).use {
|
||||
it.moveToFirst()
|
||||
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
|
||||
}
|
||||
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeletedTaskIsNotPublished() {
|
||||
val id = insert("Gone")
|
||||
insert("Kept")
|
||||
db.tasks().markDeleted(id, clock.now())
|
||||
|
||||
assertThat(titles()).containsExactly("Kept")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWriteNotifiesObserversAndIsServedNext() {
|
||||
source.start()
|
||||
assertThat(titles()).isEmpty()
|
||||
|
||||
insert("Later")
|
||||
|
||||
awaitChange()
|
||||
assertThat(titles()).containsExactly("Later")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBurstOfWritesNotifiesOnce() {
|
||||
source.start()
|
||||
titles()
|
||||
|
||||
repeat(5) { insert("t$it") }
|
||||
|
||||
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||
assertNoChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalModeServesNothing() {
|
||||
insert("Mine")
|
||||
resolver.storageMode = StorageMode.EXTERNAL
|
||||
|
||||
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingModeNotifiesAndRebuilds() {
|
||||
source.start()
|
||||
insert("Mine")
|
||||
assertThat(titles()).containsExactly("Mine")
|
||||
changes.clear()
|
||||
|
||||
resolver.storageMode = StorageMode.EXTERNAL
|
||||
awaitChange()
|
||||
assertThat(titles()).isEmpty()
|
||||
|
||||
resolver.storageMode = StorageMode.OWN
|
||||
awaitChange()
|
||||
assertThat(titles()).containsExactly("Mine")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writesInExternalModeDoNotNotify() {
|
||||
resolver.storageMode = StorageMode.EXTERNAL
|
||||
source.start()
|
||||
titles()
|
||||
|
||||
insert("Hidden")
|
||||
|
||||
assertNoChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aClientSelectionNarrowsTheRows() {
|
||||
insert("a")
|
||||
insert("b")
|
||||
|
||||
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
|
||||
source.start()
|
||||
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
|
||||
awaitChange()
|
||||
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
|
||||
|
||||
clock.current += 1.days
|
||||
|
||||
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
|
||||
awaitChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFreshSnapshotIsReused() {
|
||||
insert("a")
|
||||
titles()
|
||||
|
||||
clock.current += 30.minutes
|
||||
|
||||
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||
assertNoChange()
|
||||
}
|
||||
|
||||
private fun currentOriginalTime(): Long =
|
||||
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
|
||||
assertThat(it.moveToFirst()).isTrue()
|
||||
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
|
||||
}
|
||||
}
|
||||
|
||||
private class MutableClock(var current: Instant) : Clock {
|
||||
override fun now(): Instant = current
|
||||
}
|
||||
|
||||
private object NoProviderInstalled : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = null
|
||||
override fun isGranted(permission: String): Boolean = false
|
||||
override fun appLabel(packageName: String): String? = null
|
||||
}
|
||||
+811
@@ -0,0 +1,811 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
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.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
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.TaskStatus
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertThrows
|
||||
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.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Writes from another app, applied to the own store through the published tables. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProviderWriterTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var resolver: ProviderResolver
|
||||
private lateinit var source: ProviderSnapshotSource
|
||||
private lateinit var writer: ProviderWriter
|
||||
private var afterWrites = 0
|
||||
private var home = 0L
|
||||
|
||||
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
|
||||
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||
resolver = ProviderResolver(NoProvider)
|
||||
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
|
||||
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
|
||||
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun task(id: Long) = db.tasks().entity(id)
|
||||
|
||||
private fun insert(vararg values: Pair<String, Any?>): Long =
|
||||
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
|
||||
|
||||
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
|
||||
|
||||
private fun synced(readOnly: Boolean = false): Long {
|
||||
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
|
||||
return db.taskLists().insert(
|
||||
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
|
||||
)
|
||||
}
|
||||
|
||||
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
|
||||
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
|
||||
)
|
||||
|
||||
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
|
||||
private fun instances(): List<Triple<Long, Long, Int>> =
|
||||
source.rows(
|
||||
ProviderTable.INSTANCES,
|
||||
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
|
||||
null,
|
||||
null,
|
||||
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
|
||||
|
||||
private fun instanceId(original: Instant): Long =
|
||||
source.rows(
|
||||
ProviderTable.INSTANCES,
|
||||
listOf(Tasks.ID),
|
||||
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
|
||||
arrayOf(original.toEpochMilliseconds().toString()),
|
||||
).single().single() as Long
|
||||
|
||||
// --- inserts ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anInsertedTaskIsPublishedAndOwedASync() {
|
||||
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
|
||||
|
||||
val stored = task(id)!!
|
||||
assertThat(stored.title).isEqualTo("Milk")
|
||||
assertThat(stored.priority).isEqualTo(1)
|
||||
assertThat(stored.isDirty).isTrue()
|
||||
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
|
||||
.containsExactly(listOf("Milk"))
|
||||
assertThat(afterWrites).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInsertNeedsAListThatExistsAndIsWritable() {
|
||||
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
|
||||
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
|
||||
val readOnly = synced(readOnly = true)
|
||||
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
|
||||
assertThat(db.tasks().live()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceCannotBeInsertedAsATask() {
|
||||
val series = dailySeries()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDuplicateUidIsRejected() {
|
||||
insert(Tasks.UID to "same")
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listsAndInstancesTakeNoInserts() {
|
||||
assertThrows(UnsupportedOperationException::class.java) {
|
||||
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
|
||||
}
|
||||
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
|
||||
assertThrows(UnsupportedOperationException::class.java) {
|
||||
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
|
||||
}
|
||||
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownColumnIsRejected() {
|
||||
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nothingIsWrittenInExternalMode() {
|
||||
val id = insert(Tasks.TITLE to "Mine")
|
||||
resolver.storageMode = StorageMode.EXTERNAL
|
||||
|
||||
assertThrows(IllegalStateException::class.java) { insert() }
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
|
||||
}
|
||||
assertThat(task(id)!!.title).isEqualTo("Mine")
|
||||
}
|
||||
|
||||
// --- updates ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anUpdateChangesTheRowsItsSelectionMatches() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b")
|
||||
|
||||
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
|
||||
|
||||
assertThat(count).isEqualTo(1)
|
||||
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(task(b)!!.completedAt).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUpdateMatchingNothingChangesNothing() {
|
||||
insert(Tasks.TITLE to "a")
|
||||
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTaskInAReadOnlyListCannotChange() {
|
||||
val readOnly = synced(readOnly = true)
|
||||
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
|
||||
|
||||
assertThrows(UnsupportedOperationException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
|
||||
}
|
||||
assertThrows(UnsupportedOperationException::class.java) {
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||
}
|
||||
assertThat(task(id)!!.title).isEqualTo("theirs")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTaskMovesBetweenLists() {
|
||||
val work = synced()
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||
|
||||
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParentMustBeInTheSameListAndNotADescendant() {
|
||||
val work = synced()
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
|
||||
|
||||
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
|
||||
val series = dailySeries()
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
assertThat(db.tasks().overrides(series)).hasSize(1)
|
||||
|
||||
writer.update(
|
||||
ProviderTable.TASKS,
|
||||
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
|
||||
byId(ProviderTable.TASKS, series),
|
||||
null,
|
||||
)
|
||||
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aClientCannotTouchSyncState() {
|
||||
val work = synced()
|
||||
val id = db.tasks().insert(
|
||||
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
|
||||
)
|
||||
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
|
||||
|
||||
val stored = task(id)!!
|
||||
assertThat(stored.title).isEqualTo("new")
|
||||
assertThat(stored.href).isEqualTo("/s.ics")
|
||||
assertThat(stored.etag).isEqualTo("e")
|
||||
assertThat(stored.sequence).isEqualTo(2)
|
||||
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
|
||||
assertThat(stored.isDirty).isTrue()
|
||||
}
|
||||
|
||||
// --- instances --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun completingAnOccurrenceTicksOffOnlyThatOne() {
|
||||
val series = dailySeries()
|
||||
|
||||
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(count).isEqualTo(1)
|
||||
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
val override = db.tasks().overrides(series).single()
|
||||
assertThat(override.recurrenceId).isEqualTo(today)
|
||||
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
val statuses = instances().associate { it.first to it.third }
|
||||
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
|
||||
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceCanBeCancelledAndReopened() {
|
||||
val series = dailySeries()
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentWrittenToAnOccurrenceEditsTheSeries() {
|
||||
val series = dailySeries()
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceCannotBeMoved() {
|
||||
dailySeries()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceHasNoPartialProgress() {
|
||||
dailySeries()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlainTaskInstanceWritesTheTask() {
|
||||
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
|
||||
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
|
||||
|
||||
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAnOccurrenceExceptsIt() {
|
||||
val series = dailySeries()
|
||||
|
||||
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
|
||||
|
||||
assertThat(task(series)!!.exdate).isNotNull()
|
||||
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||
assertThat(instances()).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAModifiedOccurrenceExceptsItToo() {
|
||||
val series = dailySeries()
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||
}
|
||||
|
||||
// --- deletes ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aParentGoesWithItsSubtasks() {
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||
val other = insert(Tasks.TITLE to "other")
|
||||
|
||||
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
|
||||
|
||||
assertThat(task(parent)).isNull()
|
||||
assertThat(task(child)).isNull()
|
||||
assertThat(task(grandchild)).isNull()
|
||||
assertThat(task(other)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSyncedTaskLeavesATombstone() {
|
||||
val work = synced()
|
||||
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||
|
||||
assertThat(task(id)!!.isDeleted).isTrue()
|
||||
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
|
||||
val series = dailySeries()
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
val override = db.tasks().overrides(series).single().id
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
|
||||
|
||||
assertThat(task(series)).isNotNull()
|
||||
assertThat(task(series)!!.exdate).isNotNull()
|
||||
assertThat(instances()).hasSize(4)
|
||||
}
|
||||
|
||||
// --- properties -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anAlarmCanBeAddedChangedAndRemoved() {
|
||||
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
|
||||
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||
|
||||
val propertyId = writer.insert(
|
||||
ProviderTable.PROPERTIES,
|
||||
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
|
||||
)
|
||||
val alarm = db.alarms().forTask(id).single()
|
||||
assertThat(alarm.minutesBefore).isEqualTo(15)
|
||||
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
|
||||
assertThat(task(id)!!.isDirty).isTrue()
|
||||
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
|
||||
|
||||
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
|
||||
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
|
||||
|
||||
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
|
||||
assertThat(db.alarms().forTask(id)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tagsCanBeAddedRenamedAndRemoved() {
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
|
||||
val propertyId = writer.insert(
|
||||
ProviderTable.PROPERTIES,
|
||||
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
|
||||
)
|
||||
assertThat(task(id)!!.categories).containsExactly("errand")
|
||||
|
||||
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||
|
||||
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
|
||||
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||
|
||||
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
|
||||
assertThat(task(id)!!.categories).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPropertyCannotChangeItsKindOrTask() {
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
val other = insert(Tasks.TITLE to "o")
|
||||
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
|
||||
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAlarmsAndTagsCanBeWritten() {
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
|
||||
}
|
||||
}
|
||||
|
||||
// --- batches ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aBatchIsAllOrNothing() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.batch {
|
||||
insert(Tasks.TITLE to "first")
|
||||
insert(Tasks.TITLE to "second")
|
||||
insert(Tasks.LIST_ID to 999L)
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(db.tasks().live()).isEmpty()
|
||||
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
|
||||
writer.batch {
|
||||
insert(Tasks.TITLE to "first")
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
|
||||
}
|
||||
|
||||
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(afterWrites).isEqualTo(1)
|
||||
}
|
||||
|
||||
// --- review regressions -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b")
|
||||
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
|
||||
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
|
||||
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
|
||||
.map { it.single() as Long }
|
||||
|
||||
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
|
||||
|
||||
assertThat(db.alarms().forTask(a)).isEmpty()
|
||||
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
|
||||
dailySeries()
|
||||
val first = instanceId(today)
|
||||
val second = instanceId(today + 1.days)
|
||||
|
||||
writer.batch {
|
||||
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
|
||||
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
|
||||
}
|
||||
|
||||
assertThat(instances().map { it.first }).containsExactly(
|
||||
(today - 1.days).toEpochMilliseconds(),
|
||||
(today + 2.days).toEpochMilliseconds(),
|
||||
(today + 3.days).toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
|
||||
dailySeries()
|
||||
val later = instanceId(today + 2.days)
|
||||
|
||||
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(instanceId(today + 2.days)).isEqualTo(later)
|
||||
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
|
||||
.isEqualTo(1)
|
||||
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
|
||||
.isEqualTo((today + 2.days).toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
val longAgo = today - 1000.days
|
||||
val subtask = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
|
||||
timezone = "UTC", parentId = parent),
|
||||
)
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||
|
||||
assertThat(task(subtask)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
val subtask = dailySeries("sub")
|
||||
db.tasks().update(task(subtask)!!.copy(parentId = parent))
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||
|
||||
assertThat(task(subtask)).isNull()
|
||||
assertThat(db.tasks().live()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
|
||||
val series = dailySeries()
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
val override = db.tasks().overrides(series).single().id
|
||||
val row = readRow(ProviderTable.TASKS, override)
|
||||
|
||||
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
|
||||
|
||||
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
|
||||
val series = dailySeries()
|
||||
val instance = instanceId(today)
|
||||
val row = readRow(ProviderTable.INSTANCES, instance)
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingASubtreeMovesItsSubtasks() {
|
||||
val work = synced()
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
|
||||
|
||||
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
|
||||
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMoveOntoATakenUidIsRejected() {
|
||||
val work = synced()
|
||||
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
|
||||
val id = insert(Tasks.UID to "same")
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||
}
|
||||
assertThat(task(id)!!.listId).isEqualTo(home)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
|
||||
val series = dailySeries()
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
val override = db.tasks().overrides(series).single().id
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWriteThatChangesNothingOwesNoSync() {
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||
val modified = task(id)!!.lastModified
|
||||
|
||||
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
|
||||
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
|
||||
|
||||
assertThat(task(id)!!.isDirty).isFalse()
|
||||
assertThat(task(id)!!.lastModified).isEqualTo(modified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningAnOpenOccurrenceForksNothing() {
|
||||
val series = dailySeries()
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWriteBeforeStartupSettlesIsRefused() {
|
||||
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
|
||||
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
|
||||
|
||||
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
|
||||
assertThat(db.tasks().live()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleRowWriteNeedsNoSnapshot() {
|
||||
val id = insert(Tasks.TITLE to "t")
|
||||
val unknown = 987_654L
|
||||
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
|
||||
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
|
||||
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
|
||||
}
|
||||
|
||||
// --- second review regressions -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
|
||||
val series = dailySeries("Standup")
|
||||
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
|
||||
store().setCompletedInstance(series, today, false)
|
||||
val override = db.tasks().overrides(series).single()
|
||||
db.tasks().update(override.copy(title = "Standup (moved room)"))
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Standup")
|
||||
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
|
||||
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
|
||||
val series = dailySeries()
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBadZoneOnAnInstanceReportsTheZone() {
|
||||
dailySeries()
|
||||
val instance = instanceId(today)
|
||||
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
|
||||
}
|
||||
|
||||
assertThat(error).hasMessageThat().contains("Unknown time zone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInProcessSeriesCanBeWrittenBackAsRead() {
|
||||
val series = dailySeries()
|
||||
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
|
||||
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFloatingSeriesCanBeWrittenBackAsRead() {
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||
)
|
||||
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||
|
||||
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||
|
||||
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(task(series)!!.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aParentLoopDeletesInsteadOfRecursingForever() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
|
||||
db.tasks().update(task(a)!!.copy(parentId = b))
|
||||
|
||||
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
|
||||
|
||||
assertThat(task(a)).isNull()
|
||||
assertThat(task(b)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
|
||||
val work = synced()
|
||||
// The subtask gets the lower id, so it comes first unless the writer reorders.
|
||||
val child = insert(Tasks.TITLE to "child")
|
||||
val parent = insert(Tasks.TITLE to "parent")
|
||||
db.tasks().update(task(child)!!.copy(parentId = parent))
|
||||
|
||||
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
|
||||
|
||||
assertThat(count).isEqualTo(2)
|
||||
assertThat(task(child)!!.listId).isEqualTo(work)
|
||||
assertThat(task(parent)!!.listId).isEqualTo(work)
|
||||
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRowSelectedWithAnArgumentIsWritten() {
|
||||
val a = insert(Tasks.TITLE to "a")
|
||||
val b = insert(Tasks.TITLE to "b")
|
||||
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
|
||||
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
|
||||
|
||||
assertThat(task(a)!!.title).isEqualTo("a")
|
||||
assertThat(task(b)!!.title).isEqualTo("x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageExceptionDatesAreRejected() {
|
||||
val series = dailySeries()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
|
||||
}
|
||||
assertThat(task(series)!!.exdate).isNull()
|
||||
}
|
||||
|
||||
private fun store() = RoomTasksDataSource(db)
|
||||
|
||||
/** Every column of one published row, as a client that read it would write it back. */
|
||||
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||
val columns = table.columns.map { it.first }
|
||||
val row = source.rows(table, columns, byId(table, id), null).single()
|
||||
return columns.zip(row).toMap() - table.idColumn
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private object NoProvider : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = null
|
||||
override fun isGranted(permission: String): Boolean = false
|
||||
override fun appLabel(packageName: String): String? = null
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SnapshotDatabaseTest {
|
||||
|
||||
private val db = SnapshotDatabase()
|
||||
|
||||
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
|
||||
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
|
||||
|
||||
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
|
||||
ProviderSnapshot(
|
||||
ProviderTable.entries.associateWith {
|
||||
when (it) {
|
||||
ProviderTable.TASKS -> tasks.toList()
|
||||
ProviderTable.LISTS -> lists
|
||||
else -> emptyList()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
private fun Cursor.strings(column: String): List<String?> = use {
|
||||
val index = getColumnIndexOrThrow(column)
|
||||
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNullProjectionReturnsEveryColumnInTableOrder() {
|
||||
load(task(1, "a"))
|
||||
|
||||
db.query(ProviderTable.TASKS, null, null, null, null).use {
|
||||
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
|
||||
.inOrder()
|
||||
assertThat(it.count).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectionArgumentsAndSortOrderApply() {
|
||||
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
|
||||
|
||||
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
|
||||
.strings(Tasks.TITLE)
|
||||
|
||||
assertThat(titles).containsExactly("a", "c", "b").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typesSurviveTheCopy() {
|
||||
load(task(1, null, due = 1_700_000_000_000))
|
||||
|
||||
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
|
||||
it.moveToFirst()
|
||||
assertThat(it.isNull(0)).isTrue()
|
||||
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
|
||||
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
|
||||
assertThat(it.getInt(2)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnsupportedColumnReadsAsNull() {
|
||||
load(task(1, "a"))
|
||||
|
||||
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
|
||||
.containsExactly(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownProjectionColumnIsRejected() {
|
||||
load(task(1, "a"))
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownSelectionColumnIsRejected() {
|
||||
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
load(task(1, "a"))
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSelectionCannotBreakOutOfItsWhereClause() {
|
||||
load(task(1, "a"))
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachTableAnswersOnlyForItsOwnRows() {
|
||||
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
|
||||
|
||||
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
|
||||
.containsExactly("Home")
|
||||
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
|
||||
load(task(1, "old"))
|
||||
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
|
||||
|
||||
load(task(2, "new"))
|
||||
|
||||
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
|
||||
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
|
||||
.containsExactly("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptySnapshotEmptiesEveryTable() {
|
||||
load(task(1, "a"))
|
||||
|
||||
db.load(ProviderSnapshot.EMPTY)
|
||||
|
||||
ProviderTable.entries.forEach { table ->
|
||||
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-38
@@ -5,7 +5,7 @@ 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.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
@@ -366,41 +366,23 @@ class RoomTasksDataSourceTest {
|
||||
@Test
|
||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
||||
|
||||
// The whole reminder, not just the minute count: collapsing it to a bare
|
||||
// Int is what fired an imported START-referenced alarm off DUE, and an
|
||||
// alarm this seam sets from the UI is always due-referenced.
|
||||
source.setAlarm(id, 30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
|
||||
|
||||
source.setAlarm(id, 60)
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.alarms()).doesNotContainKey(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingTheEditableReminderLeavesTheOthersAlone() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
||||
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
|
||||
|
||||
source.setAlarm(id, 45)
|
||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
|
||||
|
||||
source.setAlarm(id, null)
|
||||
// Int is what fired an imported START-referenced alarm off DUE.
|
||||
source.setReminders(id, listOf(start, TaskReminder(30)))
|
||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
|
||||
|
||||
source.setReminders(id, listOf(TaskReminder(60)))
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, 30)
|
||||
source.setReminders(id, listOf(TaskReminder(30)))
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
@@ -417,7 +399,7 @@ class RoomTasksDataSourceTest {
|
||||
val original = db.alarms().forTask(id).single()
|
||||
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
|
||||
|
||||
source.updateTask(id, form(title = "renamed", due = now + 2.days), dueReminders = listOf(15, 60))
|
||||
source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60)))
|
||||
|
||||
val rows = db.alarms().forTask(id)
|
||||
val kept = rows.single { it.minutesBefore == 15 }
|
||||
@@ -429,13 +411,13 @@ class RoomTasksDataSourceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSaveKeepsABeforeStartReminderTheEditorNeverShows() {
|
||||
fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
|
||||
|
||||
source.updateTask(id, form(due = now + 1.days), dueReminders = listOf(45))
|
||||
source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
|
||||
|
||||
assertThat(source.reminders()[id]).containsExactly(TaskReminder(10, fromStart = true), TaskReminder(45))
|
||||
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -448,7 +430,7 @@ class RoomTasksDataSourceTest {
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(60))
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60)))
|
||||
|
||||
// ⚠️ The master is never written, not even briefly: a sync landing in
|
||||
// between would upload the occurrence's reminder for the whole series.
|
||||
@@ -467,7 +449,7 @@ class RoomTasksDataSourceTest {
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(30))
|
||||
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30)))
|
||||
|
||||
assertThat(created).isNotEqualTo(id)
|
||||
val copy = db.alarms().forTask(created).single()
|
||||
@@ -500,12 +482,12 @@ class RoomTasksDataSourceTest {
|
||||
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
|
||||
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
|
||||
source.setCompletedInstance(id, completed, true)
|
||||
source.updateInstance(id, own, form(title = "own"), listOf(5))
|
||||
source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5)))
|
||||
|
||||
// Same times and rule, so the series is not reshaped — that drops its overrides.
|
||||
val first = occurrences.first()
|
||||
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
|
||||
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(60))
|
||||
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60)))
|
||||
|
||||
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
|
||||
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
|
||||
@@ -529,12 +511,12 @@ class RoomTasksDataSourceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingTheEditableReminderOwesTheServerAPut() {
|
||||
fun settingRemindersOwesTheServerAPut() {
|
||||
val work = syncedList()
|
||||
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||
|
||||
source.setAlarm(id, 30)
|
||||
source.setReminders(id, listOf(TaskReminder(30)))
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
||||
}
|
||||
@@ -567,7 +549,7 @@ class RoomTasksDataSourceTest {
|
||||
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, minutesBeforeDue = 30)
|
||||
source.setReminders(id, listOf(TaskReminder(30)))
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
@@ -667,4 +649,47 @@ class RoomTasksDataSourceTest {
|
||||
|
||||
assertThat(thrown).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtaskIdsNameRowsNotOccurrences() {
|
||||
val parent = source.insertTask(form("parent"))
|
||||
val longAgo = now - 1000.days
|
||||
val outOfWindow = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
|
||||
)
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
||||
)
|
||||
source.setCompletedInstance(series, now, true)
|
||||
val plain = source.insertTask(form("plain").copy(parentId = parent))
|
||||
|
||||
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASubtreeEndsOnAParentLoop() {
|
||||
val a = source.insertTask(form("a"))
|
||||
val b = source.insertTask(form("b").copy(parentId = a))
|
||||
val c = source.insertTask(form("c").copy(parentId = b))
|
||||
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
|
||||
|
||||
source.deleteWithSubtasks(a)
|
||||
|
||||
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
|
||||
val start = now
|
||||
val series = db.tasks().insert(
|
||||
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
|
||||
)
|
||||
val child = source.insertTask(form("child").copy(parentId = series))
|
||||
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
|
||||
|
||||
source.deleteFollowing(series, start)
|
||||
|
||||
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-7
@@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
@@ -294,25 +294,24 @@ private class FakeExternalStore : TasksDataSource {
|
||||
return tasks[listId].orEmpty()
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
||||
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||
override fun task(taskId: Long): Task? = unused()
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
|
||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = unused()
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||
unused()
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||
override fun deleteTask(taskId: Long) = unused()
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||
unused()
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||
unused()
|
||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||
|
||||
@@ -874,11 +874,11 @@ class CollectionSyncer(
|
||||
* [incoming] with each alarm we uploaded ourselves put back to the lead
|
||||
* we hold for it.
|
||||
*
|
||||
* ⚠️ The wire is coarser than the row: an all-day lead goes out in whole
|
||||
* days, and a before-start reminder on a task without a start goes out
|
||||
* before-due. Taking the round trip at its word would quietly turn a
|
||||
* 15-minute all-day reminder into "on the day", or a before-start one
|
||||
* into a before-due one, the first time the task is downloaded again.
|
||||
* ⚠️ The wire is coarser than the row: a lead past the end of its
|
||||
* anchor is clamped, and a before-start reminder on a task without a
|
||||
* start goes out before-due. Taking the round trip at its word would
|
||||
* quietly turn a before-start reminder into a before-due one the first
|
||||
* time the task is downloaded again.
|
||||
* An alarm that comes back as exactly what we would have sent has not
|
||||
* been changed, so the row's own lead stands.
|
||||
*/
|
||||
|
||||
@@ -70,6 +70,7 @@ internal fun AccountDetailScreen(
|
||||
onSignInAgain: (AccountEntity) -> Unit,
|
||||
onOpenStorage: () -> Unit,
|
||||
viewModel: AccountsViewModel,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||
@@ -90,7 +91,7 @@ internal fun AccountDetailScreen(
|
||||
// shown on the accounts list rather than here.
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = identity.title, onBack = onBack) {
|
||||
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
|
||||
AccountHero(identity)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
|
||||
onOpenStorage: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: AccountsViewModel,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_accounts),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
val loaded = accounts ?: return@CollapsingScaffold
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ internal fun AccountSections(
|
||||
// Which account the detail screen is showing; the section alone cannot say.
|
||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||
|
||||
// Accounts stays composed under Add account, for the same reason Storage
|
||||
// stays composed under Export: the deeper screen slides over it.
|
||||
// Accounts stays composed under Add account, for the same reason Backup
|
||||
// stays composed under export and import: the deeper screen slides over it.
|
||||
val accountsOpen = section == SettingsSection.Accounts ||
|
||||
section?.parent == SettingsSection.Accounts
|
||||
SlideInSection(visible = accountsOpen) {
|
||||
@@ -50,9 +50,11 @@ internal fun AccountSections(
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
onBack = { onSection(null) },
|
||||
viewModel = accountsViewModel,
|
||||
visible = accountsOpen,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Account) {
|
||||
val accountOpen = section == SettingsSection.Account
|
||||
SlideInSection(visible = accountOpen) {
|
||||
openAccount?.let { id ->
|
||||
AccountDetailScreen(
|
||||
accountId = id,
|
||||
@@ -61,6 +63,7 @@ internal fun AccountSections(
|
||||
onSignInAgain = signInAgain,
|
||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||
viewModel = accountsViewModel,
|
||||
visible = accountOpen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,26 @@
|
||||
selected External mode. Both are dangerous-level.
|
||||
|
||||
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. -->
|
||||
directory. -->
|
||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||
|
||||
<!-- What another app requests to read or write the own store through
|
||||
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
|
||||
them per app. Suffixed per build, so debug and release can coexist. -->
|
||||
<permission
|
||||
android:name="${applicationId}.permission.READ_TASKS"
|
||||
android:description="@string/permission_read_tasks_description"
|
||||
android:label="@string/permission_read_tasks_label"
|
||||
android:protectionLevel="dangerous" />
|
||||
<permission
|
||||
android:name="${applicationId}.permission.WRITE_TASKS"
|
||||
android:description="@string/permission_write_tasks_description"
|
||||
android:label="@string/permission_write_tasks_label"
|
||||
android:protectionLevel="dangerous" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||
@@ -37,6 +50,7 @@
|
||||
<application
|
||||
android:name=".AgendulaApp"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -233,6 +247,16 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The own store under the OpenTasks contract. Empty, and refusing
|
||||
writes, while External mode is active. -->
|
||||
<provider
|
||||
android:name=".data.tasks.provider.AgendulaTaskProvider"
|
||||
android:authorities="${applicationId}.tasks"
|
||||
android:exported="true"
|
||||
android:readPermission="${applicationId}.permission.READ_TASKS"
|
||||
android:writePermission="${applicationId}.permission.WRITE_TASKS"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
|
||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||
@HiltWorker workers can take injected dependencies. -->
|
||||
|
||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
scheduler.sync()
|
||||
}
|
||||
runCatching { entryPoint.taskWidgetUpdater().start() }
|
||||
runCatching { entryPoint.providerSnapshotSource().start() }
|
||||
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
||||
}
|
||||
}
|
||||
@@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider {
|
||||
fun taskNotifier(): TaskNotifier
|
||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||
fun providerSnapshotSource(): ProviderSnapshotSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
@@ -57,10 +61,10 @@ data class Settings(
|
||||
val dynamicColor: Boolean = true,
|
||||
/** The list a new task defaults to; `null` = first available. */
|
||||
val defaultListId: Long? = null,
|
||||
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
|
||||
val defaultReminderMinutes: List<Int> = listOf(0),
|
||||
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
|
||||
/** Default reminders (0 before due = at due time); empty = none. */
|
||||
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
|
||||
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
|
||||
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||
/** Master switch for due reminders; off clears every scheduled alarm. */
|
||||
@@ -74,13 +78,13 @@ data class Settings(
|
||||
*/
|
||||
val bottomAddBar: Boolean = false,
|
||||
/**
|
||||
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
|
||||
* Per-list overrides of [defaultReminders]: a list present in the map
|
||||
* overrides the global default (an empty list = no reminder); absent =
|
||||
* inherit.
|
||||
*/
|
||||
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
|
||||
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
|
||||
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
|
||||
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
|
||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||
@@ -109,16 +113,16 @@ data class Settings(
|
||||
}
|
||||
|
||||
/** The lead times for a task in [listId]: its override if set, else the global default. */
|
||||
fun reminderLeadsFor(listId: Long): List<Int> =
|
||||
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
|
||||
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
|
||||
|
||||
/** The lead times for an all-day task in [listId]. */
|
||||
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
||||
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
||||
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
|
||||
|
||||
/** The default leads a task in [listId] takes — the one rule for which default applies. */
|
||||
fun defaultLeadsFor(listId: Long, allDay: Boolean): List<Int> =
|
||||
if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)
|
||||
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
|
||||
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
|
||||
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
|
||||
}
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@@ -134,16 +138,18 @@ class SettingsPrefs @Inject constructor(
|
||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||
// The single lead of earlier versions carries over until a list is saved.
|
||||
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
|
||||
?: listOf(p[REMINDER_LEAD] ?: 0),
|
||||
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
|
||||
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
|
||||
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
|
||||
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
|
||||
?: listOf(ReminderLead(0)),
|
||||
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
|
||||
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
||||
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
|
||||
perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]),
|
||||
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
|
||||
.mapValues { it.value.inWholeDays() },
|
||||
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||
.toSet(),
|
||||
@@ -189,12 +195,12 @@ class SettingsPrefs @Inject constructor(
|
||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
||||
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
||||
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
||||
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
||||
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||
}
|
||||
|
||||
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
|
||||
@@ -247,16 +253,16 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||
current.applyReminderOverride(listId, override)
|
||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||
}
|
||||
|
||||
/** Set (or clear) a list's all-day reminder override. */
|
||||
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
||||
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
||||
current.applyReminderOverride(listId, override)
|
||||
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||
}
|
||||
|
||||
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
|
||||
@@ -334,6 +340,13 @@ class SettingsPrefs @Inject constructor(
|
||||
*/
|
||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||
|
||||
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
|
||||
private fun parseMinutes(stored: String): List<Int> =
|
||||
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
|
||||
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
|
||||
private fun parseLeads(stored: String): List<ReminderLead> =
|
||||
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
|
||||
|
||||
/**
|
||||
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
|
||||
* before the all-day time; a lead that is not whole days now names a time of day.
|
||||
*/
|
||||
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
|
||||
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.AllDayReminder
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
@@ -36,23 +37,24 @@ internal object ReminderPlanner {
|
||||
// One reminder per *occurrence* and lead: a recurring series yields a row
|
||||
// per occurrence, all sharing a taskId, so this is a Set rather than a
|
||||
// taskId-keyed Map.
|
||||
.filter { !it.isClosed && it.due != null }
|
||||
.filter { !it.isClosed && (it.due != null || it.start != null) }
|
||||
.flatMap { task ->
|
||||
// Reminders set on the task itself win — every one of them, not just
|
||||
// the one the editor shows; otherwise the task's list may override the
|
||||
// global lead, or opt out entirely (override = null). All-day tasks
|
||||
// have their own day-scale defaults.
|
||||
// Reminders set on the task itself win — every one of them;
|
||||
// otherwise the task's list may override the global lead, or opt
|
||||
// out entirely (override = null). All-day tasks have their own
|
||||
// day-scale defaults.
|
||||
//
|
||||
// ⚠️ Present-but-empty is "none", not "the default": the store's
|
||||
// answer for a task whose alarms another client turned off. A
|
||||
// default here would keep ringing for what the user switched off.
|
||||
val reminders = perTask[task.taskId]
|
||||
?: settings.defaultLeadsFor(task.listId, task.isAllDay).map { TaskReminder(it) }
|
||||
// A default counts only from a date the task has; its own reminders
|
||||
// stand in each date for the other when the task lacks it.
|
||||
val own = perTask[task.taskId]
|
||||
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
|
||||
.filter { (if (it.fromStart) task.start else task.due) != null }
|
||||
val created = task.created?.toEpochMilliseconds()
|
||||
reminders.mapNotNull { reminder ->
|
||||
// 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*.
|
||||
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
|
||||
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
|
||||
val trigger = triggerAt(
|
||||
anchor,
|
||||
task.isAllDay,
|
||||
@@ -60,14 +62,19 @@ internal object ReminderPlanner {
|
||||
settings.allDayReminderMinuteOfDay,
|
||||
zone,
|
||||
)
|
||||
// A default never rings for a moment before the task existed:
|
||||
// that is a task saved after its default had passed, not a
|
||||
// reminder missed while the device was off.
|
||||
if (own == null && created != null && trigger < created) return@mapNotNull null
|
||||
// Dismissed on another client already: firing it here within
|
||||
// the missed-reminder grace would ring for something done with.
|
||||
//
|
||||
// ⚠️ An all-day reminder is compared from the start of its day,
|
||||
// not from this device's time of day: other clients ring it at
|
||||
// midnight, or at their own hour, so a dismissal at 07:00 must
|
||||
// still silence the 09:00 this device would ring it at.
|
||||
val earliest = if (task.isAllDay) {
|
||||
// ⚠️ An all-day reminder without a time of its own is compared
|
||||
// from the start of its day, not from this device's time of
|
||||
// day: other clients ring it at midnight, or at their own hour,
|
||||
// so a dismissal at 07:00 must still silence the 09:00 this
|
||||
// device would ring it at.
|
||||
val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) {
|
||||
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
|
||||
} else {
|
||||
trigger
|
||||
@@ -96,8 +103,9 @@ internal object ReminderPlanner {
|
||||
*
|
||||
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
|
||||
* storage convention and not a moment — firing off it rang at 02:00 in
|
||||
* Berlin and the evening before in New York. It is read as its calendar date
|
||||
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
|
||||
* Berlin and the evening before in New York. It is read as its calendar
|
||||
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
|
||||
* [allDayMinuteOfDay] local time, anything else at the time it names.
|
||||
*/
|
||||
fun triggerAt(
|
||||
anchor: Instant,
|
||||
@@ -106,12 +114,11 @@ internal object ReminderPlanner {
|
||||
allDayMinuteOfDay: Int,
|
||||
zone: ZoneId,
|
||||
): Long {
|
||||
val base = if (allDay) {
|
||||
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
|
||||
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
|
||||
} else {
|
||||
anchor.toEpochMilliseconds()
|
||||
}
|
||||
return base - leadMinutes.coerceAtLeast(0) * 60_000L
|
||||
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
|
||||
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
|
||||
val minute = minuteOfDay ?: allDayMinuteOfDay
|
||||
val time = LocalTime.of(minute / 60 % 24, minute % 60)
|
||||
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
|
||||
.atTime(time).atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
@@ -85,6 +86,27 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
||||
|
||||
/**
|
||||
* Read from the `tasks` table, so a recurring subtask with no occurrence in
|
||||
* the window still counts. All columns and a lenient `_deleted`, as in
|
||||
* [exportTasks], since the table's shape varies across providers.
|
||||
*/
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
|
||||
TasksContract.tasksUri(authority()),
|
||||
null,
|
||||
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
arrayOf(parentTaskId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
|
||||
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||
val uri = TasksContract.instancesUri(authority())
|
||||
// projection = null (all columns): the instances view differs across
|
||||
@@ -158,22 +180,20 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: throw TaskWriteFailedException("insert task: no id")
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) {
|
||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||
setCategories(taskId, form.tags)
|
||||
writeDueReminders(taskId, dueReminders)
|
||||
writeReminders(taskId, reminders)
|
||||
}
|
||||
|
||||
/** [leads] as the task's before-due reminders; a start-relative one from elsewhere is kept. */
|
||||
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
|
||||
if (leads == null) return
|
||||
val kept = alarmRows(taskId).map { it.second }.filter { it.fromStart }
|
||||
setReminders(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
|
||||
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||
if (reminders == null) return
|
||||
setReminders(taskId, reminders)
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
val occurrence = task(seriesId, occurrenceStart)
|
||||
val anchors = resolver.query(
|
||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||
@@ -193,7 +213,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||
setCategories(seriesId, form.tags)
|
||||
writeDueReminders(seriesId, dueReminders)
|
||||
writeReminders(seriesId, reminders)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
* task. Per-occurrence edits past the split point are dropped rather than
|
||||
* carried over — the provider offers no way to re-parent an exception.
|
||||
*/
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
||||
updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
||||
updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||
return seriesId
|
||||
}
|
||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
||||
@@ -215,13 +235,12 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
).size
|
||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
||||
val reminders = dueReminders?.distinct()?.sorted()?.map { TaskReminder(it) }
|
||||
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
|
||||
}
|
||||
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||
val spec = seriesSpec(seriesId) ?: return
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
|
||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
|
||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||
}
|
||||
|
||||
@@ -282,15 +301,15 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||
}
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
|
||||
if (dueReminders == null) return writeInstance(taskId, occurrenceStart, form)
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
|
||||
// ⚠️ Written to the series, and then put back. Forking an occurrence
|
||||
// copies the task's *current* properties onto the new override row, so
|
||||
// setting the alarm beforehand is the only way to carry it across — and
|
||||
// leaving it there would silently give every other occurrence in the
|
||||
// series the reminder the user set on one of them.
|
||||
val seriesReminders = alarmRows(taskId).map { it.second }
|
||||
writeDueReminders(taskId, dueReminders)
|
||||
writeReminders(taskId, reminders)
|
||||
try {
|
||||
writeInstance(taskId, occurrenceStart, form)
|
||||
} finally {
|
||||
@@ -351,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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. Only the editable
|
||||
// (last before-due) row goes — a start-relative or additional alarm
|
||||
// another client wrote is not ours to drop.
|
||||
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
|
||||
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
|
||||
}
|
||||
if (minutesBeforeDue != null) {
|
||||
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
||||
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||
val uri = TasksContract.propertiesUri(authority())
|
||||
resolver.delete(
|
||||
@@ -382,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
override fun reminders(): Map<Long, List<TaskReminder>> =
|
||||
readAlarms(null).groupBy({ it.first }) { it.third }
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> =
|
||||
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
||||
|
||||
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
|
||||
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
|
||||
readAlarms(taskId).map { it.second to it.third }
|
||||
|
||||
+11
-10
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
@@ -34,24 +35,24 @@ class ModeRoutingTasksDataSource(
|
||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
||||
active().updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
|
||||
active().splitSeries(seriesId, occurrenceStart, form, dueReminders)
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||
active().updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||
active().splitSeries(seriesId, occurrenceStart, form, reminders)
|
||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
||||
active().deleteOccurrence(seriesId, occurrenceStart)
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||
active().deleteFollowing(seriesId, occurrenceStart)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
|
||||
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
|
||||
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
|
||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) =
|
||||
active().updateTask(taskId, form, dueReminders)
|
||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||
active().updateTask(taskId, form, reminders)
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
|
||||
active().updateInstance(taskId, occurrenceStart, form, dueReminders)
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||
active().updateInstance(taskId, occurrenceStart, form, reminders)
|
||||
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
||||
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
|
||||
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
||||
active().setReminders(taskId, reminders)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
internal object StoredReminders {
|
||||
|
||||
/**
|
||||
* The reminders to store for [form], given the task as it was.
|
||||
*
|
||||
* A date the task gets in this write brings the list's or the app's
|
||||
* default for it *as the task's own reminders*. The default is this
|
||||
* device's setting, which nothing else can see; stored on the task, it
|
||||
* travels with it to the server and fires on every client. The cost is
|
||||
* tasks.org's: changing the default later does not reach the tasks that
|
||||
* already have it.
|
||||
*
|
||||
* A task getting its first date takes the defaults only when the form
|
||||
* names no reminder; one that already had a date takes those of the new
|
||||
* date that no reminder of the form counts from. Otherwise the form is
|
||||
* kept, so an edit that clears every reminder means "use the default
|
||||
* here" as before.
|
||||
*
|
||||
* The form's reminders are already of its kind, timed or all-day — see
|
||||
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
|
||||
* kept as they are: it showed these defaults already.
|
||||
*/
|
||||
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
||||
val wanted = form.reminders.map { it.lead }.distinct()
|
||||
if (form.remindersChosen) return wanted
|
||||
return withDefaults(form, current?.start, current?.due, settings, now, zone)
|
||||
}
|
||||
|
||||
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
||||
fun withDefaults(
|
||||
form: TaskForm,
|
||||
start: Instant?,
|
||||
due: Instant?,
|
||||
settings: Settings,
|
||||
now: Long,
|
||||
zone: ZoneId,
|
||||
): List<TaskReminder> {
|
||||
val wanted = form.reminders.map { it.lead }.distinct()
|
||||
val had = anchorsOf(start, due)
|
||||
val gained = anchorsOf(form.start, form.due) - had
|
||||
if (gained.isEmpty()) return wanted
|
||||
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||
val named = wanted.map { it.fromStart }.toSet()
|
||||
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
|
||||
}
|
||||
|
||||
/**
|
||||
* [reminders] for [form] once it has switched between timed and all-day.
|
||||
* Reminders that are exactly the old kind's default become the new kind's;
|
||||
* the rest keep what still means something: whole days, rounded to them on
|
||||
* an all-day task.
|
||||
*/
|
||||
fun switched(
|
||||
reminders: List<TaskReminder>,
|
||||
form: TaskForm,
|
||||
settings: Settings,
|
||||
now: Long,
|
||||
zone: ZoneId,
|
||||
): List<TaskReminder> {
|
||||
if (reminders.isEmpty()) return reminders
|
||||
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
|
||||
return defaultsFor(form, settings, now, zone)
|
||||
}
|
||||
val kept = if (form.isAllDay) {
|
||||
reminders.map { it.inWholeDays() }
|
||||
} else {
|
||||
reminders.filter { it.allDay.minuteOfDay == null }
|
||||
}
|
||||
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||
}
|
||||
|
||||
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
||||
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
||||
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
||||
|
||||
/**
|
||||
* The defaults [form] takes: those counting from a date it has. A one-off
|
||||
* task leaves out what is already past — a start of "now" would ring at once.
|
||||
*/
|
||||
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
|
||||
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
|
||||
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
|
||||
form.rrule != null || ReminderPlanner.triggerAt(
|
||||
anchor,
|
||||
form.isAllDay,
|
||||
reminder.minutesBefore,
|
||||
settings.allDayReminderMinuteOfDay,
|
||||
zone,
|
||||
) >= now
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,12 @@ object TasksContract {
|
||||
|
||||
const val UID = "_uid"
|
||||
const val DELETED = "_deleted"
|
||||
|
||||
const val HAS_ALARMS = "has_alarms"
|
||||
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
|
||||
const val LIST_OWNER = "list_owner"
|
||||
const val LIST_ACCESS_LEVEL = "list_access_level"
|
||||
const val VISIBLE = "visible"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +109,7 @@ object TasksContract {
|
||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||
const val IS_RECURRING = "is_recurring"
|
||||
const val INSTANCE_DURATION = "instance_duration"
|
||||
|
||||
/**
|
||||
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
||||
@@ -118,6 +125,10 @@ object TasksContract {
|
||||
const val PROPERTY_ID = "property_id"
|
||||
const val TASK_ID = "task_id"
|
||||
const val MIMETYPE = "mimetype"
|
||||
const val DATA0 = "data0"
|
||||
const val DATA1 = "data1"
|
||||
const val DATA2 = "data2"
|
||||
const val DATA3 = "data3"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +175,9 @@ object TasksContract {
|
||||
|
||||
/** `data1` — the tag as spelled. */
|
||||
const val NAME = "data1"
|
||||
|
||||
/** `data2` — the tag's colour, if any. */
|
||||
const val COLOR = "data2"
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||
|
||||
@@ -3,38 +3,9 @@ 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 de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
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,
|
||||
/**
|
||||
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
|
||||
* a trigger at or before it has been dealt with and is not fired here.
|
||||
* Never part of what a reminder *is* — see [lead].
|
||||
*/
|
||||
val acknowledgedAt: Instant? = null,
|
||||
) {
|
||||
/** The reminder without its bookkeeping, for comparing two of them. */
|
||||
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one reminder Agendula's single-reminder editor shows and writes: the last
|
||||
* before-due one, which is where a write lands, so it stays the same one across
|
||||
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
|
||||
* another client or the dmfs import brought — is left to ride along.
|
||||
*/
|
||||
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
|
||||
|
||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||
data class TaskQuery(
|
||||
val listId: Long? = null,
|
||||
@@ -58,17 +29,38 @@ interface TasksDataSource {
|
||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
||||
fun subtasks(parentTaskId: Long): List<Task>
|
||||
|
||||
/**
|
||||
* The task rows directly under [parentTaskId]: one id per subtask, its series
|
||||
* master when it recurs, whether or not any occurrence falls in the window
|
||||
* [subtasks] expands.
|
||||
*/
|
||||
fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
|
||||
|
||||
/**
|
||||
* Delete [taskId] and its subtasks, all the way down; left behind they would
|
||||
* hang off a tombstone. Each task once, so a parent loop an import brought in
|
||||
* ends instead of recursing forever.
|
||||
*/
|
||||
fun deleteWithSubtasks(taskId: Long) {
|
||||
val seen = HashSet<Long>()
|
||||
fun delete(id: Long) {
|
||||
if (!seen.add(id)) return
|
||||
subtaskIds(id).forEach(::delete)
|
||||
deleteTask(id)
|
||||
}
|
||||
delete(taskId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The write paths take the task's reminders in the same write as the task
|
||||
* itself, so a sync can never upload one without the other.
|
||||
*
|
||||
* [insertTask] takes the new task's [reminders] whole. The updates take the
|
||||
* before-due leads as `dueReminders`, and `null` leaves the reminders
|
||||
* alone; a start-relative reminder the task already carries is kept — the
|
||||
* editor never shows one, so it never sends one.
|
||||
* Each takes the task's whole set of [reminders], before-start and
|
||||
* before-due alike, and `null` leaves the stored ones alone.
|
||||
*/
|
||||
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||
fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>? = null)
|
||||
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||
|
||||
/**
|
||||
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||
@@ -81,27 +73,19 @@ interface TasksDataSource {
|
||||
* 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(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
|
||||
/**
|
||||
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
|
||||
* Alarm property row. Every other reminder on the task is left untouched. 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?)
|
||||
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||
|
||||
/**
|
||||
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
||||
* content from [form], and the series moved by however far that occurrence moved.
|
||||
*/
|
||||
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
|
||||
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||
|
||||
/**
|
||||
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
||||
* from there as a new series built from [form]. Returns the new series' id.
|
||||
*/
|
||||
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null): Long
|
||||
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||
|
||||
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
||||
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
||||
@@ -118,10 +102,7 @@ interface TasksDataSource {
|
||||
* scheduler. A task missing from the map takes the default; one present
|
||||
* with an empty list has none (see `TaskEntity.remindersOff`).
|
||||
*/
|
||||
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
|
||||
|
||||
/** Every task's [editable] reminder, by task id. */
|
||||
fun alarms(): Map<Long, TaskReminder>
|
||||
fun reminders(): Map<Long, List<TaskReminder>>
|
||||
|
||||
/**
|
||||
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
@@ -82,13 +83,6 @@ interface TasksRepository {
|
||||
scope: RecurringScope = RecurringScope.AllOccurrences,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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?
|
||||
|
||||
/** Every reminder stored on the task, in order — including start-relative ones. */
|
||||
suspend fun remindersFor(taskId: Long): List<TaskReminder>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
@@ -110,40 +111,17 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun createTask(form: TaskForm): Long =
|
||||
withContext(io) {
|
||||
dataSource.insertTask(form, dueRemindersFor(form, current = null).distinct().sorted().map { TaskReminder(it) })
|
||||
dataSource.insertTask(form, storedRemindersFor(form, current = null))
|
||||
}
|
||||
|
||||
/**
|
||||
* The before-due leads to store for [form], given the task as it was.
|
||||
*
|
||||
* A task that gets its due date in this write and names no reminder of its
|
||||
* own takes the list's or the app's default *as its own reminders*. The
|
||||
* default is this device's setting, which nothing else can see; stored on
|
||||
* the task, it travels with it to the server and fires on every client.
|
||||
* The cost is tasks.org's: changing the default later does not reach the
|
||||
* tasks that already have it.
|
||||
*
|
||||
* A task that already had a due date keeps what the form says, so an
|
||||
* edit that clears every reminder means "use the default here" as before —
|
||||
* with one exception. Switching between timed and all-day swaps the
|
||||
* default it was given for the other kind's, as long as the reminders are
|
||||
* still exactly that default: a timed 15 minutes is no lead for an all-day
|
||||
* task, which the planner used to sort out by itself while the default
|
||||
* stayed unstored.
|
||||
*/
|
||||
private suspend fun dueRemindersFor(form: TaskForm, current: Task?): List<Int> {
|
||||
if (form.due == null) return form.reminders
|
||||
val hadDue = current?.due != null
|
||||
if (!hadDue) {
|
||||
if (form.reminders.isNotEmpty()) return form.reminders
|
||||
return settingsPrefs.settings.first().defaultLeadsFor(form.listId, form.isAllDay)
|
||||
}
|
||||
if (current.isAllDay == form.isAllDay || form.reminders.isEmpty()) return form.reminders
|
||||
val settings = settingsPrefs.settings.first()
|
||||
val previous = settings.defaultLeadsFor(current.listId, current.isAllDay)
|
||||
return if (form.reminders.sorted() == previous.sorted()) settings.defaultLeadsFor(form.listId, form.isAllDay)
|
||||
else form.reminders
|
||||
}
|
||||
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
|
||||
StoredReminders.of(
|
||||
form,
|
||||
current,
|
||||
settingsPrefs.settings.first(),
|
||||
Clock.System.now().toEpochMilliseconds(),
|
||||
ZoneId.systemDefault(),
|
||||
)
|
||||
|
||||
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
||||
withContext(io) {
|
||||
@@ -179,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun reminderFor(taskId: Long): Int? =
|
||||
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
|
||||
|
||||
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
|
||||
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
||||
|
||||
@@ -205,7 +180,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
val seen = current?.lastModified
|
||||
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||
}
|
||||
val reminders = dueRemindersFor(form, current)
|
||||
val reminders = storedRemindersFor(form, current)
|
||||
val seriesId = current?.seriesId
|
||||
val occurrence = current?.occurrenceStart
|
||||
if (seriesId != null && occurrence != null) {
|
||||
@@ -260,7 +235,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
val current = dataSource.task(taskId, occurrenceStart)
|
||||
val seriesId = current?.seriesId
|
||||
val occurrence = current?.occurrenceStart
|
||||
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
|
||||
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
||||
when (scope) {
|
||||
RecurringScope.ThisOccurrence ->
|
||||
// An override row deletes itself into an EXDATE; a generated
|
||||
@@ -268,18 +243,10 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
|
||||
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
|
||||
}
|
||||
}
|
||||
|
||||
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
|
||||
private fun deleteWithSubtasks(taskId: Long) {
|
||||
dataSource.subtasks(taskId).map { it.taskId }.distinct()
|
||||
.filter { it != taskId }
|
||||
.forEach { dataSource.deleteTask(it) }
|
||||
dataSource.deleteTask(taskId)
|
||||
}
|
||||
|
||||
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||
withContext(io) { dataSource.createLocalList(name, color) }
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
|
||||
import kotlin.math.roundToLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -31,8 +33,9 @@ import kotlin.time.Instant
|
||||
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
|
||||
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
|
||||
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
|
||||
* - a trigger *after* its anchor. The planner coerces a negative lead to 0, so
|
||||
* it would fire at the wrong time;
|
||||
* - a trigger *after* its anchor — on an all-day task, after the end of its
|
||||
* day. The planner coerces a negative lead to 0, so it would fire at the
|
||||
* wrong time;
|
||||
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
|
||||
* back from.
|
||||
*
|
||||
@@ -50,19 +53,18 @@ import kotlin.time.Instant
|
||||
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
|
||||
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
|
||||
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
|
||||
* which wants `DTSTART` present), and reading it as a before-due reminder is
|
||||
* what makes it editable here: the editor only shows before-due rows.
|
||||
* which wants `DTSTART` present).
|
||||
*
|
||||
* ## All-day tasks
|
||||
*
|
||||
* The local fire time of day is a device preference this mapper cannot see, so
|
||||
* an all-day lead is whole days, written `-P<n>D` against `DUE`'s midnight. Read
|
||||
* back, any trigger on an all-day task rounds to the nearest whole day.
|
||||
* A relative trigger counts from the date's midnight, so the lead is the
|
||||
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
|
||||
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
|
||||
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
|
||||
* a time of day in, so it rounds to the nearest whole day.
|
||||
*/
|
||||
object AlarmCodec {
|
||||
|
||||
private const val DAY_MINUTES = 24 * 60
|
||||
|
||||
/** Apple's placeholder trigger for location alarms. Never a real time. */
|
||||
private const val APPLE_SENTINEL = "19760401T005545Z"
|
||||
|
||||
@@ -114,16 +116,11 @@ object AlarmCodec {
|
||||
else -> return null
|
||||
}
|
||||
val before = -seconds
|
||||
val minutes = if (task.isAllDay) {
|
||||
// Rounded before the sign check: `PT9H` on a date-only DUE is
|
||||
// "09:00 on the day", which is this device's "on the day".
|
||||
roundToDays(before)
|
||||
} else {
|
||||
// A lead in seconds is not one the planner can reproduce.
|
||||
if (before % 60 != 0L) return null
|
||||
before / 60
|
||||
}
|
||||
return minutes.toIntOrNull()?.let { Lead(it, reference) }
|
||||
// A lead in seconds is not one the planner can reproduce.
|
||||
if (before % 60 != 0L) return null
|
||||
val minutes = before / 60
|
||||
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
|
||||
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) }
|
||||
}
|
||||
|
||||
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
||||
@@ -143,6 +140,17 @@ object AlarmCodec {
|
||||
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
|
||||
* days. Such a row keeps the other client's time of day instead of overwriting it.
|
||||
*/
|
||||
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
|
||||
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
|
||||
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/** Nearest whole day, in minutes. */
|
||||
private fun roundToDays(seconds: Long): Long =
|
||||
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
||||
@@ -156,10 +164,10 @@ object AlarmCodec {
|
||||
* too — because `RELATED=END` without `DUE` is a resource the server
|
||||
* refuses ([VTodoMapper.validate]).
|
||||
*
|
||||
* ⚠️ A before-start row on a task that has lost its start is written
|
||||
* against `DUE`, which is what the planner fires it from. Leaving it out
|
||||
* would drop it from every other client, and the next download would then
|
||||
* take it off this device too.
|
||||
* ⚠️ A row whose anchor the task has lost is written against the other
|
||||
* date, which is what the planner fires it from. Leaving it out would drop
|
||||
* it from every other client, and the next download would then take it off
|
||||
* this device too.
|
||||
*
|
||||
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
|
||||
* planner's missed-reminder grace, so a client that honours RFC 9074 does
|
||||
@@ -167,11 +175,15 @@ object AlarmCodec {
|
||||
* next download and would silence the late reminder this device still owes.
|
||||
*/
|
||||
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
|
||||
val reference = if (row.reference == AlarmReference.START && task.dtstart == null) AlarmReference.DUE
|
||||
else row.reference
|
||||
val reference = when {
|
||||
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
|
||||
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
|
||||
else -> row.reference
|
||||
}
|
||||
val lead = Lead(row.minutesBefore, reference)
|
||||
row.raw?.let(::parse)?.let { original ->
|
||||
if (resolve(original, task) == lead) return original
|
||||
val read = resolve(original, task)
|
||||
if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original
|
||||
}
|
||||
|
||||
val anchor = when (reference) {
|
||||
@@ -179,12 +191,8 @@ object AlarmCodec {
|
||||
AlarmReference.START -> task.dtstart
|
||||
} ?: return null
|
||||
val related = if (reference == AlarmReference.DUE) "END" else "START"
|
||||
val minutes = row.minutesBefore.coerceAtLeast(0).toLong()
|
||||
val seconds = if (task.isAllDay) {
|
||||
-((minutes + DAY_MINUTES / 2) / DAY_MINUTES) * 86_400
|
||||
} else {
|
||||
-minutes * 60
|
||||
}
|
||||
val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
|
||||
val seconds = -minutes * 60
|
||||
|
||||
val properties = mutableListOf(
|
||||
ICalProperty("UID", value = row.uid),
|
||||
@@ -202,13 +210,18 @@ object AlarmCodec {
|
||||
|
||||
/**
|
||||
* What [row] reads back as once it has been to the server and back — not
|
||||
* always the row itself, because the wire is coarser: an all-day lead is
|
||||
* whole days, and a before-start row on a task without a start goes out
|
||||
* before-due. A download that brings back exactly this has not changed the
|
||||
* always the row itself, because the wire is coarser: a lead past the end
|
||||
* of its anchor is clamped, and a before-start row on a task without a
|
||||
* start goes out before-due. A download that brings back exactly this has not changed the
|
||||
* reminder, and the row's own lead must be kept rather than taken from it.
|
||||
*
|
||||
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
|
||||
* a row rounded to whole days before 1.2 re-emits a time of day it does
|
||||
* not hold, and would keep its rounded lead against the very alarm that
|
||||
* carries it.
|
||||
*/
|
||||
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
|
||||
toVAlarm(row, task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
|
||||
toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
|
||||
|
||||
private fun parse(raw: String): ICalComponent? =
|
||||
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentProviderResult
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.UriMatcher
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
|
||||
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
|
||||
* already works with OpenTasks or tasks.org works with Agendula by swapping the
|
||||
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
|
||||
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
|
||||
* `docs/CONTENT_PROVIDER.md`.
|
||||
*/
|
||||
class AgendulaTaskProvider : ContentProvider() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ProviderEntryPoint {
|
||||
fun snapshotSource(): ProviderSnapshotSource
|
||||
fun writer(): ProviderWriter
|
||||
}
|
||||
|
||||
private val entryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
|
||||
}
|
||||
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
|
||||
private val writer: ProviderWriter by lazy { entryPoint.writer() }
|
||||
|
||||
private val matcher by lazy {
|
||||
UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
addURI(source.authority, table.path, table.ordinal * 2)
|
||||
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val (table, id) = match(uri)
|
||||
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
|
||||
setNotificationUri(requireNotNull(context).contentResolver, uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String {
|
||||
val (table, id) = match(uri)
|
||||
return table.mimeType(single = id != null)
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||
val (table, id) = match(uri)
|
||||
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
|
||||
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
|
||||
}
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val (table, id) = match(uri)
|
||||
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val (table, id) = match(uri)
|
||||
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
|
||||
}
|
||||
|
||||
/** All or nothing, as against OpenTasks, and observers hear of it once. */
|
||||
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
|
||||
writer.batch { super.applyBatch(operations) }
|
||||
|
||||
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
|
||||
val code = matcher.match(uri)
|
||||
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
|
||||
val table = ProviderTable.entries[code / 2]
|
||||
if (code % 2 == 0) return table to null
|
||||
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
|
||||
return table to id
|
||||
}
|
||||
|
||||
private fun ContentValues?.toMap(): ProviderValues =
|
||||
this?.keySet()?.associateWith { get(it) }.orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
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
|
||||
|
||||
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
|
||||
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
|
||||
LISTS(
|
||||
Lists.PATH,
|
||||
listOf(
|
||||
Lists.ID to "INTEGER PRIMARY KEY",
|
||||
Lists.NAME to "TEXT",
|
||||
Lists.COLOR to "INTEGER",
|
||||
Lists.ACCESS_LEVEL to "INTEGER",
|
||||
Lists.VISIBLE to "INTEGER",
|
||||
Lists.SYNC_ENABLED to "INTEGER",
|
||||
Lists.OWNER to "TEXT",
|
||||
Lists.ACCOUNT_NAME to "TEXT",
|
||||
Lists.ACCOUNT_TYPE to "TEXT",
|
||||
),
|
||||
),
|
||||
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
|
||||
INSTANCES(
|
||||
Instances.PATH,
|
||||
listOf(
|
||||
Tasks.ID to "INTEGER PRIMARY KEY",
|
||||
Instances.TASK_ID to "INTEGER",
|
||||
Instances.INSTANCE_START to "INTEGER",
|
||||
Instances.INSTANCE_START_SORTING to "INTEGER",
|
||||
Instances.INSTANCE_DUE to "INTEGER",
|
||||
Instances.INSTANCE_DUE_SORTING to "INTEGER",
|
||||
Instances.INSTANCE_DURATION to "INTEGER",
|
||||
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
|
||||
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
|
||||
Instances.IS_RECURRING to "INTEGER",
|
||||
) + TASK_COLUMNS,
|
||||
),
|
||||
PROPERTIES(
|
||||
Properties.PATH,
|
||||
listOf(
|
||||
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
|
||||
Properties.TASK_ID to "INTEGER",
|
||||
Properties.MIMETYPE to "TEXT",
|
||||
Properties.DATA0 to "TEXT",
|
||||
Properties.DATA1 to "TEXT",
|
||||
Properties.DATA2 to "TEXT",
|
||||
Properties.DATA3 to "TEXT",
|
||||
),
|
||||
),
|
||||
;
|
||||
|
||||
val idColumn: String get() = columns.first().first
|
||||
|
||||
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
|
||||
fun selectionFor(id: Long?, selection: String?): String? =
|
||||
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
|
||||
|
||||
/** OpenTasks' MIME type for a whole table, or for one row of it. */
|
||||
fun mimeType(single: Boolean): String =
|
||||
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
|
||||
}
|
||||
|
||||
/** Every `tasks` column but `_id`; the instances view carries them too. */
|
||||
private val TASK_COLUMNS: List<Pair<String, String>>
|
||||
get() = listOf(
|
||||
Tasks.LIST_ID to "INTEGER",
|
||||
Tasks.LIST_NAME to "TEXT",
|
||||
Tasks.LIST_COLOR to "INTEGER",
|
||||
Tasks.LIST_OWNER to "TEXT",
|
||||
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
|
||||
Tasks.VISIBLE to "INTEGER",
|
||||
Tasks.ACCOUNT_NAME to "TEXT",
|
||||
Tasks.ACCOUNT_TYPE to "TEXT",
|
||||
Tasks.UID to "TEXT",
|
||||
Tasks.DELETED to "INTEGER",
|
||||
Tasks.TITLE to "TEXT",
|
||||
Tasks.DESCRIPTION to "TEXT",
|
||||
Tasks.LOCATION to "TEXT",
|
||||
Tasks.URL to "TEXT",
|
||||
Tasks.PRIORITY to "INTEGER",
|
||||
Tasks.CLASSIFICATION to "INTEGER",
|
||||
Tasks.STATUS to "INTEGER",
|
||||
Tasks.PERCENT_COMPLETE to "INTEGER",
|
||||
Tasks.COMPLETED to "INTEGER",
|
||||
Tasks.IS_CLOSED to "INTEGER",
|
||||
Tasks.IS_NEW to "INTEGER",
|
||||
Tasks.TASK_COLOR to "INTEGER",
|
||||
Tasks.DTSTART to "INTEGER",
|
||||
Tasks.DUE to "INTEGER",
|
||||
Tasks.DURATION to "TEXT",
|
||||
Tasks.IS_ALLDAY to "INTEGER",
|
||||
Tasks.TZ to "TEXT",
|
||||
Tasks.RRULE to "TEXT",
|
||||
Tasks.RDATE to "TEXT",
|
||||
Tasks.EXDATE to "TEXT",
|
||||
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
|
||||
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
|
||||
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
|
||||
Tasks.PARENT_ID to "INTEGER",
|
||||
Tasks.SORTING to "INTEGER",
|
||||
Tasks.HAS_ALARMS to "INTEGER",
|
||||
Tasks.CREATED to "INTEGER",
|
||||
Tasks.LAST_MODIFIED to "INTEGER",
|
||||
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
|
||||
|
||||
/**
|
||||
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
|
||||
* that projects them reads nothing rather than failing the query.
|
||||
*/
|
||||
private val UNSUPPORTED_TASK_COLUMNS = listOf(
|
||||
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
|
||||
"version", "_dirty", "_sync_id", "sync_version",
|
||||
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||
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.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.TaskListRow
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
typealias ProviderRow = Map<String, Any?>
|
||||
|
||||
/**
|
||||
* Ids that stay put for as long as what they name exists, so a client can read
|
||||
* an id and write to it later, or several in one batch. The one exception is
|
||||
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
|
||||
* the one that sorts first moves the other onto its id.
|
||||
*
|
||||
* An instance is its series and its original time; a plain task's one
|
||||
* instance has none. An alarm property is the alarm row; a tag property is its
|
||||
* task and its spelling. Alarms take even ids and tags odd ones, so the two
|
||||
* never meet.
|
||||
*/
|
||||
object ProviderIds {
|
||||
|
||||
fun instance(seriesId: Long, originalTime: Instant?): Long =
|
||||
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
|
||||
|
||||
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
|
||||
|
||||
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
|
||||
|
||||
fun alarm(alarmId: Long): Long = alarmId shl 1
|
||||
|
||||
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
|
||||
|
||||
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
|
||||
|
||||
/** The property id of each of [taskId]'s [categories], by spelling. */
|
||||
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
|
||||
categories(taskId, categories, ::tagHash)
|
||||
|
||||
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
|
||||
val used = HashSet<Long>()
|
||||
// Two spellings on one task with one hash: the one that sorts later takes
|
||||
// the next free slot, wrapping inside the tag bits so it never reaches the
|
||||
// task's. Sorting keeps that independent of the tags' order.
|
||||
val ids = categories.sorted().associateWith { name ->
|
||||
var bits = hash(name) and TAG_BITS
|
||||
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
|
||||
(((taskId shl 31) or bits) shl 1) or 1L
|
||||
}
|
||||
return categories.associateWith(ids::getValue)
|
||||
}
|
||||
|
||||
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
|
||||
|
||||
private const val TAG_BITS = 0x7FFFFFFFL
|
||||
|
||||
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
|
||||
private fun tagHash(name: String): Long {
|
||||
var hash = -0x340d631b7bdddcdbL
|
||||
name.encodeToByteArray().forEach { byte ->
|
||||
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
|
||||
}
|
||||
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
|
||||
}
|
||||
}
|
||||
|
||||
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
|
||||
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||
|
||||
companion object {
|
||||
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
|
||||
|
||||
/**
|
||||
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
|
||||
* what a list attached to an account reports; a device-only list reports
|
||||
* OpenTasks' local account so clients treat it the way they already do.
|
||||
*/
|
||||
fun build(
|
||||
lists: List<TaskListRow>,
|
||||
tasks: List<TaskEntity>,
|
||||
alarms: List<TaskAlarmEntity>,
|
||||
accountType: String,
|
||||
now: Instant,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): ProviderSnapshot {
|
||||
val listsById = lists.associateBy { it.list.id }
|
||||
val alarmsByTask = alarms.groupBy { it.taskId }
|
||||
val taskRows = tasks.mapNotNull { task ->
|
||||
val list = listsById[task.listId] ?: return@mapNotNull null
|
||||
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
|
||||
}.toMap()
|
||||
|
||||
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
|
||||
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
|
||||
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
|
||||
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
|
||||
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
|
||||
}
|
||||
}
|
||||
|
||||
return ProviderSnapshot(
|
||||
mapOf(
|
||||
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
|
||||
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
|
||||
ProviderTable.INSTANCES to instances,
|
||||
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
|
||||
Lists.ID to row.list.id,
|
||||
Lists.NAME to row.list.name,
|
||||
Lists.COLOR to row.list.color,
|
||||
Lists.ACCESS_LEVEL to 0,
|
||||
Lists.VISIBLE to row.list.isVisible.bit,
|
||||
Lists.SYNC_ENABLED to row.list.isSynced.bit,
|
||||
Lists.OWNER to row.list.owner,
|
||||
Lists.ACCOUNT_NAME to accountName(row),
|
||||
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
|
||||
)
|
||||
|
||||
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
|
||||
mapOf(
|
||||
Tasks.LIST_ID to task.listId,
|
||||
Tasks.LIST_NAME to list.list.name,
|
||||
Tasks.LIST_COLOR to list.list.color,
|
||||
Tasks.LIST_OWNER to list.list.owner,
|
||||
Tasks.LIST_ACCESS_LEVEL to 0,
|
||||
Tasks.VISIBLE to list.list.isVisible.bit,
|
||||
Tasks.ACCOUNT_NAME to accountName(list),
|
||||
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
|
||||
Tasks.UID to task.uid,
|
||||
Tasks.DELETED to 0,
|
||||
Tasks.TITLE to task.title,
|
||||
Tasks.DESCRIPTION to task.description,
|
||||
Tasks.LOCATION to task.location,
|
||||
Tasks.URL to task.url,
|
||||
Tasks.PRIORITY to task.priority,
|
||||
Tasks.CLASSIFICATION to task.classification,
|
||||
Tasks.STATUS to task.status.toInt(),
|
||||
Tasks.PERCENT_COMPLETE to task.percentComplete,
|
||||
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
|
||||
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
|
||||
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
|
||||
Tasks.TASK_COLOR to task.color,
|
||||
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
|
||||
Tasks.DUE to task.due?.toEpochMilliseconds(),
|
||||
Tasks.DURATION to task.duration,
|
||||
Tasks.IS_ALLDAY to task.isAllDay.bit,
|
||||
Tasks.TZ to task.timezone,
|
||||
Tasks.RRULE to task.rrule,
|
||||
Tasks.RDATE to task.rdate,
|
||||
Tasks.EXDATE to task.exdate,
|
||||
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
|
||||
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
|
||||
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
|
||||
Tasks.PARENT_ID to task.parentId,
|
||||
Tasks.SORTING to task.sortOrder,
|
||||
Tasks.HAS_ALARMS to hasAlarms.bit,
|
||||
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
|
||||
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
|
||||
)
|
||||
|
||||
/** One instance: the row it reads from, its times, and where it sits in its series. */
|
||||
private class Occurrence(
|
||||
val row: TaskEntity,
|
||||
val start: Instant?,
|
||||
val due: Instant?,
|
||||
val originalTime: Instant?,
|
||||
val distance: Int,
|
||||
val recurring: Boolean,
|
||||
)
|
||||
|
||||
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
|
||||
val series = seriesOccurrences(master, overrides, now)
|
||||
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
|
||||
return series.mapIndexed { index, occurrence ->
|
||||
Occurrence(
|
||||
row = occurrence.override ?: master,
|
||||
start = occurrence.start,
|
||||
due = occurrence.due,
|
||||
originalTime = occurrence.anchor,
|
||||
distance = occurrence.distance ?: index,
|
||||
recurring = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
|
||||
val allDay = occurrence.row.isAllDay
|
||||
val start = occurrence.start?.toEpochMilliseconds()
|
||||
val due = occurrence.due?.toEpochMilliseconds()
|
||||
return mapOf(
|
||||
Tasks.ID to id,
|
||||
Instances.TASK_ID to occurrence.row.id,
|
||||
Instances.INSTANCE_START to start,
|
||||
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
|
||||
Instances.INSTANCE_DUE to due,
|
||||
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
|
||||
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
|
||||
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
|
||||
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
|
||||
Instances.IS_RECURRING to occurrence.recurring.bit,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
|
||||
* move it to local midnight so it sorts among the timed tasks of that day.
|
||||
*/
|
||||
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
|
||||
if (!allDay) return millis
|
||||
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
|
||||
return millis - offset
|
||||
}
|
||||
|
||||
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
|
||||
val live = tasks.mapTo(HashSet()) { it.id }
|
||||
val alarmRows = alarms.filter { it.taskId in live }.map {
|
||||
mapOf(
|
||||
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
|
||||
Properties.TASK_ID to it.taskId,
|
||||
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
|
||||
Alarm.REFERENCE to when (it.reference) {
|
||||
AlarmReference.DUE -> Alarm.REFERENCE_DUE
|
||||
AlarmReference.START -> Alarm.REFERENCE_START
|
||||
}.toString(),
|
||||
Alarm.MESSAGE to it.message,
|
||||
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
|
||||
)
|
||||
}
|
||||
val categoryRows = tasks.flatMap { task ->
|
||||
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
|
||||
mapOf(
|
||||
Properties.PROPERTY_ID to id,
|
||||
Properties.TASK_ID to task.id,
|
||||
Properties.MIMETYPE to Category.MIMETYPE,
|
||||
Category.NAME to name,
|
||||
)
|
||||
}
|
||||
}
|
||||
return alarmRows + categoryRows
|
||||
}
|
||||
|
||||
private fun accountName(row: TaskListRow): String =
|
||||
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
|
||||
|
||||
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
|
||||
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
|
||||
|
||||
private val Boolean.bit: Int get() = if (this) 1 else 0
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */
|
||||
private val MAX_AGE = 1.hours
|
||||
|
||||
private val STARTUP_WAIT = 10.seconds
|
||||
|
||||
private val NOTIFY_DELAY = 500.milliseconds
|
||||
|
||||
/**
|
||||
* Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store,
|
||||
* rebuilt lazily after a write or a storage-mode switch, and tells observers of
|
||||
* the provider's authority when either happens.
|
||||
*
|
||||
* Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then,
|
||||
* and clients should read OpenTasks or tasks.org directly.
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderSnapshotSource internal constructor(
|
||||
private val context: Context,
|
||||
private val database: TasksDatabase,
|
||||
private val resolver: ProviderResolver,
|
||||
private val awaitStartup: suspend () -> Unit,
|
||||
private val clock: Clock = Clock.System,
|
||||
private val startupWait: Duration = STARTUP_WAIT,
|
||||
) {
|
||||
|
||||
@Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
database: TasksDatabase,
|
||||
resolver: ProviderResolver,
|
||||
startupGate: StartupGate,
|
||||
) : this(context, database, resolver, startupGate::awaitReady)
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
private val snapshot by lazy { SnapshotDatabase() }
|
||||
private val generation = AtomicLong(1)
|
||||
private var builtGeneration = 0L
|
||||
private var builtAt: Instant? = null
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val notify = Runnable {
|
||||
context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null)
|
||||
}
|
||||
|
||||
val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX"
|
||||
|
||||
/** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
database.invalidationTracker.addObserver(
|
||||
object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
if (resolver.mode() == StorageMode.OWN) changed()
|
||||
}
|
||||
},
|
||||
)
|
||||
resolver.onModeChanged(::changed)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun query(
|
||||
table: ProviderTable,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
refresh()
|
||||
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [block] holding the snapshot's lock, once startup has settled which
|
||||
* store is the user's. A write refuses to run without that, where a read
|
||||
* falls back to answering from whichever store autoMode() picks.
|
||||
*/
|
||||
fun <T> locked(block: () -> T): T {
|
||||
start()
|
||||
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
|
||||
"Agendula is still starting; try again"
|
||||
}
|
||||
return synchronized(this, block)
|
||||
}
|
||||
|
||||
/** The [columns] of every row of [table] that [selection] matches, as of now. */
|
||||
@Synchronized
|
||||
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
|
||||
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
add(
|
||||
List(cursor.columnCount) { column ->
|
||||
when (cursor.getType(column)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
|
||||
else -> cursor.getString(column)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the snapshot stale at once. A provider write calls this so the next
|
||||
* operation of the same batch sees it, before Room's own invalidation lands.
|
||||
*/
|
||||
fun invalidate() {
|
||||
generation.incrementAndGet()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun refresh() {
|
||||
start()
|
||||
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
|
||||
val now = clock.now()
|
||||
val current = generation.get()
|
||||
val expired = builtAt.let { it == null || now - it > MAX_AGE }
|
||||
if (current != builtGeneration || expired) {
|
||||
snapshot.load(build(now))
|
||||
builtGeneration = current
|
||||
// Instance ids and distances move with the clock, so a timed rebuild is a change too.
|
||||
if (expired && builtAt != null) notifyObservers()
|
||||
builtAt = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(now: Instant): ProviderSnapshot {
|
||||
if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY
|
||||
// Read consistently in one transaction; expand outside it, so the app's
|
||||
// own writes do not wait on recurrence expansion.
|
||||
val (lists, tasks, alarms) = database.runInTransaction<Triple<List<TaskListRow>, List<TaskEntity>, List<TaskAlarmEntity>>> {
|
||||
Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all())
|
||||
}
|
||||
return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now)
|
||||
}
|
||||
|
||||
private fun changed() {
|
||||
generation.incrementAndGet()
|
||||
notifyObservers()
|
||||
}
|
||||
|
||||
/** Coalesced, so a sync committing many small transactions wakes each client once. */
|
||||
private fun notifyObservers() {
|
||||
handler.removeCallbacks(notify)
|
||||
handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AUTHORITY_SUFFIX = "tasks"
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import org.dmfs.rfc5545.recur.RecurrenceRule
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A client's `ContentValues`, as plain Kotlin values. */
|
||||
typealias ProviderValues = Map<String, Any?>
|
||||
|
||||
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
|
||||
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
|
||||
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
|
||||
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
|
||||
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
|
||||
Tasks.SORTING,
|
||||
)
|
||||
|
||||
/** The columns that say whether a task, or one occurrence of it, is done. */
|
||||
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
|
||||
|
||||
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
|
||||
val TIME_COLUMNS: Set<String> = setOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
)
|
||||
|
||||
/** What a write to one occurrence of a series does to it. */
|
||||
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
|
||||
|
||||
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||
|
||||
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
|
||||
|
||||
/**
|
||||
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
|
||||
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
|
||||
* which reaches the client as is.
|
||||
*
|
||||
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
|
||||
* publishes is accepted and ignored, so a client can write back a row it read;
|
||||
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
|
||||
* properties) are not published and so cannot be written at all.
|
||||
*/
|
||||
object ProviderTaskWriter {
|
||||
|
||||
/**
|
||||
* [values] narrowed to the columns in [writable]. A column [table] does not
|
||||
* have at all is a client bug and throws.
|
||||
*/
|
||||
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
|
||||
val known = table.columns.mapTo(HashSet()) { it.first }
|
||||
val unknown = values.keys - known
|
||||
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
|
||||
return values.filterKeys { it in writable }
|
||||
}
|
||||
|
||||
/** A new task from [values], which must name its list. */
|
||||
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
|
||||
val v = Values(values)
|
||||
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
|
||||
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
|
||||
// As in OpenTasks, a new task inserted without a zone is floating.
|
||||
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
|
||||
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
|
||||
}
|
||||
|
||||
/**
|
||||
* [current] with [values] applied. Identity and sync columns are left alone,
|
||||
* and [current] itself comes back when nothing changed, so a write that
|
||||
* changes nothing owes the server nothing.
|
||||
*/
|
||||
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||
val edited = applied(current, values, now, tzId)
|
||||
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns in [values] that would change [current] on their own. Writing
|
||||
* back what a row published changes nothing, so a client that read a row can
|
||||
* send all of it with the one value it meant to change.
|
||||
*/
|
||||
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
|
||||
val stamp = current.lastModified ?: Instant.DISTANT_PAST
|
||||
return values.keys.filterTo(HashSet()) { key ->
|
||||
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
|
||||
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
|
||||
}
|
||||
}
|
||||
|
||||
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||
val v = Values(values)
|
||||
val isOverride = current.masterId != null
|
||||
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
|
||||
if (isOverride) {
|
||||
// NULL is what the row publishes, so writing it back is fine.
|
||||
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
|
||||
"A modified occurrence carries no recurrence rule of its own"
|
||||
}
|
||||
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
|
||||
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
|
||||
}
|
||||
|
||||
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
|
||||
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
|
||||
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
|
||||
|
||||
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
|
||||
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
|
||||
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
|
||||
val duration = when {
|
||||
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
|
||||
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
|
||||
Tasks.DUE in v && due != null -> null
|
||||
else -> current.duration
|
||||
}
|
||||
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
|
||||
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
|
||||
val timezone = when {
|
||||
allDay -> null
|
||||
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
|
||||
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
|
||||
}
|
||||
dtstart == null && due == null -> null
|
||||
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
|
||||
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
|
||||
else -> current.timezone ?: tzId
|
||||
}
|
||||
|
||||
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
|
||||
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
|
||||
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
|
||||
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
|
||||
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
|
||||
|
||||
val (status, percent, completedAt) = completion(current, v, now)
|
||||
|
||||
return current.copy(
|
||||
listId = listId,
|
||||
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
|
||||
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
|
||||
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
|
||||
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
|
||||
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
|
||||
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
|
||||
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
|
||||
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
|
||||
status = status,
|
||||
percentComplete = percent,
|
||||
completedAt = completedAt,
|
||||
dtstart = dtstart,
|
||||
due = due,
|
||||
duration = duration,
|
||||
isAllDay = allDay,
|
||||
timezone = timezone,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
parentId = parentId,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What [values] do to one occurrence of a series, or `null` when they say
|
||||
* nothing about its completion. An occurrence is done, cancelled or open;
|
||||
* partial progress belongs to a task, not to one of its occurrences.
|
||||
*/
|
||||
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
|
||||
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
|
||||
return when {
|
||||
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
|
||||
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
|
||||
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
|
||||
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
|
||||
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
|
||||
}
|
||||
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
|
||||
100 -> OccurrenceChange.COMPLETE
|
||||
0, null -> OccurrenceChange.REOPEN
|
||||
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
|
||||
}
|
||||
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status, progress and completion time after a write. They move together the
|
||||
* way OpenTasks keeps them: a status implies its progress and time, progress
|
||||
* implies a status, and a completion time alone completes the task. When a
|
||||
* row is written back, the column that changed decides, not the ones it
|
||||
* carries unchanged.
|
||||
*/
|
||||
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
|
||||
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
|
||||
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
|
||||
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
|
||||
return when {
|
||||
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
|
||||
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
|
||||
TaskStatus.NEEDS_ACTION ->
|
||||
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
|
||||
else -> Triple(status, percent, null)
|
||||
}
|
||||
Tasks.PERCENT_COMPLETE in v -> when {
|
||||
percent == null -> Triple(current.status, null, current.completedAt)
|
||||
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
|
||||
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
|
||||
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
|
||||
}
|
||||
Tasks.COMPLETED in v -> when {
|
||||
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
|
||||
// Clearing the time reopens a completed task; an open one has none to clear.
|
||||
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
|
||||
else -> Triple(current.status, current.percentComplete, null)
|
||||
}
|
||||
else -> Triple(current.status, current.percentComplete, current.completedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion columns in [v] that differ from [current]. All of them when
|
||||
* none does, so a write that only repeats the row still settles the rest.
|
||||
*/
|
||||
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
|
||||
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
|
||||
val changed = present.filterTo(HashSet()) { column ->
|
||||
when (column) {
|
||||
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
|
||||
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
|
||||
else -> v.instant(Tasks.COMPLETED) != current.completedAt
|
||||
}
|
||||
}
|
||||
return changed.ifEmpty { present }
|
||||
}
|
||||
|
||||
/**
|
||||
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
|
||||
* one kind. [stored] written back as is stays, whatever shape it synced in.
|
||||
*/
|
||||
private fun dateList(column: String, v: Values, stored: String?): String? {
|
||||
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
|
||||
if (raw == stored?.trim()) return stored
|
||||
val values = raw.split(',').map(String::trim)
|
||||
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
|
||||
// lib-recur reads the whole list or none of it, so it has to be one kind.
|
||||
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
|
||||
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
|
||||
return values.joinToString(",")
|
||||
}
|
||||
|
||||
private fun normalizedRule(raw: String?): String? {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
|
||||
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
|
||||
return rule
|
||||
}
|
||||
}
|
||||
|
||||
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
|
||||
internal class Values(private val values: ProviderValues) {
|
||||
|
||||
val keys: Set<String> get() = values.keys
|
||||
|
||||
val map: ProviderValues get() = values
|
||||
|
||||
operator fun contains(key: String) = key in values
|
||||
|
||||
fun string(key: String): String? = values[key]?.toString()
|
||||
|
||||
/** A free-text column: trimmed, blank stored as nothing. */
|
||||
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
|
||||
|
||||
fun long(key: String): Long? = when (val value = values[key]) {
|
||||
null -> null
|
||||
is Number -> value.toLong()
|
||||
is Boolean -> if (value) 1L else 0L
|
||||
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
|
||||
else -> throw IllegalArgumentException("$key is not a number: $value")
|
||||
}
|
||||
|
||||
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
|
||||
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
|
||||
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
|
||||
}
|
||||
|
||||
fun boolean(key: String): Boolean = when (val value = values[key]) {
|
||||
null -> false
|
||||
is Boolean -> value
|
||||
is String -> when (value.trim().lowercase()) {
|
||||
"1", "true" -> true
|
||||
"0", "false", "" -> false
|
||||
else -> throw IllegalArgumentException("$key is not a flag: $value")
|
||||
}
|
||||
else -> long(key) != 0L
|
||||
}
|
||||
|
||||
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
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.StorageMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** One row by id, `<id column> = N` or `<id column> = ?`. */
|
||||
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
|
||||
|
||||
/**
|
||||
* Applies another app's writes to the own store for [AgendulaTaskProvider].
|
||||
*
|
||||
* A write's selection runs against the published snapshot, so it matches the
|
||||
* rows the client can read, and each matched row is then written through
|
||||
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
|
||||
* stays read-only. On `instances`, completion columns tick off that one
|
||||
* occurrence of a series; other content columns edit the series, and a changed
|
||||
* time column is refused, since one occurrence cannot be moved this way.
|
||||
*
|
||||
* Each call runs in one transaction, and a batch in one more around them.
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderWriter internal constructor(
|
||||
private val database: TasksDatabase,
|
||||
private val store: RoomTasksDataSource,
|
||||
private val source: ProviderSnapshotSource,
|
||||
private val resolver: ProviderResolver,
|
||||
private val afterWrite: () -> Unit,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
|
||||
@Inject constructor(
|
||||
database: TasksDatabase,
|
||||
store: RoomTasksDataSource,
|
||||
source: ProviderSnapshotSource,
|
||||
resolver: ProviderResolver,
|
||||
scheduler: ReminderScheduler,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
|
||||
|
||||
private var batchDepth = 0
|
||||
private var batchWrote = false
|
||||
|
||||
/** Inserts one row and returns its published id. */
|
||||
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> insertTask(values)
|
||||
ProviderTable.PROPERTIES -> insertProperty(values)
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates every row [selection] matches and returns how many changed. */
|
||||
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
val matched = ids(table, selection, args)
|
||||
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||
}
|
||||
ProviderTable.INSTANCES -> {
|
||||
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||
instances(selection, args).count { updateInstance(it, writable) }
|
||||
}
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every row [selection] matches and returns how many went. */
|
||||
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
|
||||
when (table) {
|
||||
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
|
||||
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
|
||||
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
|
||||
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
||||
fun <T> batch(block: () -> T): T = source.locked {
|
||||
batchDepth++
|
||||
try {
|
||||
database.runInTransaction<T> { block() }
|
||||
} finally {
|
||||
// A snapshot built mid-batch may hold rows a failure rolled back.
|
||||
source.invalidate()
|
||||
batchDepth--
|
||||
if (batchDepth == 0 && batchWrote) {
|
||||
batchWrote = false
|
||||
afterWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> write(block: () -> T): T = source.locked {
|
||||
check(resolver.mode() == StorageMode.OWN) {
|
||||
"Agendula is working on top of an external tasks provider; write to that one instead"
|
||||
}
|
||||
val result = try {
|
||||
database.runInTransaction<T> { block() }
|
||||
} catch (e: TaskWriteFailedException) {
|
||||
throw IllegalArgumentException(e.message, e)
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
throw IllegalArgumentException(e.message, e)
|
||||
} finally {
|
||||
source.invalidate()
|
||||
}
|
||||
if (batchDepth == 0) afterWrite() else batchWrote = true
|
||||
result
|
||||
}
|
||||
|
||||
// --- tasks ------------------------------------------------------------------
|
||||
|
||||
private fun insertTask(values: ProviderValues): Long {
|
||||
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
|
||||
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
|
||||
}
|
||||
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
|
||||
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
|
||||
requireWritableList(task.listId)
|
||||
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
|
||||
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
|
||||
requireParent(task)
|
||||
return store.insertEntity(task)
|
||||
}
|
||||
|
||||
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(current.listId)
|
||||
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
|
||||
if (edited == current) return true
|
||||
val moved = edited.listId != current.listId
|
||||
if (moved) {
|
||||
requireWritableList(edited.listId)
|
||||
requireFreeUid(edited.listId, current.uid)
|
||||
}
|
||||
if (edited.parentId != current.parentId || moved) requireParent(edited)
|
||||
store.replaceEntity(current, edited)
|
||||
// A subtree moves as one: a subtask has to be in its parent's list.
|
||||
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
|
||||
if (!seen.add(taskId)) return
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
|
||||
if (current.listId != listId) {
|
||||
requireFreeUid(listId, current.uid)
|
||||
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [ids] with every parent before its subtasks, so a bulk move carries a
|
||||
* subtree along before reaching its subtasks, whatever order they came in.
|
||||
*/
|
||||
private fun parentsFirst(ids: List<Long>): List<Long> {
|
||||
if (ids.size < 2) return ids
|
||||
fun depth(id: Long): Int {
|
||||
val seen = HashSet<Long>()
|
||||
var parent = database.tasks().entity(id)?.parentId
|
||||
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
|
||||
return seen.size
|
||||
}
|
||||
val depths = ids.associateWith(::depth)
|
||||
return ids.sortedBy(depths::getValue)
|
||||
}
|
||||
|
||||
private fun deleteTask(taskId: Long): Boolean {
|
||||
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(current.listId)
|
||||
// An override deletes itself into an EXDATE on its master.
|
||||
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
|
||||
return true
|
||||
}
|
||||
|
||||
// --- instances --------------------------------------------------------------
|
||||
|
||||
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
|
||||
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
|
||||
|
||||
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
|
||||
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
|
||||
return source.rows(
|
||||
ProviderTable.INSTANCES,
|
||||
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
|
||||
selection,
|
||||
args,
|
||||
).map { (id, taskId, original, recurring) ->
|
||||
InstanceRow(
|
||||
taskId = taskId as Long,
|
||||
seriesId = ProviderIds.instanceSeries(id as Long),
|
||||
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
|
||||
recurring = recurring == 1L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
|
||||
private fun instanceRow(instanceId: Long): InstanceRow? {
|
||||
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
|
||||
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
|
||||
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
|
||||
?: return InstanceRow(master.id, master.id, null, recurring = false)
|
||||
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
|
||||
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
|
||||
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
|
||||
}
|
||||
|
||||
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
|
||||
val occurrence = row.originalTime
|
||||
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
|
||||
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(shown.listId)
|
||||
// A row read back carries every column; only what differs from it is a write.
|
||||
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
|
||||
val moved = changed intersect TIME_COLUMNS
|
||||
if (moved.isNotEmpty()) {
|
||||
// A bad value reports itself rather than as a move.
|
||||
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
|
||||
throw IllegalArgumentException(
|
||||
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
|
||||
)
|
||||
}
|
||||
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
|
||||
if (content.isNotEmpty()) {
|
||||
updateTask(row.seriesId, content)
|
||||
// A modified occurrence shows its own copy of the content, so it takes the write too.
|
||||
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
|
||||
}
|
||||
if (changed.none { it in COMPLETION_COLUMNS }) return true
|
||||
// The column the client changed decides, not the unchanged ones it carries along.
|
||||
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
|
||||
?: return true
|
||||
val status = database.tasks().entity(row.taskId)?.status
|
||||
when (change) {
|
||||
OccurrenceChange.COMPLETE ->
|
||||
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
|
||||
OccurrenceChange.CANCEL ->
|
||||
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
|
||||
OccurrenceChange.REOPEN ->
|
||||
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteInstance(row: InstanceRow): Boolean {
|
||||
val occurrence = row.originalTime
|
||||
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
|
||||
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||
requireWritableList(task.listId)
|
||||
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
|
||||
return true
|
||||
}
|
||||
|
||||
// --- properties -------------------------------------------------------------
|
||||
|
||||
/** What a published property id stands for, read from the store. */
|
||||
private sealed interface PropertyRef {
|
||||
val task: TaskEntity
|
||||
|
||||
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
|
||||
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
|
||||
}
|
||||
|
||||
private fun propertyRef(propertyId: Long): PropertyRef? {
|
||||
if (ProviderIds.isAlarm(propertyId)) {
|
||||
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
|
||||
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
|
||||
return PropertyRef.AlarmRow(alarm, task)
|
||||
}
|
||||
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
|
||||
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
|
||||
?: return null
|
||||
return PropertyRef.CategoryRow(name, task)
|
||||
}
|
||||
|
||||
private fun insertProperty(values: ProviderValues): Long {
|
||||
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
|
||||
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
|
||||
val task = liveTask(taskId)
|
||||
return when (v.string(Properties.MIMETYPE)) {
|
||||
Alarm.MIMETYPE -> {
|
||||
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
|
||||
var id = 0L
|
||||
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
|
||||
ProviderIds.alarm(id)
|
||||
}
|
||||
Category.MIMETYPE -> {
|
||||
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||
setCategories(task, task.categories + name)
|
||||
val categories = database.tasks().entity(taskId)!!.categories
|
||||
ProviderIds.categories(taskId, categories).getValue(name)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
|
||||
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
|
||||
val ref = propertyRef(propertyId) ?: return false
|
||||
requireWritableList(ref.task.listId)
|
||||
requireSame(v, Properties.TASK_ID, ref.task.id)
|
||||
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
|
||||
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
|
||||
when (ref) {
|
||||
is PropertyRef.AlarmRow -> {
|
||||
val edited = alarm(ref.alarm, v, requireMinutes = false)
|
||||
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
|
||||
}
|
||||
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
|
||||
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteProperty(propertyId: Long): Boolean {
|
||||
val ref = propertyRef(propertyId) ?: return false
|
||||
requireWritableList(ref.task.listId)
|
||||
when (ref) {
|
||||
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
|
||||
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
|
||||
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
|
||||
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
|
||||
null -> base.reference
|
||||
Alarm.REFERENCE_DUE -> AlarmReference.DUE
|
||||
Alarm.REFERENCE_START -> AlarmReference.START
|
||||
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
|
||||
}
|
||||
return base.copy(
|
||||
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
|
||||
"${Alarm.MINUTES_BEFORE} cannot be null"
|
||||
} else base.minutesBefore,
|
||||
reference = reference,
|
||||
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setCategories(task: TaskEntity, categories: List<String>) {
|
||||
val normalized = normalizeTags(categories)
|
||||
if (normalized == task.categories) return
|
||||
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
|
||||
// --- checks -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
|
||||
* against the store directly; anything else runs against the snapshot.
|
||||
*/
|
||||
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
|
||||
singleId(table, selection, args)?.let { id ->
|
||||
val exists = when (table) {
|
||||
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
|
||||
ProviderTable.PROPERTIES -> propertyRef(id) != null
|
||||
else -> true
|
||||
}
|
||||
return listOfNotNull(id.takeIf { exists })
|
||||
}
|
||||
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
|
||||
}
|
||||
|
||||
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
|
||||
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
|
||||
if (match.groupValues[1] != table.idColumn) return null
|
||||
val value = match.groupValues[2]
|
||||
return when {
|
||||
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
|
||||
args.isNullOrEmpty() -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireFreeUid(listId: Long, uid: String) {
|
||||
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
|
||||
}
|
||||
|
||||
private fun liveTask(taskId: Long): TaskEntity {
|
||||
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
|
||||
requireWritableList(task.listId)
|
||||
return task
|
||||
}
|
||||
|
||||
private fun requireWritableList(listId: Long) {
|
||||
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
|
||||
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
|
||||
}
|
||||
|
||||
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
|
||||
private fun requireParent(task: TaskEntity) {
|
||||
var parentId = task.parentId ?: return
|
||||
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
|
||||
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
|
||||
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
|
||||
val seen = HashSet<Long>()
|
||||
while (true) {
|
||||
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
|
||||
if (!seen.add(parentId)) break
|
||||
parentId = database.tasks().entity(parentId)?.parentId ?: break
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSame(v: Values, column: String, expected: Long) {
|
||||
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
|
||||
}
|
||||
|
||||
private fun zone(): String = ZoneId.systemDefault().id
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import android.os.Build
|
||||
import androidx.core.database.sqlite.transaction
|
||||
|
||||
/**
|
||||
* An in-memory SQLite copy of a [ProviderSnapshot], so a client's own
|
||||
* `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would
|
||||
* against OpenTasks. Holds only what the caller may already read.
|
||||
*
|
||||
* Not thread-safe; [ProviderSnapshotSource] serialises access.
|
||||
*/
|
||||
internal class SnapshotDatabase {
|
||||
|
||||
private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
execSQL(
|
||||
"CREATE TABLE ${table.path} (" +
|
||||
table.columns.joinToString { (name, type) -> "$name $type" } + ")",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun load(snapshot: ProviderSnapshot) {
|
||||
db.transaction {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
delete(table.path, null, null)
|
||||
snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the query and copies the result out, so the cursor a client holds
|
||||
* survives the next [load].
|
||||
*/
|
||||
fun query(
|
||||
table: ProviderTable,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val builder = SQLiteQueryBuilder().apply {
|
||||
tables = table.path
|
||||
isStrict = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
isStrictColumns = true
|
||||
isStrictGrammar = true
|
||||
}
|
||||
projectionMap = table.columns.associate { (name, _) -> name to name }
|
||||
}
|
||||
builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source ->
|
||||
val copy = MatrixCursor(source.columnNames, source.count)
|
||||
while (source.moveToNext()) {
|
||||
copy.addRow(
|
||||
Array(source.columnCount) { column ->
|
||||
when (source.getType(column)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> source.getLong(column)
|
||||
Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column)
|
||||
Cursor.FIELD_TYPE_BLOB -> source.getBlob(column)
|
||||
else -> source.getString(column)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProviderRow.toContentValues() = ContentValues(size).also { values ->
|
||||
forEach { (key, value) ->
|
||||
when (value) {
|
||||
null -> values.putNull(key)
|
||||
is Long -> values.put(key, value)
|
||||
is Int -> values.put(key, value)
|
||||
is String -> values.put(key, value)
|
||||
else -> values.put(key, value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-73
@@ -2,8 +2,8 @@ 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.editable
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
@@ -31,7 +31,51 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "
|
||||
/** Enough to count every occurrence before a split point without bounding a real series. */
|
||||
internal const val SPLIT_COUNT_CEILING = 100_000
|
||||
|
||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
/** The window every read of the store expands a series over. */
|
||||
internal fun expansionWindow(now: Instant) = ExpansionWindow(
|
||||
from = now - WINDOW_BACK,
|
||||
until = now + WINDOW_FORWARD,
|
||||
pivot = now,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
|
||||
if (master.dtstart == null) return null to anchor
|
||||
val length = master.due?.let { it - master.dtstart }
|
||||
return anchor to length?.let { anchor + it }
|
||||
}
|
||||
|
||||
/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */
|
||||
internal class SeriesOccurrence(
|
||||
val anchor: Instant,
|
||||
val override: TaskEntity?,
|
||||
val start: Instant?,
|
||||
val due: Instant?,
|
||||
val distance: Int?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every occurrence of [master] inside the expansion window, with any
|
||||
* `RECURRENCE-ID` override substituted for the occurrence it replaces, or
|
||||
* `null` when [master] does not recur.
|
||||
*/
|
||||
internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<SeriesOccurrence>? {
|
||||
val spec = master.recurrenceSpec() ?: return null
|
||||
val anchors = RecurrenceExpander.expand(spec, expansionWindow(now))
|
||||
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||
val byAnchor = overrides.associateBy { it.recurrenceId }
|
||||
return anchors.mapIndexed { index, anchor ->
|
||||
val override = byAnchor[anchor]
|
||||
val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor)
|
||||
SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index))
|
||||
}
|
||||
}
|
||||
|
||||
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
|
||||
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
||||
private const val ACK_CACHE_SIZE = 512
|
||||
@@ -104,6 +148,12 @@ class RoomTasksDataSource @Inject constructor(
|
||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||
}
|
||||
|
||||
/** One transaction, so a failure part-way leaves the subtree whole. */
|
||||
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
|
||||
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||
|
||||
@@ -125,9 +175,6 @@ class RoomTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> =
|
||||
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
||||
|
||||
/**
|
||||
* Every occurrence of [row] inside the expansion window, with any
|
||||
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
|
||||
@@ -136,54 +183,31 @@ class RoomTasksDataSource @Inject constructor(
|
||||
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
|
||||
*/
|
||||
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
||||
val window = ExpansionWindow(
|
||||
from = now - WINDOW_BACK,
|
||||
until = now + WINDOW_FORWARD,
|
||||
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 }
|
||||
|
||||
return anchors.mapIndexedNotNull { index, anchor ->
|
||||
val override = byAnchor[anchor]
|
||||
val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row))
|
||||
return occurrences.map { occurrence ->
|
||||
val override = occurrence.override
|
||||
if (override != null) {
|
||||
RoomTaskMapper.task(
|
||||
row = row.copy(task = override),
|
||||
occurrenceStart = anchor,
|
||||
start = override.dtstart,
|
||||
due = override.due,
|
||||
distanceFromCurrent = distances[index],
|
||||
occurrenceStart = occurrence.anchor,
|
||||
start = occurrence.start,
|
||||
due = occurrence.due,
|
||||
distanceFromCurrent = occurrence.distance,
|
||||
seriesId = row.task.id,
|
||||
recurrenceRule = row.task.rrule,
|
||||
)
|
||||
} else {
|
||||
val (start, due) = occurrenceTimes(row.task, anchor)
|
||||
RoomTaskMapper.task(
|
||||
row = row,
|
||||
occurrenceStart = anchor,
|
||||
start = start,
|
||||
due = due,
|
||||
distanceFromCurrent = distances[index],
|
||||
occurrenceStart = occurrence.anchor,
|
||||
start = occurrence.start,
|
||||
due = occurrence.due,
|
||||
distanceFromCurrent = occurrence.distance,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Instant?, Instant?> {
|
||||
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, reminders: List<TaskReminder>?): Long {
|
||||
@@ -199,26 +223,24 @@ class RoomTasksDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* [leads] as [taskId]'s before-due reminders, inside the caller's
|
||||
* transaction. A start-relative row is kept: the editor never shows one.
|
||||
* [reminders] as [taskId]'s own, inside the caller's transaction.
|
||||
*
|
||||
* ⚠️ Part of the task write rather than a second call after it. Once
|
||||
* reminders sync, a sync landing between the two would upload the task
|
||||
* without them, or with the ones it is about to lose.
|
||||
*/
|
||||
private fun dueLeads(taskId: Long): List<Int> =
|
||||
alarms.forTask(taskId).filter { it.reference == AlarmReference.DUE }.map { it.minutesBefore }.sorted()
|
||||
|
||||
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
|
||||
if (leads == null) return
|
||||
val kept = alarms.forTask(taskId).filter { it.reference == AlarmReference.START }.map { it.asReminder() }
|
||||
alarms.syncForTask(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
|
||||
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||
if (reminders == null) return
|
||||
alarms.syncForTask(taskId, reminders)
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = tx {
|
||||
private fun storedReminders(taskId: Long): List<TaskReminder> =
|
||||
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
||||
writeDueReminders(taskId, dueReminders)
|
||||
writeReminders(taskId, reminders)
|
||||
if (current.masterId == null && edited.listId != current.listId) {
|
||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
||||
@@ -257,20 +279,23 @@ class RoomTasksDataSource @Inject constructor(
|
||||
plan.tombstone?.let { tasks.insert(it) }
|
||||
}
|
||||
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) = tx {
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||
val master = seriesMaster(seriesId)
|
||||
// ⚠️ The overrides too, where they still carry the series' reminders.
|
||||
// A fork copies them as a snapshot — completing one occurrence is
|
||||
// enough — and each override uploads its own VALARMs, so leaving them
|
||||
// would keep ringing those occurrences at the old lead everywhere. One
|
||||
// whose reminders were set for that occurrence alone keeps them.
|
||||
if (dueReminders != null) {
|
||||
val before = dueLeads(master.id)
|
||||
if (reminders != null) {
|
||||
val before = storedReminders(master.id)
|
||||
tasks.overrides(master.id)
|
||||
.filter { dueLeads(it.id) == before }
|
||||
.forEach { writeDueReminders(it.id, dueReminders) }
|
||||
.filter { storedReminders(it.id) == before }
|
||||
.forEach {
|
||||
writeReminders(it.id, reminders)
|
||||
if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true)
|
||||
}
|
||||
}
|
||||
writeDueReminders(master.id, dueReminders)
|
||||
writeReminders(master.id, reminders)
|
||||
val (start, due) = resolvedTimes(master, occurrenceStart)
|
||||
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
|
||||
// A moved or re-ruled series no longer generates the anchors its
|
||||
@@ -286,14 +311,14 @@ class RoomTasksDataSource @Inject constructor(
|
||||
writes.onWritten(setOf(master.listId, edited.listId))
|
||||
}
|
||||
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||
var created = seriesId
|
||||
tx {
|
||||
val master = seriesMaster(seriesId)
|
||||
val spec = master.recurrenceSpec()
|
||||
val anchor = master.recurrenceAnchor
|
||||
if (spec == null || anchor == null || occurrenceStart <= anchor) {
|
||||
updateSeries(seriesId, occurrenceStart, form, dueReminders)
|
||||
updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||
return@tx
|
||||
}
|
||||
val now = clock.now()
|
||||
@@ -338,7 +363,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
val newId = tasks.insert(fresh)
|
||||
// A new UID, so a new resource: the alarms are copies, not the same ones.
|
||||
alarms.copyToTask(master.id, newId, sameResource = false)
|
||||
writeDueReminders(newId, dueReminders)
|
||||
writeReminders(newId, reminders)
|
||||
|
||||
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
|
||||
tasks.overrides(master.id)
|
||||
@@ -373,8 +398,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
val master = tasks.entity(seriesId) ?: return@tx
|
||||
val anchor = master.recurrenceAnchor
|
||||
if (anchor == null || occurrenceStart <= anchor) {
|
||||
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
|
||||
deleteTask(seriesId)
|
||||
deleteWithSubtasks(seriesId)
|
||||
return@tx
|
||||
}
|
||||
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
|
||||
@@ -403,7 +427,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
* 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, dueReminders: List<Int>?) = tx {
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||
val now = clock.now()
|
||||
val existing = tasks.override(taskId, occurrenceStart)
|
||||
@@ -412,7 +436,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
// master's, whatever the form was carrying.
|
||||
val edited = TaskFormWriter.apply(existing, form, now, zone())
|
||||
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
|
||||
writeDueReminders(existing.id, dueReminders)
|
||||
writeReminders(existing.id, reminders)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return@tx
|
||||
}
|
||||
@@ -429,18 +453,10 @@ class RoomTasksDataSource @Inject constructor(
|
||||
// The occurrence starts out with the series' reminders and then takes
|
||||
// the form's — the master's own are never touched.
|
||||
alarms.copyToTask(taskId, id, sameResource = true)
|
||||
writeDueReminders(id, dueReminders)
|
||||
writeReminders(id, reminders)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
}
|
||||
|
||||
/** Through [setReminders], so a synced task is owed the PUT and the others keep their identity. */
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||
val current = alarms.forTask(taskId).map { it.asReminder() }
|
||||
val editable = current.indexOfLast { !it.fromStart }
|
||||
val others = if (editable < 0) current else current.filterIndexed { index, _ -> index != editable }
|
||||
setReminders(taskId, others + listOfNotNull(minutesBeforeDue?.let { TaskReminder(it) }))
|
||||
}
|
||||
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
||||
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
||||
// The reminders are part of the VTODO now, so changing them is an edit.
|
||||
@@ -566,6 +582,47 @@ class RoomTasksDataSource @Inject constructor(
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
// --- whole-row writes, for the published provider --------------------------
|
||||
|
||||
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
|
||||
internal fun insertEntity(entity: TaskEntity): Long {
|
||||
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
|
||||
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes [edited] over [current] under the rules [updateTask] and
|
||||
* [updateSeries] keep: a task moves lists through [TaskMove], an override
|
||||
* keeps its master's list and parent, and a series whose anchor or rule
|
||||
* changed drops the overrides it no longer generates.
|
||||
*/
|
||||
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
|
||||
tx {
|
||||
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
|
||||
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
|
||||
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
|
||||
when {
|
||||
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
|
||||
edited.listId != current.listId -> {
|
||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||
move(current, edited)
|
||||
}
|
||||
else -> tasks.update(edited)
|
||||
}
|
||||
}
|
||||
writes.onWritten(setOf(current.listId, edited.listId))
|
||||
}
|
||||
|
||||
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
|
||||
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
|
||||
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
|
||||
tx {
|
||||
write()
|
||||
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
|
||||
}
|
||||
writes.onWritten(setOf(task.listId))
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long =
|
||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||
import kotlin.time.Instant
|
||||
@@ -25,18 +25,21 @@ interface TaskAlarmDao {
|
||||
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
|
||||
fun forTasks(taskIds: List<Long>): List<TaskAlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM task_alarms WHERE id = :id")
|
||||
fun entity(id: Long): TaskAlarmEntity?
|
||||
|
||||
@Insert
|
||||
fun insert(alarm: TaskAlarmEntity): Long
|
||||
|
||||
@Update
|
||||
fun update(alarm: TaskAlarmEntity): Int
|
||||
|
||||
@Query("DELETE FROM task_alarms WHERE id = :id")
|
||||
fun delete(id: Long): Int
|
||||
|
||||
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
||||
fun deleteForTask(taskId: Long): Int
|
||||
|
||||
@Update
|
||||
fun update(alarm: TaskAlarmEntity)
|
||||
|
||||
/** See [TaskEntity.remindersOff]. */
|
||||
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
|
||||
fun setRemindersOff(taskId: Long, off: Boolean): Int
|
||||
|
||||
@@ -105,6 +105,10 @@ interface TaskDao {
|
||||
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
|
||||
fun exportTasks(listId: Long): List<TaskEntity>
|
||||
|
||||
/** Every row but the tombstones, overrides included — what the published provider lays out. */
|
||||
@Query("SELECT * FROM tasks WHERE is_deleted = 0")
|
||||
fun live(): List<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
||||
fun dirty(): List<TaskEntity>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ object TaskFormWriter {
|
||||
isAllDay = form.isAllDay,
|
||||
timezone = if (timed) tzId else null,
|
||||
parentId = form.parentId?.takeIf { it > 0 },
|
||||
remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.datastore.preferences.core.edit
|
||||
import de.jeanlucmakiola.agendula.data.di.ExternalStore
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
|
||||
@@ -18,8 +18,10 @@ data class TaskForm(
|
||||
val parentId: Long? = null,
|
||||
/** 0..100 progress; `null` leaves it unset (cleared). */
|
||||
val percentComplete: Int? = null,
|
||||
/** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */
|
||||
val reminders: List<Int> = emptyList(),
|
||||
/** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */
|
||||
val reminders: List<TaskReminder> = emptyList(),
|
||||
/** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */
|
||||
val remindersChosen: Boolean = false,
|
||||
val location: String? = null,
|
||||
val url: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
@@ -30,14 +32,14 @@ data class TaskForm(
|
||||
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
||||
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
||||
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
||||
if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE)
|
||||
if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE)
|
||||
}
|
||||
|
||||
val isValid: Boolean get() = validate().isEmpty()
|
||||
}
|
||||
|
||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE }
|
||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE }
|
||||
|
||||
/**
|
||||
* Optional edit-form sections that can be shown or tucked behind "More fields"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||
import kotlin.time.Instant
|
||||
|
||||
const val DAY_MINUTES = 24 * 60
|
||||
|
||||
/**
|
||||
* The earliest lead an all-day reminder can have: 00:01 on the day. On an
|
||||
* all-day task a lead that is not whole days counts back from the date's
|
||||
* midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay].
|
||||
*/
|
||||
const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1)
|
||||
|
||||
/** A stored reminder: how long before the start ([fromStart]) or the due date. */
|
||||
data class TaskReminder(
|
||||
val minutesBefore: Int,
|
||||
val fromStart: Boolean = false,
|
||||
/**
|
||||
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
|
||||
* a trigger at or before it has been dealt with and is not fired here.
|
||||
* Never part of what a reminder *is* — see [lead].
|
||||
*/
|
||||
val acknowledgedAt: Instant? = null,
|
||||
) {
|
||||
/** The reminder without its bookkeeping, for comparing two of them. */
|
||||
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
|
||||
|
||||
/** This reminder read as an all-day one. */
|
||||
val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore)
|
||||
|
||||
/** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */
|
||||
fun inWholeDays(): TaskReminder =
|
||||
copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The all-day reminder [daysBefore] the date at [minuteOfDay], or at the
|
||||
* device's all-day time when that is null. Midnight would read back as
|
||||
* the latter, so it is 00:01 — see [allDayMinuteOfDay].
|
||||
*/
|
||||
fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) =
|
||||
TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart)
|
||||
|
||||
/** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */
|
||||
fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1)
|
||||
}
|
||||
}
|
||||
|
||||
/** An all-day reminder as the day it rings on and, when it names one, its time. */
|
||||
data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) {
|
||||
companion object {
|
||||
/** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */
|
||||
fun of(minutesBefore: Int): AllDayReminder {
|
||||
val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD)
|
||||
if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null)
|
||||
val days = Math.floorDiv(minutes, DAY_MINUTES) + 1
|
||||
return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A canonical order, for comparing two sets of reminders. */
|
||||
val TaskReminderOrder: Comparator<TaskReminder> =
|
||||
compareBy<TaskReminder>({ !it.fromStart }, { -it.minutesBefore })
|
||||
|
||||
/**
|
||||
* The order the editor and the detail screen list reminders in: start's first,
|
||||
* then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay].
|
||||
*/
|
||||
fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator<TaskReminder> =
|
||||
compareBy<TaskReminder>({ !it.fromStart }, { reminder ->
|
||||
if (!allDay) {
|
||||
-reminder.minutesBefore
|
||||
} else {
|
||||
val (days, minuteOfDay) = reminder.allDay
|
||||
(minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES
|
||||
}
|
||||
})
|
||||
|
||||
/** A default reminder's lead as a reminder of the task's own. */
|
||||
fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart)
|
||||
|
||||
/** This reminder's lead, as default reminders are kept. */
|
||||
fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart)
|
||||
@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
|
||||
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||
@@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when
|
||||
minutes <= 0 -> stringResource(R.string.reminder_at_due)
|
||||
minutes % 10_080 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||
minutes % 1_440 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
||||
minutes % DAY_MINUTES == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES)
|
||||
minutes % 60 == 0 ->
|
||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* [reminderLeadTimeLabel] naming the date the lead counts from, for a task
|
||||
* that has both: "15 minutes before the start", "On the due date".
|
||||
*/
|
||||
@Composable
|
||||
fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String {
|
||||
@Composable
|
||||
fun plural(start: Int, due: Int, count: Int) =
|
||||
pluralStringResource(if (fromStart) start else due, count, count)
|
||||
return when {
|
||||
minutes <= 0 && allDay ->
|
||||
stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date)
|
||||
minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time)
|
||||
minutes % 10_080 == 0 ->
|
||||
plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080)
|
||||
minutes % DAY_MINUTES == 0 ->
|
||||
plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES)
|
||||
minutes % 60 == 0 ->
|
||||
plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60)
|
||||
else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes)
|
||||
}
|
||||
}
|
||||
|
||||
/** A lead's label, naming its date when [anchored]. */
|
||||
@Composable
|
||||
fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String =
|
||||
if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay)
|
||||
|
||||
/**
|
||||
* One of a task's own reminders in one line, naming its date when [anchored].
|
||||
* An all-day one names its day and the time it rings at — its own, or
|
||||
* [allDayMinuteOfDay] when it has none.
|
||||
*/
|
||||
@Composable
|
||||
fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String {
|
||||
if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored)
|
||||
val (days, minuteOfDay) = reminder.allDay
|
||||
return stringResource(
|
||||
R.string.reminder_at_time,
|
||||
reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored),
|
||||
formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current),
|
||||
)
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun reminderAnchorLabel(fromStart: Boolean): Int =
|
||||
if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R
|
||||
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
||||
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||
* a "None" row; [allDay] switches to day-scale presets and wording. Returns the
|
||||
* choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
|
||||
* a "None" row; [allDay] switches to day-scale presets and wording, and its
|
||||
* custom amounts to days and weeks. [header] sits above the rows, for whatever
|
||||
* else the caller's reminder carries. Returns the choice as a
|
||||
* [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderLeadPicker(
|
||||
@@ -59,6 +54,7 @@ fun ReminderLeadPicker(
|
||||
onDismiss: () -> Unit,
|
||||
allDay: Boolean = false,
|
||||
presets: List<Int> = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
// Agendula is single-reminder: an override carries a one-element list, so
|
||||
// take the single value for this single-select picker.
|
||||
@@ -68,7 +64,7 @@ fun ReminderLeadPicker(
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(ReminderOverride.Inherit)
|
||||
@@ -78,6 +74,7 @@ fun ReminderLeadPicker(
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
@@ -112,6 +109,8 @@ fun ReminderLeadPicker(
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
units = reminderUnits(allDay),
|
||||
allDay = allDay,
|
||||
onConfirm = { minutes ->
|
||||
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||
onDismiss()
|
||||
@@ -129,7 +128,9 @@ fun ReminderLeadPicker(
|
||||
* Clearing the last time falls back to "Use default" on a per-list picker and to
|
||||
* "No reminder" on the global one. Changes apply live; the user leaves via back.
|
||||
* [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second
|
||||
* line to each lead-time row. Ported from Calendula's ReminderDefaultPicker.
|
||||
* line to each lead-time row. A start / due toggle picks which date the rows
|
||||
* count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]).
|
||||
* Ported from Calendula's ReminderDefaultPicker.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -149,24 +150,27 @@ fun ReminderDefaultPicker(
|
||||
LaunchedEffect(selected) { if (!userEdited) current = selected }
|
||||
val inherits = current is ReminderOverride.Inherit
|
||||
val isNone = current is ReminderOverride.None
|
||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty()
|
||||
var fromStart by rememberSaveable { mutableStateOf(true) }
|
||||
val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes }
|
||||
// Custom values seen this session keep their row once unchecked.
|
||||
val seenCustom = remember { mutableSetOf<Int>() }
|
||||
seenCustom += selectedMinutes.filter { it !in presets }
|
||||
val rows = presets + seenCustom.sorted()
|
||||
val seenCustom = remember { mutableSetOf<ReminderLead>() }
|
||||
seenCustom += selectedLeads.filter { it.minutes !in presets }
|
||||
val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) }
|
||||
|
||||
fun apply(override: ReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||
fun emit(leads: List<ReminderLead>) = apply(reminderOverrideForLeads(leads, allowInherit))
|
||||
fun lead(minute: Int) = ReminderLead(minute, fromStart)
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute))
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
if (allowInherit) {
|
||||
@@ -186,6 +190,8 @@ fun ReminderDefaultPicker(
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
@@ -213,8 +219,10 @@ fun ReminderDefaultPicker(
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
units = reminderUnits(allDay),
|
||||
allDay = allDay,
|
||||
onConfirm = { minutes ->
|
||||
emit(selectedMinutes + minutes)
|
||||
emit(selectedLeads + lead(minutes))
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
@@ -223,57 +231,54 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which date a reminder counts back from. */
|
||||
@Composable
|
||||
fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
listOf(true, false).forEachIndexed { index, start ->
|
||||
SegmentedButton(
|
||||
selected = fromStart == start,
|
||||
onClick = { onChange(start) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, 2),
|
||||
label = { Text(stringResource(reminderAnchorLabel(start))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomReminderEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
units: List<ReminderUnit>,
|
||||
allDay: Boolean,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = unit == entry,
|
||||
onClick = { onUnitChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
enabled = amount != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = units.indexOf(unit),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The units a custom lead is entered in. An all-day reminder's time of day is
|
||||
* set on its own, so its lead is whole days: an hour count would read back as a
|
||||
* time of day.
|
||||
*/
|
||||
private fun reminderUnits(allDay: Boolean): List<ReminderUnit> =
|
||||
if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries
|
||||
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) {
|
||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
|
||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
@@ -66,7 +66,7 @@ import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||
import de.jeanlucmakiola.agendula.ui.common.linkified
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -198,6 +198,7 @@ fun TaskDetailScreen(
|
||||
detail = s.detail,
|
||||
inner = inner,
|
||||
reminders = s.reminders,
|
||||
allDayMinuteOfDay = s.allDayMinuteOfDay,
|
||||
onToggle = { viewModel.toggleComplete(s.detail.task) },
|
||||
onToggleSubtask = { viewModel.toggleComplete(it) },
|
||||
onAddSubtask = { viewModel.addSubtask(it) },
|
||||
@@ -251,6 +252,7 @@ private fun DetailBody(
|
||||
detail: TaskDetail,
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
reminders: List<TaskReminder>,
|
||||
allDayMinuteOfDay: Int,
|
||||
onToggle: () -> Unit,
|
||||
onToggleSubtask: (Task) -> Unit,
|
||||
onAddSubtask: (String) -> Unit,
|
||||
@@ -352,8 +354,12 @@ private fun DetailBody(
|
||||
DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) {
|
||||
reminders.forEach { reminder ->
|
||||
Text(
|
||||
if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore))
|
||||
else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay),
|
||||
taskReminderLabel(
|
||||
reminder,
|
||||
task.isAllDay,
|
||||
allDayMinuteOfDay,
|
||||
anchored = task.start != null && task.due != null,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -30,7 +35,9 @@ sealed interface TaskDetailUiState {
|
||||
data class Content(
|
||||
val detail: TaskDetail,
|
||||
/** The task's own reminders; empty when the list's or app default applies. */
|
||||
val reminders: List<de.jeanlucmakiola.agendula.data.tasks.TaskReminder> = emptyList(),
|
||||
val reminders: List<de.jeanlucmakiola.agendula.domain.TaskReminder> = emptyList(),
|
||||
/** The time an all-day reminder without one of its own rings at. */
|
||||
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||
) : TaskDetailUiState
|
||||
}
|
||||
|
||||
@@ -38,6 +45,7 @@ sealed interface TaskDetailUiState {
|
||||
@HiltViewModel
|
||||
class TaskDetailViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor(
|
||||
val state: StateFlow<TaskDetailUiState> =
|
||||
target.filterNotNull()
|
||||
.flatMapLatest { (id, occurrence) ->
|
||||
val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged()
|
||||
repository.taskDetail(id, occurrence)
|
||||
.map<TaskDetail?, TaskDetailUiState> { detail ->
|
||||
if (detail == null) {
|
||||
.map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } }
|
||||
.combine(allDayMinute) { loaded, minute ->
|
||||
if (loaded == null) {
|
||||
TaskDetailUiState.NotFound
|
||||
} else {
|
||||
val (detail, reminders) = loaded
|
||||
TaskDetailUiState.Content(
|
||||
detail = detail,
|
||||
reminders = repository.remindersFor(detail.task.taskId),
|
||||
reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)),
|
||||
allDayMinuteOfDay = minute,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle
|
||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||
import de.jeanlucmakiola.agendula.ui.common.icon
|
||||
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
|
||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
@@ -123,6 +126,8 @@ import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
@@ -263,6 +268,8 @@ private fun EditContent(
|
||||
// the title takes the target list's colour.
|
||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
// A reminder's anchor only says something when the task has both to choose from.
|
||||
val bothDates = state.start != null && state.due != null
|
||||
|
||||
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -559,23 +566,25 @@ private fun EditContent(
|
||||
Spacer(Modifier.height(gap))
|
||||
// One card per reminder, connected, the last row adding another.
|
||||
val rows = state.reminders.size + 1
|
||||
state.reminders.forEachIndexed { index, minutes ->
|
||||
state.reminders.forEachIndexed { index, reminder ->
|
||||
ReminderItemCard(
|
||||
position = positionOf(index, rows),
|
||||
title = reminderLeadTimeLabel(minutes, state.isAllDay),
|
||||
onRemove = { viewModel.removeReminder(minutes) },
|
||||
title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates),
|
||||
onRemove = { viewModel.removeReminder(reminder) },
|
||||
)
|
||||
}
|
||||
AddReminderCard(
|
||||
position = positionOf(state.reminders.size, rows),
|
||||
label = stringResource(
|
||||
if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add,
|
||||
// Only a set the user has not chosen still falls back to the default.
|
||||
if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first
|
||||
else R.string.edit_reminder_add,
|
||||
),
|
||||
onClick = { showReminderPicker = true },
|
||||
)
|
||||
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
|
||||
if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
FieldError(R.string.error_reminder_without_due)
|
||||
FieldError(R.string.error_reminder_without_date)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,18 +697,73 @@ private fun EditContent(
|
||||
}
|
||||
|
||||
if (showReminderPicker) {
|
||||
var fromStart by rememberSaveable { mutableStateOf(state.start != null) }
|
||||
var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) }
|
||||
var pickingTime by rememberSaveable { mutableStateOf(false) }
|
||||
ReminderLeadPicker(
|
||||
title = stringResource(R.string.edit_reminder_add),
|
||||
selected = ReminderOverride.Inherit,
|
||||
allowInherit = false,
|
||||
allowNone = false,
|
||||
allDay = state.isAllDay,
|
||||
header = if (bothDates || state.isAllDay) {
|
||||
{
|
||||
if (bothDates) {
|
||||
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
if (state.isAllDay) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_time),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Text(
|
||||
formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { pickingTime = true },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSelect = { choice ->
|
||||
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder)
|
||||
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes ->
|
||||
// A task with one date counts from that one, whatever the toggle said.
|
||||
val anchorStart = if (bothDates) fromStart else state.due == null
|
||||
viewModel.addReminder(
|
||||
if (state.isAllDay) {
|
||||
// The default time is left unnamed, so the reminder follows the setting.
|
||||
TaskReminder.allDay(
|
||||
daysBefore = minutes / DAY_MINUTES,
|
||||
minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay },
|
||||
fromStart = anchorStart,
|
||||
)
|
||||
} else {
|
||||
TaskReminder(minutes, fromStart = anchorStart)
|
||||
},
|
||||
)
|
||||
}
|
||||
showReminderPicker = false
|
||||
},
|
||||
onDismiss = { showReminderPicker = false },
|
||||
)
|
||||
if (pickingTime) {
|
||||
TimePickerAlert(
|
||||
initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60),
|
||||
onConfirm = {
|
||||
val picked = it.hour * 60 + it.minute
|
||||
// The default time stays unnamed; any other is one an all-day reminder can name.
|
||||
minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked)
|
||||
pickingTime = false
|
||||
},
|
||||
onDismiss = { pickingTime = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showRecurrencePicker) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||
@@ -16,6 +18,10 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StoredReminders
|
||||
import java.time.ZoneId
|
||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||
@@ -54,7 +60,13 @@ data class TaskEditUiState(
|
||||
val priority: Priority = Priority.NONE,
|
||||
val parentId: Long? = null,
|
||||
val percentComplete: Int? = null,
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val reminders: List<TaskReminder> = emptyList(),
|
||||
/** Whether the user added or removed a reminder; until then the defaults follow the form. */
|
||||
val remindersTouched: Boolean = false,
|
||||
/** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */
|
||||
val remindersChosen: Boolean = false,
|
||||
/** The time an all-day reminder without one of its own rings at. */
|
||||
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||
val location: String = "",
|
||||
val url: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
@@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor(
|
||||
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
|
||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||
|
||||
/** The settings as the form was bound, for the defaults an all-day switch swaps in. */
|
||||
private var settings: Settings = Settings()
|
||||
|
||||
/** The reminders before the last all-day switch, and what it made of them. */
|
||||
private var lastSwitch: Pair<List<TaskReminder>, List<TaskReminder>>? = null
|
||||
|
||||
/** The reminders the task was loaded with, and the dates it had then. */
|
||||
private var storedReminders: Set<TaskReminder> = emptySet()
|
||||
|
||||
/** [storedReminders] as the task's current kind, timed or all-day. */
|
||||
private var loadedReminders: List<TaskReminder> = emptyList()
|
||||
private var loadedStart: Instant? = null
|
||||
private var loadedDue: Instant? = null
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(
|
||||
presetListId: Long? = null,
|
||||
@@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
baselineLastModified = null
|
||||
viewModelScope.launch {
|
||||
val settings = settingsPrefs.settings.first()
|
||||
this@TaskEditViewModel.settings = settings
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||
@@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
loading = false,
|
||||
isNew = true,
|
||||
autofocusTitle = settings.autofocusTitle,
|
||||
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
|
||||
listId = defaultList,
|
||||
// A preset due is all-day, so a default start has to be too, or
|
||||
// today's start would land after a due of today.
|
||||
@@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
title = initialTitle.orEmpty(),
|
||||
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
||||
),
|
||||
).withDefaultReminders(),
|
||||
).also { baseline = it.toForm().copy(title = "") }
|
||||
}
|
||||
}
|
||||
@@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor(
|
||||
editingTaskId = taskId
|
||||
editingOccurrence = occurrenceStart
|
||||
viewModelScope.launch {
|
||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||
val settings = settingsPrefs.settings.first()
|
||||
this@TaskEditViewModel.settings = settings
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull()
|
||||
if (task == null) {
|
||||
@@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
baselineLastModified = task.lastModified
|
||||
loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct()
|
||||
storedReminders = loadedReminders.toSet()
|
||||
loadedStart = task.start
|
||||
loadedDue = task.due
|
||||
_state.value = withFields(
|
||||
TaskEditUiState(
|
||||
loading = false,
|
||||
@@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor(
|
||||
priority = task.priority,
|
||||
parentId = task.parentId,
|
||||
percentComplete = task.percentComplete,
|
||||
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
||||
reminders = loadedReminders
|
||||
.sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)),
|
||||
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
|
||||
location = task.location.orEmpty(),
|
||||
url = task.url.orEmpty(),
|
||||
tags = task.tags,
|
||||
@@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor(
|
||||
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
|
||||
fun onListChange(listId: Long) {
|
||||
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
|
||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
|
||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() }
|
||||
viewModelScope.launch {
|
||||
val candidates = loadParents(listId, selfId = editingTaskId)
|
||||
update { it.copy(parentCandidates = candidates) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
|
||||
/**
|
||||
* 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.
|
||||
* looks to the user like the time reset itself. The reminders are made
|
||||
* the new kind's here, so the editor shows what will be stored; switching
|
||||
* straight back gives the old ones back.
|
||||
*/
|
||||
fun onAllDayChange(value: Boolean) = update {
|
||||
it.copy(
|
||||
if (it.isAllDay == value) return@update it
|
||||
val switched = it.copy(
|
||||
isAllDay = value,
|
||||
start = it.start?.rebasedForAllDay(value),
|
||||
due = it.due?.rebasedForAllDay(value),
|
||||
)
|
||||
if (!it.remindersTouched) {
|
||||
loadedReminders = carried(loadedReminders, switched)
|
||||
return@update switched.withDefaultReminders()
|
||||
}
|
||||
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
|
||||
}
|
||||
|
||||
/** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */
|
||||
private fun carried(reminders: List<TaskReminder>, switched: TaskEditUiState): List<TaskReminder> {
|
||||
val previous = lastSwitch
|
||||
val carried = if (previous != null && previous.second == reminders) {
|
||||
previous.first
|
||||
} else {
|
||||
StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault())
|
||||
}
|
||||
lastSwitch = reminders to carried
|
||||
return carried
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminders the task will be saved with while the user has not
|
||||
* touched them: those it was loaded with, plus the defaults of the dates
|
||||
* it has gained — all of them, for a new task. Shown so they are noted
|
||||
* before saving rather than added unseen.
|
||||
*/
|
||||
private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState {
|
||||
if (remindersTouched) return this
|
||||
val reminders = StoredReminders.withDefaults(
|
||||
toForm().copy(reminders = loadedReminders),
|
||||
loadedStart,
|
||||
loadedDue,
|
||||
settings,
|
||||
nowMillis(),
|
||||
ZoneId.systemDefault(),
|
||||
).sortedForDisplay(this)
|
||||
val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder
|
||||
return copy(
|
||||
reminders = reminders,
|
||||
// A new task's, or defaults added to the ones it had: stored as shown.
|
||||
remindersChosen = isNew || reminders.toSet() != storedReminders,
|
||||
visibleFields = visible,
|
||||
hiddenFields = hiddenOf(visible),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
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) }
|
||||
fun addReminder(minutesBeforeDue: Int) = update {
|
||||
it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet())
|
||||
fun addReminder(reminder: TaskReminder) = update {
|
||||
it.copy(
|
||||
reminders = (it.reminders + reminder).distinct().sortedForDisplay(it),
|
||||
remindersTouched = true,
|
||||
remindersChosen = true,
|
||||
errors = emptySet(),
|
||||
)
|
||||
}
|
||||
fun removeReminder(reminder: TaskReminder) = update {
|
||||
it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true)
|
||||
}
|
||||
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
||||
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
||||
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
||||
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
||||
@@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The order the editor lists [state]'s reminders in. */
|
||||
private fun List<TaskReminder>.sortedForDisplay(state: TaskEditUiState): List<TaskReminder> =
|
||||
sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay))
|
||||
|
||||
/** Snapshot the editable state as a validated [TaskForm]. */
|
||||
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
title = title,
|
||||
@@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
parentId = parentId,
|
||||
percentComplete = percentComplete,
|
||||
reminders = reminders,
|
||||
remindersChosen = remindersChosen,
|
||||
location = location.ifBlank { null },
|
||||
url = url.ifBlank { null },
|
||||
tags = withDraft(tagDraft),
|
||||
|
||||
@@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents"
|
||||
@Composable
|
||||
fun ExportScreen(
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ExportViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -89,6 +90,8 @@ fun ExportScreen(
|
||||
title = stringResource(R.string.settings_export),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.export_hint),
|
||||
|
||||
@@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati
|
||||
fun ImportScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
predictiveBack: Boolean = false,
|
||||
visible: Boolean? = null,
|
||||
initialUri: Uri? = null,
|
||||
onOpenStorage: (() -> Unit)? = null,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
@@ -105,6 +107,8 @@ fun ImportScreen(
|
||||
title = stringResource(R.string.import_title),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
predictiveBack = predictiveBack,
|
||||
visible = visible,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.import_hint),
|
||||
|
||||
@@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf
|
||||
* source, which is the specific thing §3.2(a) requires us to point at.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LicencesScreen(onBack: () -> Unit) {
|
||||
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.licences_title),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.licences_intro),
|
||||
|
||||
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import kotlin.time.Instant
|
||||
@@ -27,6 +29,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
|
||||
/**
|
||||
* The single in-app back stack: lists → task list → detail / edit.
|
||||
@@ -37,6 +40,7 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
||||
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
|
||||
* unaware of the nav library.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AgendulaNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -70,19 +74,19 @@ fun AgendulaNavHost(
|
||||
onNavRequestConsumed()
|
||||
}
|
||||
|
||||
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Dest.LISTS,
|
||||
modifier = modifier,
|
||||
// Only the screen on top moves; the background is always held static.
|
||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
||||
// Back is the predictive-back peek: navigation-compose composes the
|
||||
// screen being revealed underneath and seeks popExit with the gesture, so
|
||||
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
||||
enterTransition = { fadeIn() },
|
||||
// Calendula's overlay motion. NavHost's own back handler seeks the exit
|
||||
// over the previous screen, so destinations don't add a preview of their own.
|
||||
enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
||||
popExitTransition = { overlayExit },
|
||||
) {
|
||||
composable(Dest.LISTS) {
|
||||
ReadableWidth {
|
||||
|
||||
@@ -34,6 +34,7 @@ internal fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
@@ -42,7 +43,12 @@ internal fun AppearanceScreen(
|
||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||
val locale = currentLocale()
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = stringResource(themeLabel(state.settings.themeMode)),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Backup & restore: the way into export and import, as its own entry (after Calendula). */
|
||||
@Composable
|
||||
internal fun BackupScreen(
|
||||
onOpenExport: () -> Unit,
|
||||
onOpenImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_export),
|
||||
summary = stringResource(R.string.settings_export_hint),
|
||||
position = Position.Top,
|
||||
onClick = onOpenExport,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.import_title),
|
||||
summary = stringResource(R.string.settings_import_hint),
|
||||
position = Position.Bottom,
|
||||
onClick = onOpenImport,
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
-19
@@ -41,6 +41,8 @@ import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
|
||||
@@ -48,7 +50,7 @@ import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings
|
||||
import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
@@ -57,16 +59,18 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import java.time.LocalTime
|
||||
|
||||
/** Whether due tasks notify at all, how far ahead, and which lists differ. */
|
||||
@Composable
|
||||
internal fun RemindersScreen(
|
||||
internal fun NotificationsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) }
|
||||
@@ -119,7 +123,12 @@ internal fun RemindersScreen(
|
||||
)
|
||||
val lists = state.lists.filter { it.id > 0L }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_reminders),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (state.settings.remindersEnabled && !notificationsAllowed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notifications_blocked),
|
||||
@@ -153,13 +162,13 @@ internal fun RemindersScreen(
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.settings.defaultReminderMinutes),
|
||||
summary = reminderChoiceLabel(state.settings.defaultReminders),
|
||||
position = Position.Middle,
|
||||
onClick = { showOffset = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true),
|
||||
summary = reminderChoiceLabel(state.settings.defaultAllDayReminders, allDay = true),
|
||||
position = Position.Middle,
|
||||
onClick = { showAllDay = true },
|
||||
)
|
||||
@@ -245,7 +254,7 @@ internal fun RemindersScreen(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = listOverrideSummary(
|
||||
state.settings.perListReminderOverride.reminderOverrideFor(list.id),
|
||||
state.settings.defaultReminderMinutes,
|
||||
state.settings.defaultReminders,
|
||||
allDay = false,
|
||||
),
|
||||
position = Position.Middle,
|
||||
@@ -255,7 +264,7 @@ internal fun RemindersScreen(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = listOverrideSummary(
|
||||
state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id),
|
||||
state.settings.defaultAllDayReminderMinutes,
|
||||
state.settings.defaultAllDayReminders,
|
||||
allDay = true,
|
||||
),
|
||||
position = Position.Middle,
|
||||
@@ -278,24 +287,24 @@ internal fun RemindersScreen(
|
||||
if (showOffset) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
selected = state.settings.defaultReminderMinutes.let {
|
||||
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
|
||||
selected = state.settings.defaultReminders.let {
|
||||
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
|
||||
},
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
|
||||
onSelect = { viewModel.setDefaultReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
|
||||
onDismiss = { showOffset = false },
|
||||
)
|
||||
}
|
||||
if (showAllDay) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
selected = state.settings.defaultAllDayReminderMinutes.let {
|
||||
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
|
||||
selected = state.settings.defaultAllDayReminders.let {
|
||||
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
|
||||
},
|
||||
allowInherit = false,
|
||||
allDay = true,
|
||||
leadTimeSummary = allDayFiresAt,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
|
||||
onSelect = { viewModel.setDefaultAllDayReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
|
||||
onDismiss = { showAllDay = false },
|
||||
)
|
||||
}
|
||||
@@ -350,19 +359,27 @@ private data class OverrideTarget(val listId: Long, val allDay: Boolean)
|
||||
|
||||
/** Row summary for a list: its override, or the inherited global default. */
|
||||
@Composable
|
||||
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<Int>, allDay: Boolean): String =
|
||||
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<ReminderLead>, allDay: Boolean): String =
|
||||
when (choice) {
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay))
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay)
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.leads, allDay)
|
||||
}
|
||||
|
||||
/** "30 minutes before, At due time", or "No reminder" for an empty set. */
|
||||
/**
|
||||
* "30 minutes before, At due time", or "No reminder" for an empty set. Each
|
||||
* names its date once any counts from the start.
|
||||
*/
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: List<Int>, allDay: Boolean = false): String =
|
||||
if (minutes.isEmpty()) stringResource(R.string.reminder_none)
|
||||
else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ")
|
||||
private fun reminderChoiceLabel(leads: List<ReminderLead>, allDay: Boolean = false): String {
|
||||
if (leads.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
val reminders = leads.map { it.asTaskReminder() }.sortedWith(TaskReminderOrder)
|
||||
val anchored = reminders.any { it.fromStart }
|
||||
return reminders.map { reminder ->
|
||||
reminderLabel(reminder.minutesBefore, allDay, reminder.fromStart, anchored)
|
||||
}.joinToString(", ")
|
||||
}
|
||||
|
||||
/** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */
|
||||
private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
@@ -1,11 +1,8 @@
|
||||
package de.jeanlucmakiola.agendula.ui.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -25,6 +23,7 @@ import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.rounded.CloudSync
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -50,20 +50,22 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
internal enum class SettingsSection {
|
||||
Appearance,
|
||||
TaskForm,
|
||||
Reminders,
|
||||
Notifications,
|
||||
Storage,
|
||||
Backup,
|
||||
Export,
|
||||
Import,
|
||||
Accounts,
|
||||
@@ -72,10 +74,10 @@ internal enum class SettingsSection {
|
||||
Licences,
|
||||
;
|
||||
|
||||
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */
|
||||
/** The section that stays composed underneath: Backup under export and import, Accounts under its screens. */
|
||||
val parent: SettingsSection?
|
||||
get() = when (this) {
|
||||
Export, Import -> Storage
|
||||
Export, Import -> Backup
|
||||
AddAccount, Account -> Accounts
|
||||
else -> null
|
||||
}
|
||||
@@ -88,8 +90,9 @@ internal enum class SettingsSection {
|
||||
* A full-screen destination; [onBack] pops it. [initialSection] names a
|
||||
* sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]).
|
||||
*
|
||||
* Every sub-screen is its own file. This one owns the hub, the section enum and
|
||||
* the back rules between them, and nothing else.
|
||||
* Every sub-screen is its own file and handles its own back through the
|
||||
* predictive-back preview. This one owns the hub, the section enum and the
|
||||
* routing between them, and nothing else.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -106,10 +109,8 @@ fun SettingsScreen(
|
||||
else SettingsSection.entries.firstOrNull { it.name == initialSection },
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = section?.parent }
|
||||
// Import's "open storage" comes back to Import rather than the hub.
|
||||
var storageReturn by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -118,34 +119,58 @@ fun SettingsScreen(
|
||||
) {
|
||||
SettingsHub(onBack = onBack, onOpenSection = { section = it })
|
||||
|
||||
SlideInSection(visible = section == SettingsSection.Appearance) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
val appearance = section == SettingsSection.Appearance
|
||||
SlideInSection(visible = appearance) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.TaskForm) {
|
||||
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
val taskForm = section == SettingsSection.TaskForm
|
||||
SlideInSection(visible = taskForm) {
|
||||
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
val notifications = section == SettingsSection.Notifications
|
||||
SlideInSection(visible = notifications) {
|
||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = notifications)
|
||||
}
|
||||
// Storage stays composed under Export, so the deeper screen slides over it.
|
||||
val storageOpen = section == SettingsSection.Storage ||
|
||||
section?.parent == SettingsSection.Storage
|
||||
SlideInSection(visible = storageOpen) {
|
||||
val storage = section == SettingsSection.Storage
|
||||
SlideInSection(visible = storage) {
|
||||
StorageScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = {
|
||||
section = storageReturn
|
||||
storageReturn = null
|
||||
},
|
||||
visible = storage,
|
||||
)
|
||||
}
|
||||
// Backup stays composed under export and import, so the deeper screen slides over it.
|
||||
val backup = section == SettingsSection.Backup || section?.parent == SettingsSection.Backup
|
||||
SlideInSection(visible = backup) {
|
||||
BackupScreen(
|
||||
onOpenExport = { section = SettingsSection.Export },
|
||||
onOpenImport = { section = SettingsSection.Import },
|
||||
onBack = { section = null },
|
||||
visible = backup,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Export) {
|
||||
ExportScreen(onBack = { section = SettingsSection.Storage })
|
||||
val exportOpen = section == SettingsSection.Export
|
||||
SlideInSection(visible = exportOpen) {
|
||||
ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Import) {
|
||||
ImportScreen(onBack = { section = SettingsSection.Storage })
|
||||
val importOpen = section == SettingsSection.Import
|
||||
SlideInSection(visible = importOpen) {
|
||||
ImportScreen(
|
||||
onBack = { section = SettingsSection.Backup },
|
||||
onOpenStorage = {
|
||||
storageReturn = SettingsSection.Import
|
||||
section = SettingsSection.Storage
|
||||
},
|
||||
predictiveBack = true,
|
||||
visible = importOpen,
|
||||
)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Licences) {
|
||||
LicencesScreen(onBack = { section = null })
|
||||
val licences = section == SettingsSection.Licences
|
||||
SlideInSection(visible = licences) {
|
||||
LicencesScreen(onBack = { section = null }, visible = licences)
|
||||
}
|
||||
AccountSections(
|
||||
section = section,
|
||||
@@ -155,12 +180,18 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-screen layered over the hub, with Calendula's overlay motion: it slides
|
||||
* in on the motion scheme's spring and leaves from its predictive-back preview.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally { it } + fadeIn(),
|
||||
exit = slideOutHorizontally { it } + fadeOut(),
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@@ -208,7 +239,7 @@ private fun SettingsHub(
|
||||
summary = stringResource(R.string.settings_reminders_subtitle),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||
onClick = { onOpenSection(SettingsSection.Notifications) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -225,14 +256,41 @@ private fun SettingsHub(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_storage),
|
||||
summary = stringResource(R.string.settings_storage_subtitle),
|
||||
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
|
||||
position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top,
|
||||
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.Storage) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) },
|
||||
onClick = { onOpenSection(SettingsSection.Backup) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_app))
|
||||
LanguageRow(position = Position.Top)
|
||||
LanguagePickerRow(
|
||||
position = Position.Top,
|
||||
title = stringResource(R.string.settings_language),
|
||||
autoLabel = stringResource(R.string.settings_language_auto),
|
||||
localesConfig = R.xml.locales_config,
|
||||
predictiveBack = true,
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val context = LocalContext.current
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
summary = stringResource(R.string.settings_translate_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, translateUrl) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
},
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
|
||||
)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
// Source, licence and privacy sit at the bottom as reference material
|
||||
@@ -316,64 +374,6 @@ private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
|
||||
* picker it opens carries a "Help translate" header, and inviting contributions
|
||||
* right where a user goes looking for their language is app-specific framing,
|
||||
* not a family primitive. Everything else matches that recipe.
|
||||
*/
|
||||
@Composable
|
||||
private fun LanguageRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||
// row updates instantly even before the recreation lands.
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember(supported) { listOf<String?>(null) + supported }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
summary = languageLabel(current),
|
||||
position = position,
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
|
||||
onClick = { showDialog = true },
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_language),
|
||||
predictiveBack = true,
|
||||
options = options,
|
||||
selected = current,
|
||||
label = { languageLabel(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
summary = stringResource(R.string.settings_translate_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, translateUrl) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
/**
|
||||
* Opens the project's issue tracker to report a problem. If a crash report was
|
||||
* captured (and not yet sent), it surfaces that report first via the same
|
||||
|
||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
|
||||
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -219,12 +220,12 @@ class SettingsViewModel @Inject constructor(
|
||||
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) }
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
|
||||
prefs.setDefaultReminderMinutes(minutes)
|
||||
fun setDefaultReminders(leads: List<ReminderLead>) = viewModelScope.launch {
|
||||
prefs.setDefaultReminders(leads)
|
||||
resyncReminders()
|
||||
}
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
|
||||
prefs.setDefaultAllDayReminderMinutes(minutes)
|
||||
fun setDefaultAllDayReminders(leads: List<ReminderLead>) = viewModelScope.launch {
|
||||
prefs.setDefaultAllDayReminders(leads)
|
||||
resyncReminders()
|
||||
}
|
||||
fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch {
|
||||
|
||||
@@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
@Composable
|
||||
internal fun StorageScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onOpenExport: () -> Unit,
|
||||
onOpenImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val storage by viewModel.storage.collectAsStateWithLifecycle()
|
||||
@@ -85,11 +84,17 @@ internal fun StorageScreen(
|
||||
// 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) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_storage),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
val canCopy = storage?.canCopyFromExternal == true
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_task_store),
|
||||
summary = storage?.let { storeLabel(it) },
|
||||
position = Position.Top,
|
||||
position = if (canCopy) Position.Top else Position.Alone,
|
||||
// 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 {
|
||||
@@ -99,31 +104,19 @@ internal fun StorageScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (storage?.canCopyFromExternal == true) {
|
||||
if (canCopy) {
|
||||
GroupedRow(
|
||||
title = stringResource(
|
||||
R.string.settings_copy_from_external,
|
||||
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
|
||||
),
|
||||
summary = stringResource(R.string.settings_copy_from_external_hint),
|
||||
position = Position.Middle,
|
||||
position = Position.Bottom,
|
||||
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
|
||||
// Inert while a copy is in flight; the receipt below says so.
|
||||
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_export),
|
||||
summary = stringResource(R.string.settings_export_hint),
|
||||
position = Position.Middle,
|
||||
onClick = onOpenExport,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.import_title),
|
||||
summary = stringResource(R.string.settings_import_hint),
|
||||
position = Position.Bottom,
|
||||
onClick = onOpenImport,
|
||||
)
|
||||
|
||||
TransferStatus(transfer)
|
||||
|
||||
|
||||
@@ -42,10 +42,16 @@ internal fun TaskFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
var showDefaultList by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_task_form),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val fields = TaskFormField.entries
|
||||
|
||||
@@ -464,14 +464,12 @@
|
||||
<string name="recurring_scope_series">جميع المهام في السلسلة</string>
|
||||
<string name="error_blank_title">أدخل عنوانًا.</string>
|
||||
<string name="error_no_list">اختر قائمة.</string>
|
||||
<string name="error_reminder_without_due">عيِّن موعد استحقاق لاستخدام التذكير.</string>
|
||||
<string name="error_recurrence_without_date">تحتاج مهمة متكررة لموعد بدء أو استحقاق.</string>
|
||||
<string name="edit_recurrence_none">لا يتكرر</string>
|
||||
<string name="edit_tag_remove">إزالة الوسم %1$s</string>
|
||||
<string name="edit_discard_body">لم يتم حفظ تغييراتك لهذه المهمة.</string>
|
||||
<string name="edit_scope_title">حفظ التغييرات لمهمة متكررة</string>
|
||||
<string name="edit_reminder_add_first">أضف تذكيرًا (بخلاف ذلك الافتراضي ينطبق)</string>
|
||||
<string name="detail_reminder_before_start">%1$s (قبل البدء)</string>
|
||||
<string name="subtask_progress">%1$d / %2$d</string>
|
||||
<string name="detail_delete_body">سيتم حذف هذه المهمة.</string>
|
||||
<plurals name="detail_delete_body_subtasks">
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
<string name="error_blank_title">Gebe einen Titel ein.</string>
|
||||
<string name="error_no_list">Wähle eine Liste.</string>
|
||||
<string name="error_due_before_start">Fälligkeit darf nicht vor dem Start liegen.</string>
|
||||
<string name="error_reminder_without_due">Setze ein Fälligkeits-Datum für Erinnerungen.</string>
|
||||
<string name="lists_header">Listen</string>
|
||||
<string name="new_task">Neue Aufgabe</string>
|
||||
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
<string name="error_blank_title">Veuillez saisir un titre.</string>
|
||||
<string name="error_no_list">Sélectionnez une liste.</string>
|
||||
<string name="error_due_before_start">La date d\'échéance ne peut pas être antérieure au début.</string>
|
||||
<string name="error_reminder_without_due">Fixez une date d\'échéance pour pourvoir définir un rappel.</string>
|
||||
<string name="lists_header">Listes</string>
|
||||
<string name="new_task">Nouvelle tâche</string>
|
||||
<string name="lists_failure">Impossible de lire vos tâches.</string>
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
<string name="error_blank_title">Insira um título.</string>
|
||||
<string name="error_no_list">Escolha uma lista.</string>
|
||||
<string name="error_due_before_start">Prazo não pode ser antes do início.</string>
|
||||
<string name="error_reminder_without_due">Ajuste um prazo para usar um lembrete.</string>
|
||||
<string name="lists_header">Listas</string>
|
||||
<string name="new_task">Nova tarefa</string>
|
||||
<string name="lists_failure">Não foi possível ler suas tarefas.</string>
|
||||
|
||||
@@ -122,8 +122,8 @@
|
||||
<string name="reminder_unit_days">Days</string>
|
||||
<string name="reminder_unit_weeks">Weeks</string>
|
||||
<string name="settings_list_reminders_title">Per-list reminders</string>
|
||||
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
|
||||
<string name="settings_list_notifications">Notifications</string>
|
||||
<string name="settings_list_reminders_hint">Override the default per list — separately for timed and all-day tasks. A list can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_list_notifications">Sound & vibration</string>
|
||||
<string name="settings_list_notifications_hint">Sound, vibration and importance for this list’s reminders</string>
|
||||
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<string name="error_blank_title">Enter a title.</string>
|
||||
<string name="error_no_list">Choose a list.</string>
|
||||
<string name="error_due_before_start">Due can\'t be before the start.</string>
|
||||
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
|
||||
<string name="error_reminder_without_date">Set a start or due date to use a reminder.</string>
|
||||
<string name="error_recurrence_without_date">A repeating task needs a start or due date.</string>
|
||||
<string name="edit_recurrence_label">Repeat</string>
|
||||
<string name="edit_recurrence_none">Does not repeat</string>
|
||||
@@ -199,7 +199,15 @@
|
||||
<string name="edit_reminder_add">Add reminder</string>
|
||||
<string name="edit_reminder_add_first">Add a reminder (otherwise the default applies)</string>
|
||||
<string name="edit_reminder_remove">Remove reminder</string>
|
||||
<string name="detail_reminder_before_start">%1$s (before start)</string>
|
||||
<string name="reminder_at_start">At start time</string>
|
||||
<string name="reminder_at_due_time">At due time</string>
|
||||
<string name="reminder_on_start_date">On the start date</string>
|
||||
<string name="reminder_on_due_date">On the due date</string>
|
||||
<!-- An all-day reminder's day and its time: "On the day at 09:00", "1 day before at 17:00" -->
|
||||
<string name="reminder_at_time">%1$s at %2$s</string>
|
||||
<string name="reminder_anchor_start">From start</string>
|
||||
<string name="reminder_anchor_due">From due date</string>
|
||||
<string name="reminder_time">Time</string>
|
||||
<string name="subtask_progress">%1$d / %2$d</string>
|
||||
<string name="progress_fraction_compact">%1$d/%2$d</string>
|
||||
<string name="detail_delete_title">Delete task?</string>
|
||||
@@ -359,7 +367,7 @@
|
||||
|
||||
<!-- First run: backup -->
|
||||
<string name="onboarding_backup_title">Keep a copy</string>
|
||||
<string name="onboarding_backup_body_later">Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Storage → Export.</string>
|
||||
<string name="onboarding_backup_body_later">Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Backup & restore → Export.</string>
|
||||
<string name="onboarding_backup_continue">Continue</string>
|
||||
|
||||
<!-- First run: quick add bar -->
|
||||
@@ -394,10 +402,10 @@
|
||||
<!-- Group of reference links at the foot of the settings hub. -->
|
||||
<string name="settings_group_about">About</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_appearance_subtitle">Theme, colour and time format</string>
|
||||
<string name="settings_section_task_form">Task form</string>
|
||||
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
|
||||
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
|
||||
<string name="settings_appearance_subtitle">Theme, colours, time format</string>
|
||||
<string name="settings_section_task_form">New task form</string>
|
||||
<string name="settings_task_form_subtitle">Default fields and behaviour</string>
|
||||
<string name="settings_reminders_subtitle">Reminders and delivery</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
|
||||
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
|
||||
<string name="settings_autofocus_title">Focus title on new task</string>
|
||||
@@ -442,13 +450,13 @@
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||
<string name="settings_section_reminders">Reminders</string>
|
||||
<string name="settings_reminders">Due reminders</string>
|
||||
<string name="settings_section_reminders">Notifications</string>
|
||||
<string name="settings_reminders">Task reminders</string>
|
||||
<string name="settings_reminders_hint">Notify me when a task is due</string>
|
||||
<string name="settings_notifications_blocked">Notifications are off</string>
|
||||
<string name="settings_notifications_blocked_hint">Reminders can\u2019t reach you. Tap to allow notifications for Agendula.</string>
|
||||
<string name="settings_default_reminder">When to remind</string>
|
||||
<string name="settings_allday_reminder_time">Time for all-day reminders</string>
|
||||
<string name="settings_default_reminder">Default reminder</string>
|
||||
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||
<string name="settings_allday_reminder_time_summary">All-day tasks count back from %1$s</string>
|
||||
<string name="settings_default_reminder_allday">All-day tasks</string>
|
||||
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
|
||||
@@ -472,9 +480,11 @@
|
||||
|
||||
<!-- Storage and export -->
|
||||
<string name="settings_section_storage">Storage</string>
|
||||
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
|
||||
<string name="settings_storage_subtitle">Where your tasks are kept</string>
|
||||
<string name="settings_section_backup">Backup & restore</string>
|
||||
<string name="settings_backup_subtitle">Export and import</string>
|
||||
<string name="settings_task_store">Task store</string>
|
||||
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</string>
|
||||
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them from Backup & restore.</string>
|
||||
<string name="settings_store_own">On this device</string>
|
||||
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
|
||||
<string name="settings_store_external">Another task app</string>
|
||||
@@ -594,6 +604,38 @@
|
||||
<item quantity="one">%1$d week before</item>
|
||||
<item quantity="other">%1$d weeks before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_minutes_before_start">
|
||||
<item quantity="one">%1$d minute before the start</item>
|
||||
<item quantity="other">%1$d minutes before the start</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_start">
|
||||
<item quantity="one">%1$d hour before the start</item>
|
||||
<item quantity="other">%1$d hours before the start</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days_before_start">
|
||||
<item quantity="one">%1$d day before the start</item>
|
||||
<item quantity="other">%1$d days before the start</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_start">
|
||||
<item quantity="one">%1$d week before the start</item>
|
||||
<item quantity="other">%1$d weeks before the start</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_minutes_before_due">
|
||||
<item quantity="one">%1$d minute before the due date</item>
|
||||
<item quantity="other">%1$d minutes before the due date</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_due">
|
||||
<item quantity="one">%1$d hour before the due date</item>
|
||||
<item quantity="other">%1$d hours before the due date</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days_before_due">
|
||||
<item quantity="one">%1$d day before the due date</item>
|
||||
<item quantity="other">%1$d days before the due date</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_due">
|
||||
<item quantity="one">%1$d week before the due date</item>
|
||||
<item quantity="other">%1$d weeks before the due date</item>
|
||||
</plurals>
|
||||
|
||||
<!-- CalDAV accounts -->
|
||||
<string name="settings_section_accounts">Accounts</string>
|
||||
@@ -854,4 +896,10 @@
|
||||
<string name="widget_lists_config_title">Lists to show</string>
|
||||
<string name="widget_week_label">Week</string>
|
||||
<string name="widget_week_description">A seven-day strip of task counts</string>
|
||||
<!-- Permission another app requests to read Agendula's tasks -->
|
||||
<string name="permission_read_tasks_label">read Agendula tasks</string>
|
||||
<string name="permission_read_tasks_description">Allows the app to read your task lists and tasks stored in Agendula, including their reminders and tags.</string>
|
||||
<!-- Permission another app requests to add, change or delete Agendula's tasks -->
|
||||
<string name="permission_write_tasks_label">add, change and delete Agendula tasks</string>
|
||||
<string name="permission_write_tasks_description">Allows the app to add, change, complete and delete tasks stored in Agendula, including their reminders and tags. It cannot change or delete task lists.</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.testTask
|
||||
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.asReminderLeads
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
@@ -38,6 +40,28 @@ class ReminderPlannerTest {
|
||||
assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a default from before the task existed does not ring`() {
|
||||
val created = Instant.fromEpochMilliseconds(millis(date.atTime(14, 0), berlin))
|
||||
val task = testTask(id = 1, due = allDayInstantOf(date), isAllDay = true, created = created)
|
||||
val plan = ReminderPlanner.plan(
|
||||
tasks = listOf(task),
|
||||
perTask = emptyMap(),
|
||||
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
|
||||
now = created.toEpochMilliseconds(),
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(plan).isEmpty()
|
||||
val own = ReminderPlanner.plan(
|
||||
tasks = listOf(task),
|
||||
perTask = mapOf(1L to listOf(TaskReminder(0))),
|
||||
settings = Settings(allDayReminderMinuteOfDay = 9 * 60),
|
||||
now = created.toEpochMilliseconds(),
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(own).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed reminder counts back from the instant itself`() {
|
||||
val due = Instant.fromEpochMilliseconds(1_000_000_000L)
|
||||
@@ -89,7 +113,7 @@ class ReminderPlannerTest {
|
||||
fun plan(perTask: Map<Long, List<TaskReminder>>) = ReminderPlanner.plan(
|
||||
tasks = listOf(testTask(id = 4, due = due)),
|
||||
perTask = perTask,
|
||||
settings = Settings(defaultReminderMinutes = listOf(15)),
|
||||
settings = Settings(defaultReminders = listOf(15).asReminderLeads()),
|
||||
now = now,
|
||||
zone = berlin,
|
||||
)
|
||||
@@ -120,8 +144,8 @@ class ReminderPlannerTest {
|
||||
tasks = listOf(task),
|
||||
perTask = emptyMap(),
|
||||
settings = Settings(
|
||||
defaultReminderMinutes = listOf(5),
|
||||
defaultAllDayReminderMinutes = listOf(0, 24 * 60),
|
||||
defaultReminders = listOf(5).asReminderLeads(),
|
||||
defaultAllDayReminders = listOf(0, 24 * 60).asReminderLeads(),
|
||||
),
|
||||
now = millis(date.minusDays(2).atTime(12, 0), berlin),
|
||||
zone = berlin,
|
||||
@@ -205,7 +229,7 @@ class ReminderPlannerTest {
|
||||
val plan = ReminderPlanner.plan(
|
||||
tasks = listOf(task),
|
||||
perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))),
|
||||
settings = Settings(defaultReminderMinutes = listOf(15)),
|
||||
settings = Settings(defaultReminders = listOf(15).asReminderLeads()),
|
||||
now = millis(date.atTime(0, 0), berlin),
|
||||
zone = berlin,
|
||||
)
|
||||
@@ -222,7 +246,7 @@ class ReminderPlannerTest {
|
||||
val plan = ReminderPlanner.plan(
|
||||
tasks = listOf(task),
|
||||
perTask = emptyMap(),
|
||||
settings = Settings(defaultReminderMinutes = listOf(5, 30)),
|
||||
settings = Settings(defaultReminders = listOf(5, 30).asReminderLeads()),
|
||||
now = millis(date.atTime(0, 0), berlin),
|
||||
zone = berlin,
|
||||
)
|
||||
@@ -239,4 +263,65 @@ class ReminderPlannerTest {
|
||||
val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin)
|
||||
assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder with a time of its own rings at it`() {
|
||||
val nine = TaskReminder.allDay(daysBefore = 0, minuteOfDay = 9 * 60)
|
||||
val fivePmBefore = TaskReminder.allDay(daysBefore = 1, minuteOfDay = 17 * 60)
|
||||
assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, nine.minutesBefore, 7 * 60, berlin))
|
||||
.isEqualTo(millis(date.atTime(9, 0), berlin))
|
||||
assertThat(ReminderPlanner.triggerAt(allDayInstantOf(date), true, fivePmBefore.minutesBefore, 7 * 60, berlin))
|
||||
.isEqualTo(millis(date.minusDays(1).atTime(17, 0), berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `several times on one all-day task each ring`() {
|
||||
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true)
|
||||
val reminders = listOf(TaskReminder.allDay(0, 9 * 60), TaskReminder.allDay(0, 17 * 60))
|
||||
val plan = ReminderPlanner.plan(listOf(task), mapOf(6L to reminders), Settings(), millis(date.atTime(0, 0), berlin), berlin)
|
||||
assertThat(plan).containsExactly(
|
||||
ScheduledReminder(6, millis(date.atTime(9, 0), berlin)),
|
||||
ScheduledReminder(6, millis(date.atTime(17, 0), berlin)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a default counts only from a date the task has`() {
|
||||
val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin))
|
||||
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
|
||||
val both = testTask(id = 7, due = due).copy(start = start)
|
||||
val dueOnly = testTask(id = 8, due = due)
|
||||
val startOnly = testTask(id = 9).copy(start = start)
|
||||
val defaults = listOf(TaskReminder(60, fromStart = true), TaskReminder(0)).map { it.asReminderLead() }
|
||||
val now = millis(date.atTime(0, 0), berlin)
|
||||
|
||||
val plan = ReminderPlanner.plan(
|
||||
listOf(both, dueOnly, startOnly),
|
||||
emptyMap(),
|
||||
Settings(defaultReminders = defaults),
|
||||
now,
|
||||
berlin,
|
||||
)
|
||||
|
||||
assertThat(plan).containsExactly(
|
||||
ScheduledReminder(7, millis(date.atTime(8, 0), berlin)),
|
||||
ScheduledReminder(7, due.toEpochMilliseconds()),
|
||||
ScheduledReminder(8, due.toEpochMilliseconds()),
|
||||
ScheduledReminder(9, millis(date.atTime(8, 0), berlin)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task's own reminder counts from the other date when its own is missing`() {
|
||||
val start = Instant.fromEpochMilliseconds(millis(date.atTime(9, 0), berlin))
|
||||
val task = testTask(id = 10).copy(start = start)
|
||||
val plan = ReminderPlanner.plan(
|
||||
listOf(task),
|
||||
mapOf(10L to listOf(TaskReminder(15))),
|
||||
Settings(),
|
||||
millis(date.atTime(0, 0), berlin),
|
||||
berlin,
|
||||
)
|
||||
assertThat(plan).containsExactly(ScheduledReminder(10, millis(date.atTime(8, 45), berlin)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||
import de.jeanlucmakiola.agendula.domain.testTask
|
||||
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||
import de.jeanlucmakiola.floret.reminders.asReminderLeads
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
class StoredRemindersTest {
|
||||
|
||||
private val zone = ZoneId.of("Europe/Berlin")
|
||||
private val now = Instant.parse("2026-10-01T10:00:00Z")
|
||||
private val dayBeforeStart = TaskReminder(1_440, fromStart = true)
|
||||
private val dayBeforeDue = TaskReminder(1_440)
|
||||
private val hourBeforeDue = TaskReminder(60)
|
||||
private val settings = Settings(
|
||||
defaultReminders = listOf(dayBeforeStart, dayBeforeDue, hourBeforeDue).map { it.asReminderLead() },
|
||||
)
|
||||
|
||||
private fun stored(form: TaskForm, current: de.jeanlucmakiola.agendula.domain.Task? = null, with: Settings = settings) =
|
||||
StoredReminders.of(form, current, with, now.toEpochMilliseconds(), zone)
|
||||
|
||||
@Test
|
||||
fun `a new task takes the defaults for the dates it has`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days)
|
||||
assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
|
||||
assertThat(stored(form.copy(start = null))).containsExactly(dayBeforeDue, hourBeforeDue)
|
||||
assertThat(stored(form.copy(due = null))).containsExactly(dayBeforeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a default already past when the task gets it is left out`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days)
|
||||
assertThat(stored(form)).containsExactly(dayBeforeDue, hourBeforeDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring task keeps it for the occurrences to come`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now, due = now + 5.days, rrule = "FREQ=DAILY")
|
||||
assertThat(stored(form)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
|
||||
}
|
||||
|
||||
private fun switched(reminders: List<TaskReminder>, form: TaskForm, with: Settings = settings) =
|
||||
StoredReminders.switched(reminders, form, with, now.toEpochMilliseconds(), zone)
|
||||
|
||||
@Test
|
||||
fun `a date the task gains brings its defaults`() {
|
||||
val current = testTask(start = now + 2.days)
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(dayBeforeStart))
|
||||
assertThat(stored(form, current)).containsExactly(dayBeforeStart, dayBeforeDue, hourBeforeDue)
|
||||
val due = testTask(due = now + 5.days)
|
||||
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), due)).containsExactly(hourBeforeDue, dayBeforeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gained date the form already counts from keeps the form`() {
|
||||
val current = testTask(start = now + 2.days)
|
||||
val ownDue = TaskReminder(30)
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, reminders = listOf(ownDue))
|
||||
assertThat(stored(form, current)).containsExactly(ownDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminders the editor chose are stored as they are`() {
|
||||
val form = TaskForm(title = "t", listId = 1, start = now + 2.days, due = now + 5.days, remindersChosen = true)
|
||||
assertThat(stored(form)).isEmpty()
|
||||
assertThat(stored(form.copy(reminders = listOf(hourBeforeDue)), testTask(due = now + 5.days)))
|
||||
.containsExactly(hourBeforeDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit that keeps its dates keeps the form`() {
|
||||
val current = testTask(due = now + 5.days)
|
||||
val form = TaskForm(title = "t", listId = 1, due = now + 6.days)
|
||||
assertThat(stored(form, current)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a default from before anchors existed swaps when the task turns all-day`() {
|
||||
val legacy = Settings(defaultReminders = listOf(15).asReminderLeads(), defaultAllDayReminders = listOf(0).asReminderLeads())
|
||||
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
|
||||
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
|
||||
assertThat(switched(listOf(TaskReminder(15)), form, legacy)).containsExactly(TaskReminder(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the whole default swaps`() {
|
||||
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
|
||||
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
|
||||
assertThat(switched(listOf(dayBeforeDue), form)).containsExactly(dayBeforeDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder of its own turns into whole days on an all-day task`() {
|
||||
val due = allDayInstantOf(LocalDate.of(2026, 10, 3))
|
||||
val form = TaskForm(title = "t", listId = 1, due = due, isAllDay = true)
|
||||
assertThat(switched(listOf(TaskReminder(20 * 60)), form)).containsExactly(TaskReminder(1_440))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a time of day is dropped when the task turns timed`() {
|
||||
val form = TaskForm(title = "t", listId = 1, due = now + 2.days + 3.hours)
|
||||
val ownTime = TaskReminder.allDay(0, 17 * 60)
|
||||
assertThat(switched(listOf(ownTime, TaskReminder(2_880)), form)).containsExactly(TaskReminder(2_880))
|
||||
assertThat(switched(listOf(ownTime), form)).containsExactly(dayBeforeDue, hourBeforeDue)
|
||||
}
|
||||
}
|
||||
@@ -131,17 +131,23 @@ class AlarmCodecTest {
|
||||
inner class AllDay {
|
||||
|
||||
@Test
|
||||
fun `a relative trigger rounds to whole days`() {
|
||||
fun `a relative trigger counts back from the date's midnight`() {
|
||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-P1D"), allDay))
|
||||
.isEqualTo(lead(24 * 60))
|
||||
// 09:00 the day before.
|
||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:-PT15H"), allDay))
|
||||
.isEqualTo(lead(24 * 60))
|
||||
.isEqualTo(lead(15 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a time on the day itself is "on the day", not an after-due alarm`() {
|
||||
fun `a time on the day itself is a time of day, not an after-due alarm`() {
|
||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT9H"), allDay))
|
||||
.isEqualTo(lead(0))
|
||||
.isEqualTo(lead(-9 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trigger after the day is over is not claimed`() {
|
||||
assertThat(AlarmCodec.resolve(alarm("ACTION:DISPLAY", "TRIGGER;RELATED=END:PT24H"), allDay)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,6 +164,14 @@ class AlarmCodecTest {
|
||||
val onTheDay = row.copy(minutesBefore = 0)
|
||||
assertThat(AlarmCodec.toVAlarm(onTheDay, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT0S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an authored all-day alarm with a time of its own is written from midnight`() {
|
||||
val nine = TaskAlarmEntity(taskId = 1, minutesBefore = -9 * 60, uid = "a")
|
||||
assertThat(AlarmCodec.toVAlarm(nine, allDay, due)?.property("TRIGGER")?.value).isEqualTo("PT9H")
|
||||
val fivePmBefore = nine.copy(minutesBefore = 7 * 60)
|
||||
assertThat(AlarmCodec.toVAlarm(fivePmBefore, allDay, due)?.property("TRIGGER")?.value).isEqualTo("-PT7H")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -216,11 +230,41 @@ class AlarmCodecTest {
|
||||
assertThat(trigger?.value).isEqualTo("-PT10M")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a before-due row on a task without a due goes out before start`() {
|
||||
val row = TaskAlarmEntity(taskId = 1, minutesBefore = 10, uid = "a")
|
||||
val trigger = AlarmCodec.toVAlarm(row, withStart.copy(due = null), earlier)?.property("TRIGGER")
|
||||
assertThat(trigger?.param("RELATED")).isEqualTo("START")
|
||||
assertThat(trigger?.value).isEqualTo("-PT10M")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day row kept in whole days by an earlier build keeps the original's time`() {
|
||||
val row = TaskAlarmEntity(
|
||||
taskId = 1,
|
||||
minutesBefore = 0,
|
||||
uid = "a",
|
||||
raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n",
|
||||
)
|
||||
assertThat(AlarmCodec.toVAlarm(row, allDay, earlier)?.property("TRIGGER")?.value).isEqualTo("PT9H")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wire lead is what the row reads back as`() {
|
||||
val fifteen = TaskAlarmEntity(taskId = 1, minutesBefore = 15, uid = "a")
|
||||
assertThat(AlarmCodec.wireLead(fifteen, allDay)).isEqualTo(lead(0))
|
||||
assertThat(AlarmCodec.wireLead(fifteen, timed)).isEqualTo(lead(15))
|
||||
val afterDue = TaskAlarmEntity(taskId = 1, minutesBefore = -15, uid = "a")
|
||||
assertThat(AlarmCodec.wireLead(afterDue, timed)).isEqualTo(lead(0))
|
||||
assertThat(AlarmCodec.wireLead(afterDue, allDay)).isEqualTo(lead(-15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row rounded by an earlier build does not claim the original's time as its own`() {
|
||||
val row = TaskAlarmEntity(
|
||||
taskId = 1,
|
||||
minutesBefore = 0,
|
||||
uid = "a",
|
||||
raw = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:PT9H\r\nEND:VALARM\r\n",
|
||||
)
|
||||
assertThat(AlarmCodec.wireLead(row, allDay)).isEqualTo(lead(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ProviderIdsTest {
|
||||
|
||||
@Test
|
||||
fun `an instance id gives back its series`() {
|
||||
val id = ProviderIds.instance(1234, Instant.parse("2026-10-15T09:00:00Z"))
|
||||
|
||||
assertThat(ProviderIds.instanceSeries(id)).isEqualTo(1234L)
|
||||
assertThat(ProviderIds.instanceSeconds(id)).isEqualTo(Instant.parse("2026-10-15T09:00:00Z").epochSeconds)
|
||||
assertThat(id).isGreaterThan(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain task's instance has no time`() {
|
||||
assertThat(ProviderIds.instanceSeconds(ProviderIds.instance(7, null))).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrences of one series and of different series never share an id`() {
|
||||
val at = Instant.parse("2026-10-15T09:00:00Z")
|
||||
|
||||
val ids = listOf(
|
||||
ProviderIds.instance(1, at),
|
||||
ProviderIds.instance(1, Instant.fromEpochSeconds(at.epochSeconds + 1)),
|
||||
ProviderIds.instance(2, at),
|
||||
ProviderIds.instance(1, null),
|
||||
)
|
||||
|
||||
assertThat(ids).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarm ids are even and tag ids odd`() {
|
||||
val alarm = ProviderIds.alarm(42)
|
||||
val tags = ProviderIds.categories(42, listOf("a", "b"))
|
||||
|
||||
assertThat(ProviderIds.isAlarm(alarm)).isTrue()
|
||||
assertThat(ProviderIds.alarmRow(alarm)).isEqualTo(42L)
|
||||
tags.values.forEach {
|
||||
assertThat(ProviderIds.isAlarm(it)).isFalse()
|
||||
assertThat(ProviderIds.categoryTask(it)).isEqualTo(42L)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tag's id depends on its task and spelling only`() {
|
||||
val alone = ProviderIds.categories(5, listOf("home"))
|
||||
val among = ProviderIds.categories(5, listOf("work", "home", "errand"))
|
||||
|
||||
assertThat(among["home"]).isEqualTo(alone["home"])
|
||||
assertThat(ProviderIds.categories(6, listOf("home"))["home"]).isNotEqualTo(alone["home"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two spellings with one hash still get two ids`() {
|
||||
// "Aa" and "BB" share String.hashCode().
|
||||
val ids = ProviderIds.categories(5, listOf("Aa", "BB"))
|
||||
|
||||
assertThat(ids.values).containsNoDuplicates()
|
||||
assertThat(ids.values.map(ProviderIds::categoryTask)).containsExactly(5L, 5L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `colliding tags keep their ids whatever their order`() {
|
||||
val hash = { _: String -> 7L }
|
||||
|
||||
assertThat(ProviderIds.categories(5, listOf("b", "a"), hash))
|
||||
.isEqualTo(ProviderIds.categories(5, listOf("a", "b"), hash))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `colliding tags stay inside their task's bits`() {
|
||||
val ids = ProviderIds.categories(5, listOf("a", "b", "c"), hash = { 0x7FFFFFFFL })
|
||||
|
||||
assertThat(ids.values).containsNoDuplicates()
|
||||
ids.values.forEach {
|
||||
assertThat(ProviderIds.categoryTask(it)).isEqualTo(5L)
|
||||
assertThat(ProviderIds.isAlarm(it)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags of neighbouring tasks never share an id`() {
|
||||
val names = (1..200).map { "tag$it" }
|
||||
|
||||
val ids = (1L..50L).flatMap { ProviderIds.categories(it, names).values }
|
||||
|
||||
assertThat(ids).containsNoDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||
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.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.TaskListRow
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ProviderSnapshotTest {
|
||||
|
||||
private val now = Instant.parse("2026-10-10T08:00:00Z")
|
||||
private val local = TaskListRow(TaskListEntity(id = 1, name = "Home", color = 0xFF00FF00.toInt()), null)
|
||||
private val synced = TaskListRow(TaskListEntity(id = 2, name = "Work", color = 0, accountId = 5), "Nextcloud")
|
||||
|
||||
private fun build(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity> = emptyList()) =
|
||||
ProviderSnapshot.build(
|
||||
lists = listOf(local, synced),
|
||||
tasks = tasks,
|
||||
alarms = alarms,
|
||||
accountType = "caldav-type",
|
||||
now = now,
|
||||
zone = ZoneId.of("Europe/Berlin"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `lists report the local account for device-only lists and ours for synced ones`() {
|
||||
val rows = build(emptyList()).rows.getValue(ProviderTable.LISTS)
|
||||
|
||||
assertThat(rows.map { it[Lists.ACCOUNT_TYPE] })
|
||||
.containsExactly(TasksContract.LOCAL_ACCOUNT_TYPE, "caldav-type").inOrder()
|
||||
assertThat(rows.map { it[Lists.ACCOUNT_NAME] })
|
||||
.containsExactly(TasksContract.LOCAL_ACCOUNT_NAME, "Nextcloud").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain task is one task row and one instance`() {
|
||||
val due = now + 1.days
|
||||
val task = TaskEntity(id = 7, listId = 1, uid = "u7", title = "Milk", due = due, priority = 3)
|
||||
|
||||
val snapshot = build(listOf(task))
|
||||
val row = snapshot.rows.getValue(ProviderTable.TASKS).single()
|
||||
val instance = snapshot.rows.getValue(ProviderTable.INSTANCES).single()
|
||||
|
||||
assertThat(row[Tasks.ID]).isEqualTo(7L)
|
||||
assertThat(row[Tasks.PRIORITY]).isEqualTo(3)
|
||||
assertThat(row[Tasks.LIST_NAME]).isEqualTo("Home")
|
||||
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(0)
|
||||
assertThat(row[Tasks.IS_NEW]).isEqualTo(1)
|
||||
assertThat(instance[Instances.TASK_ID]).isEqualTo(7L)
|
||||
assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(due.toEpochMilliseconds())
|
||||
assertThat(instance[Instances.IS_RECURRING]).isEqualTo(0)
|
||||
assertThat(instance[Instances.DISTANCE_FROM_CURRENT]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completed task is closed`() {
|
||||
val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.COMPLETED, completedAt = now)
|
||||
|
||||
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
|
||||
|
||||
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1)
|
||||
assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||
assertThat(row[Tasks.COMPLETED]).isEqualTo(now.toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override replaces the occurrence it names`() {
|
||||
val start = Instant.parse("2026-10-09T09:00:00Z")
|
||||
val master = TaskEntity(
|
||||
id = 10, listId = 1, uid = "s", title = "Run", dtstart = start, due = start + 1.hours,
|
||||
rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC",
|
||||
)
|
||||
val second = start + 1.days
|
||||
val override = master.copy(
|
||||
id = 11, rrule = null, recurrenceId = second, masterId = 10, title = "Run long",
|
||||
dtstart = second + 2.hours, due = second + 4.hours,
|
||||
)
|
||||
|
||||
val snapshot = build(listOf(master, override))
|
||||
val instances = snapshot.rows.getValue(ProviderTable.INSTANCES)
|
||||
|
||||
assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 11L, 10L).inOrder()
|
||||
assertThat(instances.map { it[Tasks.TITLE] }).containsExactly("Run", "Run long", "Run").inOrder()
|
||||
assertThat(instances[1][Instances.INSTANCE_ORIGINAL_TIME]).isEqualTo(second.toEpochMilliseconds())
|
||||
assertThat(instances[1][Instances.INSTANCE_DURATION]).isEqualTo(2.hours.inWholeMilliseconds)
|
||||
assertThat(instances.map { it[Tasks.ID] }).containsNoDuplicates()
|
||||
|
||||
val overrideRow = snapshot.rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 11L }
|
||||
assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_ID]).isEqualTo(10L)
|
||||
assertThat(overrideRow[Tasks.ORIGINAL_INSTANCE_TIME]).isEqualTo(second.toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day date sorts at local midnight`() {
|
||||
val day = Instant.parse("2026-10-12T00:00:00Z")
|
||||
val task = TaskEntity(id = 1, listId = 1, uid = "u", due = day, isAllDay = true)
|
||||
|
||||
val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single()
|
||||
|
||||
assertThat(instance[Instances.INSTANCE_DUE]).isEqualTo(day.toEpochMilliseconds())
|
||||
assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo((day - 2.hours).toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarms and tags become properties`() {
|
||||
val task = TaskEntity(id = 3, listId = 2, uid = "u", categories = listOf("errand", "home"))
|
||||
val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 15)
|
||||
|
||||
val snapshot = build(listOf(task), listOf(alarm))
|
||||
val properties = snapshot.rows.getValue(ProviderTable.PROPERTIES)
|
||||
|
||||
assertThat(snapshot.rows.getValue(ProviderTable.TASKS).single()[Tasks.HAS_ALARMS]).isEqualTo(1)
|
||||
val alarmRow = properties.single { it[Properties.MIMETYPE] == Alarm.MIMETYPE }
|
||||
assertThat(alarmRow[Alarm.MINUTES_BEFORE]).isEqualTo("15")
|
||||
assertThat(alarmRow[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_DUE.toString())
|
||||
assertThat(properties.filter { it[Properties.MIMETYPE] == Category.MIMETYPE }.map { it[Category.NAME] })
|
||||
.containsExactly("errand", "home")
|
||||
assertThat(properties.map { it[Properties.PROPERTY_ID] }).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every row carries exactly its table's columns`() {
|
||||
val task = TaskEntity(id = 3, listId = 1, uid = "u", categories = listOf("a"))
|
||||
val snapshot = build(listOf(task), listOf(TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 0)))
|
||||
|
||||
ProviderTable.entries.forEach { table ->
|
||||
val columns = table.columns.map { it.first }.toSet()
|
||||
snapshot.rows.getValue(table).forEach { assertThat(columns).containsAtLeastElementsIn(it.keys) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the empty snapshot has every table and no rows`() {
|
||||
assertThat(ProviderSnapshot.EMPTY.rows.keys).containsExactlyElementsIn(ProviderTable.entries)
|
||||
assertThat(ProviderSnapshot.EMPTY.rows.values.flatten()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lists carry their name, colour and flags`() {
|
||||
val hidden = TaskListRow(TaskListEntity(id = 3, name = "Old", color = 7, isVisible = false, isSynced = false), null)
|
||||
|
||||
val row = ProviderSnapshot.build(listOf(hidden), emptyList(), emptyList(), "t", now)
|
||||
.rows.getValue(ProviderTable.LISTS).single()
|
||||
|
||||
assertThat(row[Lists.ID]).isEqualTo(3L)
|
||||
assertThat(row[Lists.NAME]).isEqualTo("Old")
|
||||
assertThat(row[Lists.COLOR]).isEqualTo(7)
|
||||
assertThat(row[Lists.VISIBLE]).isEqualTo(0)
|
||||
assertThat(row[Lists.SYNC_ENABLED]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task reports its list's account`() {
|
||||
val task = TaskEntity(id = 1, listId = 2, uid = "u")
|
||||
|
||||
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
|
||||
|
||||
assertThat(row[Tasks.LIST_NAME]).isEqualTo("Work")
|
||||
assertThat(row[Tasks.ACCOUNT_NAME]).isEqualTo("Nextcloud")
|
||||
assertThat(row[Tasks.ACCOUNT_TYPE]).isEqualTo("caldav-type")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task whose list is gone is left out everywhere`() {
|
||||
val orphan = TaskEntity(id = 4, listId = 99, uid = "u", categories = listOf("x"))
|
||||
val alarm = TaskAlarmEntity(id = 1, taskId = 4, minutesBefore = 5)
|
||||
|
||||
val snapshot = build(listOf(orphan), listOf(alarm))
|
||||
|
||||
assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).isEmpty()
|
||||
assertThat(snapshot.rows.getValue(ProviderTable.INSTANCES)).isEmpty()
|
||||
assertThat(snapshot.rows.getValue(ProviderTable.PROPERTIES)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task fields map to their OpenTasks columns`() {
|
||||
val start = Instant.parse("2026-10-11T09:00:00Z")
|
||||
val task = TaskEntity(
|
||||
id = 5, listId = 1, uid = "uid-5", title = "T", description = "D", location = "L", url = "https://x",
|
||||
color = 42, classification = 1, percentComplete = 50, dtstart = start, due = start + 1.hours,
|
||||
timezone = "Europe/Berlin", parentId = 2, sortOrder = 3, createdAt = now, lastModified = now,
|
||||
)
|
||||
|
||||
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
|
||||
|
||||
assertThat(row[Tasks.UID]).isEqualTo("uid-5")
|
||||
assertThat(row[Tasks.TITLE]).isEqualTo("T")
|
||||
assertThat(row[Tasks.DESCRIPTION]).isEqualTo("D")
|
||||
assertThat(row[Tasks.LOCATION]).isEqualTo("L")
|
||||
assertThat(row[Tasks.URL]).isEqualTo("https://x")
|
||||
assertThat(row[Tasks.TASK_COLOR]).isEqualTo(42)
|
||||
assertThat(row[Tasks.CLASSIFICATION]).isEqualTo(1)
|
||||
assertThat(row[Tasks.PERCENT_COMPLETE]).isEqualTo(50)
|
||||
assertThat(row[Tasks.DTSTART]).isEqualTo(start.toEpochMilliseconds())
|
||||
assertThat(row[Tasks.DUE]).isEqualTo((start + 1.hours).toEpochMilliseconds())
|
||||
assertThat(row[Tasks.TZ]).isEqualTo("Europe/Berlin")
|
||||
assertThat(row[Tasks.IS_ALLDAY]).isEqualTo(0)
|
||||
assertThat(row[Tasks.PARENT_ID]).isEqualTo(2L)
|
||||
assertThat(row[Tasks.SORTING]).isEqualTo(3)
|
||||
assertThat(row[Tasks.CREATED]).isEqualTo(now.toEpochMilliseconds())
|
||||
assertThat(row[Tasks.LAST_MODIFIED]).isEqualTo(now.toEpochMilliseconds())
|
||||
assertThat(row[Tasks.DELETED]).isEqualTo(0)
|
||||
assertThat(row[Tasks.HAS_ALARMS]).isEqualTo(0)
|
||||
assertThat(row[Tasks.ORIGINAL_INSTANCE_ID]).isNull()
|
||||
assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task with progress is no longer new`() {
|
||||
val task = TaskEntity(id = 1, listId = 1, uid = "u", percentComplete = 10)
|
||||
|
||||
assertThat(build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()[Tasks.IS_NEW]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled task is closed`() {
|
||||
val task = TaskEntity(id = 1, listId = 1, uid = "u", status = TaskStatus.CANCELLED)
|
||||
|
||||
val row = build(listOf(task)).rows.getValue(ProviderTable.TASKS).single()
|
||||
|
||||
assertThat(row[Tasks.IS_CLOSED]).isEqualTo(1)
|
||||
assertThat(row[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_CANCELLED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series expands into one instance per occurrence`() {
|
||||
val start = Instant.parse("2026-10-09T09:00:00Z")
|
||||
val master = TaskEntity(
|
||||
id = 10, listId = 1, uid = "s", dtstart = start, due = start + 1.hours,
|
||||
rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC",
|
||||
)
|
||||
|
||||
val snapshot = build(listOf(master))
|
||||
val instances = snapshot.rows.getValue(ProviderTable.INSTANCES)
|
||||
|
||||
assertThat(snapshot.rows.getValue(ProviderTable.TASKS)).hasSize(1)
|
||||
assertThat(instances.map { it[Instances.TASK_ID] }).containsExactly(10L, 10L, 10L)
|
||||
assertThat(instances.map { it[Instances.IS_RECURRING] }).containsExactly(1, 1, 1)
|
||||
assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactlyElementsIn(
|
||||
(0..2).map { (start + it.days).toEpochMilliseconds() },
|
||||
).inOrder()
|
||||
assertThat(instances.map { it[Instances.INSTANCE_ORIGINAL_TIME] })
|
||||
.isEqualTo(instances.map { it[Instances.INSTANCE_START] })
|
||||
assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(
|
||||
1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds, 1.hours.inWholeMilliseconds,
|
||||
)
|
||||
// 10-09 is past, 10-10 09:00 is the next one at or after 08:00.
|
||||
assertThat(instances.map { it[Instances.DISTANCE_FROM_CURRENT] }).containsExactly(-1, 0, 1).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a due-anchored series has no start`() {
|
||||
val due = Instant.parse("2026-10-12T17:00:00Z")
|
||||
val master = TaskEntity(id = 10, listId = 1, uid = "s", due = due, rrule = "FREQ=WEEKLY;COUNT=2", timezone = "UTC")
|
||||
|
||||
val instances = build(listOf(master)).rows.getValue(ProviderTable.INSTANCES)
|
||||
|
||||
assertThat(instances.map { it[Instances.INSTANCE_START] }).containsExactly(null, null)
|
||||
assertThat(instances.map { it[Instances.INSTANCE_DUE] }).containsExactly(
|
||||
due.toEpochMilliseconds(), (due + 7.days).toEpochMilliseconds(),
|
||||
).inOrder()
|
||||
assertThat(instances.map { it[Instances.INSTANCE_DURATION] }).containsExactly(null, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instance ids are unique across series and plain tasks`() {
|
||||
val start = Instant.parse("2026-10-09T09:00:00Z")
|
||||
val a = TaskEntity(id = 1, listId = 1, uid = "a", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
|
||||
val b = TaskEntity(id = 2, listId = 1, uid = "b", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
|
||||
val plain = TaskEntity(id = 3, listId = 2, uid = "c")
|
||||
|
||||
val ids = build(listOf(a, b, plain)).rows.getValue(ProviderTable.INSTANCES).map { it[Tasks.ID] }
|
||||
|
||||
assertThat(ids).hasSize(9)
|
||||
assertThat(ids).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override is flagged all-day like the occurrence it replaces`() {
|
||||
val day = Instant.parse("2026-10-09T00:00:00Z")
|
||||
val master = TaskEntity(id = 1, listId = 1, uid = "s", due = day, isAllDay = true, rrule = "FREQ=DAILY;COUNT=2")
|
||||
val override = master.copy(id = 2, rrule = null, masterId = 1, recurrenceId = day + 1.days)
|
||||
|
||||
val row = build(listOf(master, override)).rows.getValue(ProviderTable.TASKS).single { it[Tasks.ID] == 2L }
|
||||
|
||||
assertThat(row[Tasks.ORIGINAL_INSTANCE_ALLDAY]).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed date sorts as itself`() {
|
||||
val due = Instant.parse("2026-10-12T15:30:00Z")
|
||||
val task = TaskEntity(id = 1, listId = 1, uid = "u", due = due, dtstart = due - 1.hours)
|
||||
|
||||
val instance = build(listOf(task)).rows.getValue(ProviderTable.INSTANCES).single()
|
||||
|
||||
assertThat(instance[Instances.INSTANCE_DUE_SORTING]).isEqualTo(due.toEpochMilliseconds())
|
||||
assertThat(instance[Instances.INSTANCE_START_SORTING]).isEqualTo((due - 1.hours).toEpochMilliseconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm before the start says so`() {
|
||||
val task = TaskEntity(id = 3, listId = 1, uid = "u")
|
||||
val alarm = TaskAlarmEntity(id = 1, taskId = 3, minutesBefore = 60, reference = AlarmReference.START, message = "Go")
|
||||
|
||||
val row = build(listOf(task), listOf(alarm)).rows.getValue(ProviderTable.PROPERTIES).single()
|
||||
|
||||
assertThat(row[Properties.TASK_ID]).isEqualTo(3L)
|
||||
assertThat(row[Alarm.REFERENCE]).isEqualTo(Alarm.REFERENCE_START.toString())
|
||||
assertThat(row[Alarm.MESSAGE]).isEqualTo("Go")
|
||||
assertThat(row[Alarm.ALARM_TYPE]).isEqualTo(Alarm.TYPE_MESSAGE.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an instance keeps its id when other occurrences come and go`() {
|
||||
val start = Instant.parse("2026-10-09T09:00:00Z")
|
||||
val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=4", timezone = "UTC")
|
||||
val plain = TaskEntity(id = 3, listId = 1, uid = "p")
|
||||
fun ids(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES)
|
||||
.associate { it[Instances.INSTANCE_ORIGINAL_TIME] to it[Tasks.ID] }
|
||||
|
||||
val before = ids(plain, master)
|
||||
val after = ids(master.copy(exdate = "20261010T090000Z"))
|
||||
|
||||
val third = (start + 2.days).toEpochMilliseconds()
|
||||
assertThat(after[third]).isEqualTo(before[third])
|
||||
assertThat(after).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an overridden occurrence keeps the id it had before`() {
|
||||
val start = Instant.parse("2026-10-09T09:00:00Z")
|
||||
val master = TaskEntity(id = 10, listId = 1, uid = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3", timezone = "UTC")
|
||||
val override = master.copy(id = 11, rrule = null, masterId = 10, recurrenceId = start + 1.days, title = "x")
|
||||
fun idOfSecond(vararg tasks: TaskEntity) = build(tasks.toList()).rows.getValue(ProviderTable.INSTANCES)
|
||||
.single { it[Instances.INSTANCE_ORIGINAL_TIME] == (start + 1.days).toEpochMilliseconds() }[Tasks.ID]
|
||||
|
||||
assertThat(idOfSecond(master, override)).isEqualTo(idOfSecond(master))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a property keeps its id when other properties come and go`() {
|
||||
val a = TaskEntity(id = 3, listId = 1, uid = "a", categories = listOf("one", "two"))
|
||||
val b = TaskEntity(id = 4, listId = 1, uid = "b", categories = listOf("three"))
|
||||
fun ids(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>) = build(tasks, alarms)
|
||||
.rows.getValue(ProviderTable.PROPERTIES)
|
||||
.associate { (it[Category.NAME] ?: it[Alarm.MINUTES_BEFORE]) to it[Properties.PROPERTY_ID] }
|
||||
val first = TaskAlarmEntity(id = 7, taskId = 3, minutesBefore = 5)
|
||||
val second = TaskAlarmEntity(id = 8, taskId = 4, minutesBefore = 10)
|
||||
|
||||
val before = ids(listOf(a, b), listOf(first, second))
|
||||
val after = ids(listOf(a.copy(categories = listOf("two")), b), listOf(second))
|
||||
|
||||
assertThat(after["two"]).isEqualTo(before["two"])
|
||||
assertThat(after["three"]).isEqualTo(before["three"])
|
||||
assertThat(after["10"]).isEqualTo(before["10"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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 org.junit.jupiter.api.Test
|
||||
|
||||
class ProviderTableTest {
|
||||
|
||||
@Test
|
||||
fun `paths follow the OpenTasks contract`() {
|
||||
assertThat(ProviderTable.entries.map { it.path })
|
||||
.containsExactly("tasklists", "tasks", "instances", "properties").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each table keys on its OpenTasks id column`() {
|
||||
assertThat(ProviderTable.LISTS.idColumn).isEqualTo(Lists.ID)
|
||||
assertThat(ProviderTable.TASKS.idColumn).isEqualTo(Tasks.ID)
|
||||
assertThat(ProviderTable.INSTANCES.idColumn).isEqualTo(Tasks.ID)
|
||||
assertThat(ProviderTable.PROPERTIES.idColumn).isEqualTo(Properties.PROPERTY_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no table declares a column twice`() {
|
||||
ProviderTable.entries.forEach { table ->
|
||||
assertThat(table.columns.map { it.first }).containsNoDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances carry every task column`() {
|
||||
val tasks = ProviderTable.TASKS.columns.map { it.first }
|
||||
val instances = ProviderTable.INSTANCES.columns.map { it.first }
|
||||
|
||||
assertThat(instances).containsAtLeastElementsIn(tasks)
|
||||
assertThat(instances).containsAtLeast(Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported OpenTasks columns are declared so a projection naming them succeeds`() {
|
||||
assertThat(ProviderTable.TASKS.columns.map { it.first }).containsAtLeast("_dirty", "_sync_id", "sync1", "pinned")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table URI keeps the caller's selection untouched`() {
|
||||
assertThat(ProviderTable.TASKS.selectionFor(null, "title = ?")).isEqualTo("title = ?")
|
||||
assertThat(ProviderTable.TASKS.selectionFor(null, null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row URI narrows to its id`() {
|
||||
assertThat(ProviderTable.TASKS.selectionFor(4, null)).isEqualTo("_id = 4")
|
||||
assertThat(ProviderTable.PROPERTIES.selectionFor(9, null)).isEqualTo("property_id = 9")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row URI parenthesises the caller's selection so an OR cannot widen it`() {
|
||||
assertThat(ProviderTable.TASKS.selectionFor(4, "a = 1 OR b = 2")).isEqualTo("_id = 4 AND (a = 1 OR b = 2)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MIME types are OpenTasks' own`() {
|
||||
assertThat(ProviderTable.TASKS.mimeType(single = false)).isEqualTo("vnd.android.cursor.dir/org.dmfs.tasks.tasks")
|
||||
assertThat(ProviderTable.LISTS.mimeType(single = true))
|
||||
.isEqualTo("vnd.android.cursor.item/org.dmfs.tasks.tasklists")
|
||||
}
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ProviderTaskWriterTest {
|
||||
|
||||
private val now = Instant.parse("2026-10-15T10:00:00Z")
|
||||
private val earlier = Instant.parse("2026-10-01T10:00:00Z")
|
||||
private val task = TaskEntity(id = 5, listId = 1, uid = "uid-5", title = "Old", lastModified = earlier)
|
||||
|
||||
private fun patch(current: TaskEntity = task, vararg values: Pair<String, Any?>) =
|
||||
ProviderTaskWriter.patch(current, mapOf(*values), now, "Europe/Berlin")
|
||||
|
||||
private fun millis(at: Instant) = at.toEpochMilliseconds()
|
||||
|
||||
// --- column filtering ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `published read-only columns are dropped`() {
|
||||
val values = mapOf(Tasks.TITLE to "t", Tasks.LIST_NAME to "x", Tasks.IS_CLOSED to 1, "_sync_id" to "s")
|
||||
|
||||
assertThat(ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS))
|
||||
.containsExactly(Tasks.TITLE, "t")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column the table does not have is rejected`() {
|
||||
val error = assertThrows<IllegalArgumentException> {
|
||||
ProviderTaskWriter.writable(ProviderTable.TASKS, mapOf("href" to "x", "etag" to "y"), WRITABLE_TASK_COLUMNS)
|
||||
}
|
||||
assertThat(error).hasMessageThat().contains("etag, href")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances accept every task column and their own`() {
|
||||
val values = mapOf<String, Any?>(Tasks.STATUS to 2, Instances.INSTANCE_START to 0L)
|
||||
|
||||
assertThat(ProviderTaskWriter.writable(ProviderTable.INSTANCES, values, WRITABLE_TASK_COLUMNS))
|
||||
.containsExactly(Tasks.STATUS, 2)
|
||||
}
|
||||
|
||||
// --- new tasks ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a new task needs a list`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
ProviderTaskWriter.newTask(mapOf(Tasks.TITLE to "t"), "u", now, "UTC")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new task takes the client's uid or the minted one`() {
|
||||
val minted = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "minted", now, "UTC")
|
||||
val own = ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.UID to " theirs "), "minted", now, "UTC")
|
||||
|
||||
assertThat(minted.uid).isEqualTo("minted")
|
||||
assertThat(own.uid).isEqualTo("theirs")
|
||||
assertThat(own.listId).isEqualTo(3L)
|
||||
assertThat(own.createdAt).isEqualTo(now)
|
||||
assertThat(own.lastModified).isEqualTo(now)
|
||||
assertThat(own.isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new task may have no title`() {
|
||||
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L), "u", now, "UTC").title).isNull()
|
||||
}
|
||||
|
||||
// --- content ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `only the columns given change`() {
|
||||
val current = task.copy(description = "keep", priority = 3, location = "home")
|
||||
|
||||
val edited = patch(current, Tasks.TITLE to " New ")
|
||||
|
||||
assertThat(edited.title).isEqualTo("New")
|
||||
assertThat(edited.description).isEqualTo("keep")
|
||||
assertThat(edited.priority).isEqualTo(3)
|
||||
assertThat(edited.location).isEqualTo("home")
|
||||
assertThat(edited.lastModified).isEqualTo(now)
|
||||
assertThat(edited.isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank text clears and null clears`() {
|
||||
val current = task.copy(description = "d", url = "u")
|
||||
|
||||
val edited = patch(current, Tasks.DESCRIPTION to " ", Tasks.URL to null)
|
||||
|
||||
assertThat(edited.description).isNull()
|
||||
assertThat(edited.url).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers arrive in whatever shape the client sent`() {
|
||||
val edited = patch(task, Tasks.PRIORITY to "2", Tasks.CLASSIFICATION to 1L, Tasks.SORTING to 7, Tasks.TASK_COLOR to -16711936)
|
||||
|
||||
assertThat(edited.priority).isEqualTo(2)
|
||||
assertThat(edited.classification).isEqualTo(1)
|
||||
assertThat(edited.sortOrder).isEqualTo(7)
|
||||
assertThat(edited.color).isEqualTo(-16711936)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null priority is no priority`() {
|
||||
assertThat(patch(task.copy(priority = 4), Tasks.PRIORITY to null).priority).isEqualTo(TasksContract.PRIORITY_NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of range values are rejected`() {
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.PRIORITY to 10) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.CLASSIFICATION to 3) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.PERCENT_COMPLETE to 101) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.STATUS to 4) }
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.PRIORITY to "high") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the uid cannot change`() {
|
||||
assertThat(patch(task, Tasks.UID to "uid-5").uid).isEqualTo("uid-5")
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.UID to "other") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task cannot be its own parent`() {
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.PARENT_ID to 5L) }
|
||||
assertThat(patch(task, Tasks.PARENT_ID to 9L).parentId).isEqualTo(9L)
|
||||
assertThat(patch(task.copy(parentId = 9), Tasks.PARENT_ID to null).parentId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a list can be changed but not cleared`() {
|
||||
assertThat(patch(task, Tasks.LIST_ID to 2L).listId).isEqualTo(2L)
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.LIST_ID to null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync state is left alone`() {
|
||||
val synced = task.copy(href = "/t.ics", etag = "e1", sequence = 4, unknownProperties = "X-FOO:bar")
|
||||
|
||||
val edited = patch(synced, Tasks.TITLE to "t")
|
||||
|
||||
assertThat(edited.href).isEqualTo("/t.ics")
|
||||
assertThat(edited.etag).isEqualTo("e1")
|
||||
assertThat(edited.sequence).isEqualTo(4)
|
||||
assertThat(edited.unknownProperties).isEqualTo("X-FOO:bar")
|
||||
}
|
||||
|
||||
// --- times --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a timed task takes the device zone unless it has one`() {
|
||||
val due = Instant.parse("2026-10-20T15:00:00Z")
|
||||
|
||||
assertThat(patch(task, Tasks.DUE to millis(due)).timezone).isEqualTo("Europe/Berlin")
|
||||
assertThat(patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due)).timezone).isEqualTo("UTC")
|
||||
assertThat(patch(task, Tasks.DUE to millis(due), Tasks.TZ to "America/New_York").timezone)
|
||||
.isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new timed task without a zone is floating`() {
|
||||
val due = millis(Instant.parse("2026-10-20T09:00:00Z"))
|
||||
|
||||
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due), "u", now, "UTC").timezone).isNull()
|
||||
assertThat(ProviderTaskWriter.newTask(mapOf(Tasks.LIST_ID to 3L, Tasks.DUE to due, Tasks.TZ to "Europe/Paris"), "u", now, "UTC").timezone)
|
||||
.isEqualTo("Europe/Paris")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone is rejected`() {
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.DUE to 0L, Tasks.TZ to "Mars/Olympus") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day task is floored to UTC midnight and has no zone`() {
|
||||
val due = Instant.parse("2026-10-20T15:00:00Z")
|
||||
|
||||
val edited = patch(task.copy(timezone = "UTC"), Tasks.DUE to millis(due), Tasks.IS_ALLDAY to 1)
|
||||
|
||||
assertThat(edited.isAllDay).isTrue()
|
||||
assertThat(edited.due).isEqualTo(Instant.parse("2026-10-20T00:00:00Z"))
|
||||
assertThat(edited.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `due before start is rejected`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now - 1.hours))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting a due date drops a duration`() {
|
||||
val edited = patch(task.copy(dtstart = now, duration = "PT1H"), Tasks.DUE to millis(now + 2.hours))
|
||||
|
||||
assertThat(edited.duration).isNull()
|
||||
assertThat(edited.due).isEqualTo(now + 2.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `due and duration together are rejected`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
patch(task, Tasks.DTSTART to millis(now), Tasks.DUE to millis(now + 1.hours), Tasks.DURATION to "PT1H")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration must be an iCalendar one`() {
|
||||
assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "P1W").duration).isEqualTo("P1W")
|
||||
assertThat(patch(task, Tasks.DTSTART to millis(now), Tasks.DURATION to "PT1H30M").duration).isEqualTo("PT1H30M")
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.DURATION to "an hour") }
|
||||
}
|
||||
|
||||
// --- recurrence ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a rule is stored without its property name`() {
|
||||
assertThat(patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed rule is rejected`() {
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.DUE to millis(now), Tasks.RRULE to "FREQ=SOMETIMES") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule needs a date`() {
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.RRULE to "FREQ=DAILY") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping the rule drops its exceptions`() {
|
||||
val series = task.copy(due = now, rrule = "FREQ=DAILY", exdate = "20261016T100000Z", rdate = "20261030T100000Z")
|
||||
|
||||
val edited = patch(series, Tasks.RRULE to null)
|
||||
|
||||
assertThat(edited.rrule).isNull()
|
||||
assertThat(edited.exdate).isNull()
|
||||
assertThat(edited.rdate).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override carries no rule and stays in its series`() {
|
||||
val override = task.copy(masterId = 4, recurrenceId = now, parentId = 2)
|
||||
|
||||
assertThrows<IllegalArgumentException> { patch(override, Tasks.RRULE to "FREQ=DAILY") }
|
||||
assertThat(patch(override, Tasks.RRULE to null, Tasks.RDATE to null, Tasks.EXDATE to "").rrule).isNull()
|
||||
assertThrows<IllegalArgumentException> { patch(override, Tasks.LIST_ID to 2L) }
|
||||
assertThrows<IllegalArgumentException> { patch(override, Tasks.PARENT_ID to 3L) }
|
||||
assertThat(patch(override, Tasks.LIST_ID to 1L, Tasks.PARENT_ID to 2L, Tasks.TITLE to "x").title).isEqualTo("x")
|
||||
}
|
||||
|
||||
// --- completion ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `completing sets progress and time`() {
|
||||
val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED)
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(edited.percentComplete).isEqualTo(100)
|
||||
assertThat(edited.completedAt).isEqualTo(now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completing keeps the client's completion time`() {
|
||||
val edited = patch(task, Tasks.STATUS to TasksContract.STATUS_COMPLETED, Tasks.COMPLETED to millis(earlier))
|
||||
|
||||
assertThat(edited.completedAt).isEqualTo(earlier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reopening clears progress and time`() {
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val edited = patch(done, Tasks.STATUS to TasksContract.STATUS_NEEDS_ACTION)
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(edited.percentComplete).isNull()
|
||||
assertThat(edited.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling keeps progress and clears the completion time`() {
|
||||
val edited = patch(task.copy(percentComplete = 40, completedAt = earlier), Tasks.STATUS to TasksContract.STATUS_CANCELLED)
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.CANCELLED)
|
||||
assertThat(edited.percentComplete).isEqualTo(40)
|
||||
assertThat(edited.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress implies a status`() {
|
||||
assertThat(patch(task, Tasks.PERCENT_COMPLETE to 50).status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(patch(task, Tasks.PERCENT_COMPLETE to 0).status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
val done = patch(task, Tasks.PERCENT_COMPLETE to 100)
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.completedAt).isEqualTo(now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress below 100 reopens a completed task`() {
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val edited = patch(done, Tasks.PERCENT_COMPLETE to 60)
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(edited.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completion time alone completes and clearing it reopens`() {
|
||||
val done = patch(task, Tasks.COMPLETED to millis(earlier))
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.percentComplete).isEqualTo(100)
|
||||
assertThat(done.completedAt).isEqualTo(earlier)
|
||||
|
||||
val reopened = patch(done, Tasks.COMPLETED to null)
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(reopened.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a completed row written back with status open reopens it fully`() {
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val edited = patch(done, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to millis(earlier))
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(edited.percentComplete).isNull()
|
||||
assertThat(edited.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the changed completion column decides over unchanged ones written back`() {
|
||||
val completed = patch(task, Tasks.STATUS to 0, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
|
||||
assertThat(completed.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(completed.completedAt).isEqualTo(now)
|
||||
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
val reopened = patch(done, Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100, Tasks.COMPLETED to null)
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(reopened.percentComplete).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a write without completion columns leaves completion alone`() {
|
||||
val done = task.copy(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val edited = patch(done, Tasks.TITLE to "t")
|
||||
|
||||
assertThat(edited.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(edited.completedAt).isEqualTo(earlier)
|
||||
}
|
||||
|
||||
// --- occurrences --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an occurrence is completed, cancelled or reopened`() {
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2))).isEqualTo(OccurrenceChange.COMPLETE)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 3))).isEqualTo(OccurrenceChange.CANCEL)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 0))).isEqualTo(OccurrenceChange.REOPEN)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 100))).isEqualTo(OccurrenceChange.COMPLETE)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 0))).isEqualTo(OccurrenceChange.REOPEN)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to 1L))).isEqualTo(OccurrenceChange.COMPLETE)
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.COMPLETED to null))).isEqualTo(OccurrenceChange.REOPEN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status wins over progress on an occurrence`() {
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 2, Tasks.PERCENT_COMPLETE to 100)))
|
||||
.isEqualTo(OccurrenceChange.COMPLETE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an occurrence has no partial progress`() {
|
||||
assertThrows<IllegalArgumentException> { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.STATUS to 1)) }
|
||||
assertThrows<IllegalArgumentException> { ProviderTaskWriter.occurrenceChange(mapOf(Tasks.PERCENT_COMPLETE to 50)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `values without completion say nothing about the occurrence`() {
|
||||
assertThat(ProviderTaskWriter.occurrenceChange(mapOf(Tasks.TITLE to "t"))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flags accept the shapes clients send`() {
|
||||
listOf(1, 1L, true, "1", "true").forEach {
|
||||
assertThat(patch(task, Tasks.DUE to millis(now + 1.days), Tasks.IS_ALLDAY to it).isAllDay).isTrue()
|
||||
}
|
||||
listOf(0, false, "0", null).forEach {
|
||||
assertThat(patch(task.copy(isAllDay = true), Tasks.IS_ALLDAY to it).isAllDay).isFalse()
|
||||
}
|
||||
assertThrows<IllegalArgumentException> { patch(task, Tasks.IS_ALLDAY to "yes") }
|
||||
}
|
||||
|
||||
// --- no-op writes -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a write that changes nothing returns the row as it was`() {
|
||||
val current = task.copy(description = "d", priority = 3, isDirty = false)
|
||||
|
||||
assertThat(patch(current)).isSameInstanceAs(current)
|
||||
assertThat(patch(current, Tasks.TITLE to "Old", Tasks.PRIORITY to 3L, Tasks.DESCRIPTION to "d"))
|
||||
.isSameInstanceAs(current)
|
||||
assertThat(patch(current, Tasks.TITLE to "New").isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only columns that differ from the row count as changed`() {
|
||||
val due = Instant.parse("2026-10-20T15:00:00Z")
|
||||
val series = task.copy(due = due, timezone = "UTC", rrule = "FREQ=DAILY", status = TaskStatus.IN_PROCESS, percentComplete = 50)
|
||||
val readBack = mapOf(
|
||||
Tasks.TITLE to "Old", Tasks.DUE to millis(due), Tasks.DTSTART to null, Tasks.TZ to "UTC", Tasks.IS_ALLDAY to 0,
|
||||
Tasks.RRULE to "FREQ=DAILY", Tasks.RDATE to null, Tasks.EXDATE to null, Tasks.DURATION to null,
|
||||
Tasks.STATUS to 1, Tasks.PERCENT_COMPLETE to 50, Tasks.COMPLETED to null,
|
||||
)
|
||||
|
||||
assertThat(ProviderTaskWriter.changedColumns(series, readBack, "Europe/Berlin")).isEmpty()
|
||||
assertThat(
|
||||
ProviderTaskWriter.changedColumns(
|
||||
series,
|
||||
readBack + (Tasks.DUE to millis(due + 1.hours)) + (Tasks.TITLE to "New") + (Tasks.STATUS to 2),
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).containsExactly(Tasks.DUE, Tasks.TITLE, Tasks.STATUS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value that does not fit counts as changed`() {
|
||||
assertThat(ProviderTaskWriter.changedColumns(task, mapOf(Tasks.PRIORITY to 99), "UTC")).containsExactly(Tasks.PRIORITY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a floating task stays floating`() {
|
||||
val floating = task.copy(due = now, timezone = null)
|
||||
|
||||
assertThat(patch(floating, Tasks.TITLE to "t").timezone).isNull()
|
||||
assertThat(patch(floating, Tasks.DUE to millis(now + 1.hours)).timezone).isNull()
|
||||
assertThat(patch(floating, Tasks.TITLE to "Old")).isSameInstanceAs(floating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day task made timed takes the device zone`() {
|
||||
val allDay = task.copy(due = Instant.parse("2026-10-20T00:00:00Z"), isAllDay = true)
|
||||
|
||||
val timed = patch(allDay, Tasks.IS_ALLDAY to 0, Tasks.DUE to millis(Instant.parse("2026-10-20T15:00:00Z")))
|
||||
|
||||
assertThat(timed.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing every date clears the zone`() {
|
||||
assertThat(patch(task.copy(due = now, timezone = "UTC"), Tasks.DUE to null).timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception and extra dates must be iCalendar values of one kind`() {
|
||||
val series = task.copy(due = now, rrule = "FREQ=DAILY", timezone = "UTC")
|
||||
|
||||
assertThat(patch(series, Tasks.EXDATE to "20261016T100000Z, 20261017T100000Z").exdate)
|
||||
.isEqualTo("20261016T100000Z,20261017T100000Z")
|
||||
assertThat(patch(series, Tasks.RDATE to "20261030").rdate).isEqualTo("20261030")
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "tomorrow") }
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.EXDATE to "20261016,20261017T100000Z") }
|
||||
assertThrows<IllegalArgumentException> { patch(series, Tasks.RDATE to "20261016T100000Z,20261017T100000") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored dates written back as synced are kept`() {
|
||||
val series = task.copy(due = now, rrule = "FREQ=DAILY", rdate = "20261016T100000Z/PT1H", exdate = "20261016,20261017T100000Z")
|
||||
|
||||
val edited = patch(series, Tasks.TITLE to "New", Tasks.RDATE to series.rdate, Tasks.EXDATE to series.exdate)
|
||||
|
||||
assertThat(edited.rdate).isEqualTo(series.rdate)
|
||||
assertThat(edited.exdate).isEqualTo(series.exdate)
|
||||
assertThat(ProviderTaskWriter.changedColumns(series, mapOf(Tasks.RDATE to series.rdate), "UTC")).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
@@ -53,6 +54,17 @@ class TaskFormWriterTest {
|
||||
assertThat(entity.isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty set the editor chose turns reminders off, any other keeps the flag`() {
|
||||
val due = Instant.parse("2026-02-01T10:00:00Z")
|
||||
val chosen = form(due = due).copy(remindersChosen = true)
|
||||
assertThat(TaskFormWriter.apply(task(), chosen, NOW, ZONE).remindersOff).isTrue()
|
||||
val withOne = chosen.copy(reminders = listOf(TaskReminder(15)))
|
||||
assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), withOne, NOW, ZONE).remindersOff).isFalse()
|
||||
assertThat(TaskFormWriter.apply(task().copy(remindersOff = true), form(due = due), NOW, ZONE).remindersOff).isTrue()
|
||||
assertThat(TaskFormWriter.apply(task(), form(due = due), NOW, ZONE).remindersOff).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags are trimmed and blanks dropped`() {
|
||||
val entity = TaskFormWriter.apply(task(), form(tags = listOf(" Work ", "", " ", "Home")), NOW, ZONE)
|
||||
|
||||
@@ -31,9 +31,15 @@ class TaskFormTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder requires a due date`() {
|
||||
val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate()
|
||||
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
|
||||
fun `a reminder requires a start or due date`() {
|
||||
val errors = TaskForm(title = "x", listId = 1, reminders = listOf(TaskReminder(10))).validate()
|
||||
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start alone is enough for a reminder`() {
|
||||
val errors = TaskForm(title = "x", listId = 1, start = Instant.fromEpochSeconds(0), reminders = listOf(TaskReminder(10))).validate()
|
||||
assertThat(errors).doesNotContain(TaskFormError.REMINDER_WITHOUT_DATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskReminderTest {
|
||||
|
||||
@Test
|
||||
fun `whole days keep the device's all-day time`() {
|
||||
assertThat(TaskReminder(0).allDay).isEqualTo(AllDayReminder(0, null))
|
||||
assertThat(TaskReminder(2 * 1_440).allDay).isEqualTo(AllDayReminder(2, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anything else names a time of day`() {
|
||||
assertThat(TaskReminder(-9 * 60).allDay).isEqualTo(AllDayReminder(0, 9 * 60))
|
||||
assertThat(TaskReminder(7 * 60).allDay).isEqualTo(AllDayReminder(1, 17 * 60))
|
||||
assertThat(TaskReminder(15).allDay).isEqualTo(AllDayReminder(1, 23 * 60 + 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day and a time round-trip`() {
|
||||
listOf(0 to 9 * 60, 1 to 17 * 60, 7 to 30, 3 to null).forEach { (days, minute) ->
|
||||
assertThat(TaskReminder.allDay(days, minute).allDay).isEqualTo(AllDayReminder(days, minute))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lead past the day is clamped to its last minute`() {
|
||||
assertThat(TaskReminder(-1_440).allDay).isEqualTo(AllDayReminder(0, 1_439))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight is a minute past it`() {
|
||||
assertThat(TaskReminder.allDay(1, 0).allDay).isEqualTo(AllDayReminder(1, 1))
|
||||
assertThat(TaskReminder.allDay(1, null).allDay).isEqualTo(AllDayReminder(1, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a default lead keeps its anchor whatever its lead`() {
|
||||
val reminders = listOf(TaskReminder(-540, fromStart = true), TaskReminder(1_440, fromStart = true), TaskReminder(-540))
|
||||
assertThat(reminders.map { it.asReminderLead().asTaskReminder() }).isEqualTo(reminders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole days round to the nearest`() {
|
||||
assertThat(TaskReminder(60).inWholeDays()).isEqualTo(TaskReminder(0))
|
||||
assertThat(TaskReminder(23 * 60, fromStart = true).inWholeDays()).isEqualTo(TaskReminder(1_440, fromStart = true))
|
||||
assertThat(TaskReminder(-540).inWholeDays()).isEqualTo(TaskReminder(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day reminders are listed in the order they ring`() {
|
||||
val eightPm = TaskReminder(0)
|
||||
val nine = TaskReminder.allDay(0, 9 * 60)
|
||||
val dayBefore = TaskReminder(1_440)
|
||||
assertThat(listOf(eightPm, nine, dayBefore).sortedWith(taskReminderDisplayOrder(true, 20 * 60)))
|
||||
.containsExactly(dayBefore, nine, eightPm).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ fun testTask(
|
||||
completedAt: Instant? = null,
|
||||
parentId: Long? = null,
|
||||
tags: List<String> = emptyList(),
|
||||
start: Instant? = null,
|
||||
created: Instant? = null,
|
||||
): Task = Task(
|
||||
taskId = id,
|
||||
listId = listId,
|
||||
@@ -24,7 +26,7 @@ fun testTask(
|
||||
priority = priority,
|
||||
status = status,
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = null,
|
||||
@@ -37,7 +39,7 @@ fun testTask(
|
||||
isRecurring = false,
|
||||
occurrenceStart = null,
|
||||
distanceFromCurrent = 0,
|
||||
created = null,
|
||||
created = created,
|
||||
lastModified = null,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
@@ -1048,20 +1048,33 @@ class CollectionSyncerTest {
|
||||
assertThat(store.rows.single().remindersOff).isFalse()
|
||||
}
|
||||
|
||||
@Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() {
|
||||
// A 15-minute lead on an all-day task goes out as "on the day".
|
||||
@Test fun `an all-day reminder at a time of its own round-trips from midnight`() {
|
||||
// 09:00 on the day.
|
||||
store.rows += task(id = 1, uid = "a", title = "Pay rent")
|
||||
.copy(due = Instant.parse("2026-03-05T00:00:00Z"), isAllDay = true, isDirty = true)
|
||||
store.alarms += alarm(1, 15, uid = "a-1")
|
||||
store.alarms += alarm(1, -9 * 60, uid = "a-1")
|
||||
sync()
|
||||
val sent = remote.resources.values.single()
|
||||
assertThat(sent.body).contains("TRIGGER;RELATED=END:PT9H")
|
||||
|
||||
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
|
||||
sync()
|
||||
|
||||
assertThat(store.alarms.single().minutesBefore).isEqualTo(-9 * 60)
|
||||
}
|
||||
|
||||
@Test fun `an alarm coming back as we sent it keeps the lead the wire could not carry`() {
|
||||
// A lead past due is clamped to "at due" on the way out.
|
||||
store.rows += task(id = 1, uid = "a", title = "Pay rent").copy(due = due, isDirty = true)
|
||||
store.alarms += alarm(1, -15, uid = "a-1")
|
||||
sync()
|
||||
val sent = remote.resources.values.single()
|
||||
assertThat(sent.body).contains("TRIGGER;RELATED=END:PT0S")
|
||||
|
||||
// The server hands it back under a new ETag, unchanged.
|
||||
sent.eTag = de.jeanlucmakiola.caldav.ETag("e-2", weak = false)
|
||||
sync()
|
||||
|
||||
assertThat(store.alarms.single().minutesBefore).isEqualTo(15)
|
||||
assertThat(store.alarms.single().minutesBefore).isEqualTo(-15)
|
||||
}
|
||||
|
||||
@Test fun `a before-start reminder on a task without a start survives the round trip`() {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Using Agendula's tasks from another app
|
||||
|
||||
When Agendula keeps tasks in its own database (the default storage mode), it
|
||||
publishes them through a `ContentProvider` that follows the
|
||||
[OpenTasks contract](https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java).
|
||||
An app that already works with OpenTasks or tasks.org can work with Agendula by
|
||||
using a different authority and permissions.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Authority | `de.jeanlucmakiola.agendula.tasks` (offline build: `de.jeanlucmakiola.agendula.offline.tasks`) |
|
||||
| Read permission | `de.jeanlucmakiola.agendula.permission.READ_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.READ_TASKS`) |
|
||||
| Write permission | `de.jeanlucmakiola.agendula.permission.WRITE_TASKS` (offline build: `de.jeanlucmakiola.agendula.offline.permission.WRITE_TASKS`) |
|
||||
| Protection level | `dangerous`: request them at runtime |
|
||||
| Package visibility | add `<provider android:authorities="de.jeanlucmakiola.agendula.tasks" />` to your `<queries>` |
|
||||
|
||||
Both names are derived from the installed package name, so every build has its
|
||||
own and they can be installed side by side: the authority is
|
||||
`<package>.tasks` and the permissions are `<package>.permission.READ_TASKS` and
|
||||
`<package>.permission.WRITE_TASKS`.
|
||||
|
||||
| Build | Package |
|
||||
|---|---|
|
||||
| Release | `de.jeanlucmakiola.agendula` |
|
||||
| Release, offline | `de.jeanlucmakiola.agendula.offline` |
|
||||
| Debug | `de.jeanlucmakiola.agendula.debug` |
|
||||
| Debug, offline | `de.jeanlucmakiola.agendula.offline.debug` |
|
||||
| Release test | `de.jeanlucmakiola.agendula.releasetest` |
|
||||
| Release test, offline | `de.jeanlucmakiola.agendula.offline.releasetest` |
|
||||
|
||||
## Tables
|
||||
|
||||
| URI | Rows |
|
||||
|---|---|
|
||||
| `content://<authority>/tasklists` | one per list |
|
||||
| `content://<authority>/tasks` | one per task; a modified occurrence of a recurring task is its own row with `original_instance_id` / `original_instance_time` set |
|
||||
| `content://<authority>/instances` | one per occurrence: recurring tasks are expanded up to one year back and two years ahead, and modified occurrences replace the ones they override |
|
||||
| `content://<authority>/properties` | reminders (`vnd.android.cursor.item/alarm`) and tags (`vnd.android.cursor.item/category`) |
|
||||
|
||||
Each table also accepts `/<id>`. Column names, value encodings (epoch millis,
|
||||
all-day dates as UTC midnight, `status`, `priority`, `class`) and MIME types
|
||||
are the OpenTasks ones. `selection`, `selectionArgs` and `sortOrder` work as
|
||||
they do against OpenTasks.
|
||||
|
||||
## Writing
|
||||
|
||||
Writes need the write permission and go through the same code as edits made in
|
||||
Agendula itself: a changed task is marked for the next CalDAV sync, reminders
|
||||
are re-armed, and widgets refresh.
|
||||
|
||||
| Table | insert | update | delete |
|
||||
|---|---|---|---|
|
||||
| `tasklists` | no | no | no |
|
||||
| `tasks` | yes | yes | yes |
|
||||
| `instances` | no | see below | one occurrence |
|
||||
| `properties` | alarms and tags | alarms and tags | alarms and tags |
|
||||
|
||||
`update` and `delete` take a `selection` like a query does, and it matches the
|
||||
rows you can read. `insert` returns the new row's URI. `applyBatch` runs in one
|
||||
transaction: if any operation fails, none of them is applied. A write that
|
||||
changes nothing does not mark the task for sync.
|
||||
|
||||
**Columns.** A task write may set `list_id`, `_uid` (on insert only), `title`,
|
||||
`description`, `location`, `url`, `priority`, `class`, `status`,
|
||||
`percent_complete`, `completed`, `task_color`, `dtstart`, `due`, `duration`,
|
||||
`is_allday`, `tz`, `rrule`, `rdate`, `exdate`, `parent_id` and `sorting`. Other
|
||||
columns the table publishes are ignored, so you can write back a row you read.
|
||||
A column the table does not have is rejected with `IllegalArgumentException`.
|
||||
Agendula's sync state (the server's URL and ETag, the iCalendar `SEQUENCE`, and
|
||||
properties it does not understand) is not published and cannot be written. A
|
||||
timed task without a `tz` is floating and stays floating. A task inserted
|
||||
without a `tz` is floating, as in OpenTasks; an existing task that first gets a
|
||||
date takes the device's zone. `rdate` and `exdate` take comma-separated
|
||||
iCalendar values of one kind: all dates, all UTC date-times or all local
|
||||
date-times. A value written back exactly as read is kept, whatever its shape.
|
||||
|
||||
**Status.** `status`, `percent_complete` and `completed` stay consistent the way
|
||||
OpenTasks keeps them. Completing a task sets 100 % and a completion time,
|
||||
progress between 1 and 99 sets "in process", and a completion time alone
|
||||
completes the task. When you write back a row you read, the column you changed
|
||||
decides; the others it carries unchanged do not.
|
||||
|
||||
**Recurring tasks.** Writing `status`, `percent_complete` or `completed` to an
|
||||
`instances` row completes, cancels or reopens that one occurrence; partial
|
||||
progress is rejected. Other content columns written to an instance change the
|
||||
whole series, and that occurrence too if it had been modified on its own.
|
||||
Changing a time or recurrence column (`dtstart`, `due`, `duration`,
|
||||
`is_allday`, `tz`, `rrule`, `rdate`, `exdate`) is rejected on an instance of a
|
||||
recurring task, so move a series through `tasks`; writing those columns back
|
||||
unchanged, as in a row you read, is fine. Moving a series' start or changing its
|
||||
rule drops the occurrences that had been modified on their own, as it does in
|
||||
the app. Deleting an `instances` row deletes that one occurrence.
|
||||
|
||||
**Subtasks.** A subtask has to be in its parent's list. Moving a task to another
|
||||
list moves its subtasks with it, and deleting a task deletes them. A task in a
|
||||
synced list stays as a hidden tombstone until the next sync removes it from the
|
||||
server.
|
||||
|
||||
**Rejected writes** throw `IllegalArgumentException` for a bad value, a missing
|
||||
list or parent, or a parent in another list. They throw
|
||||
`UnsupportedOperationException` for lists and for tasks in a list the server
|
||||
has made read-only, and `IllegalStateException` while Agendula works on top of
|
||||
an external provider or is still starting. `list_access_level` does not tell
|
||||
you which lists are read-only: OpenTasks reserves it and asks providers to
|
||||
publish `0`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Change notifications.** Register a `ContentObserver` on
|
||||
`content://<authority>` with `notifyForDescendants = true`. It fires when
|
||||
tasks change or when the user switches storage mode.
|
||||
- **External storage mode.** When the user has Agendula work on top of
|
||||
OpenTasks or tasks.org, every table is empty and writes are refused. Use that
|
||||
provider directly.
|
||||
- **Ids are stable.** A task keeps its `_id`. An instance's `_id` is built
|
||||
from its series and its original time, and a property's from its alarm or
|
||||
its task and tag, so they stay the same across reads and within a batch for
|
||||
as long as that occurrence, alarm or tag exists. `distance_from_current`
|
||||
still moves with the clock, and observers are notified hourly when it does.
|
||||
+18
-4
@@ -53,8 +53,10 @@ All of this is verifiable in the
|
||||
## 3. Where your tasks live — your choice
|
||||
|
||||
- **On your device (the default)** — your task lists, tasks and reminders are
|
||||
kept in Agendula's own database inside the app's private storage. Nothing is
|
||||
published to other apps, and uninstalling the app removes it.
|
||||
kept in Agendula's own database inside the app's private storage, and
|
||||
uninstalling the app removes it. Another app can read these tasks only if you
|
||||
grant it Agendula's "read Agendula tasks" permission, and change them only if
|
||||
you grant it "add, change and delete Agendula tasks" (see section 9).
|
||||
- **In a tasks provider you already use** — OpenTasks or tasks.org. Agendula
|
||||
then reads and writes that app's task database through Android's provider
|
||||
mechanism, after you grant its read/write permission. Whatever already
|
||||
@@ -210,8 +212,20 @@ source:
|
||||
used for scheduled background work (WorkManager); on older Android versions
|
||||
it needs them to run an expedited sync.
|
||||
|
||||
Agendula publishes no content provider of its own and declares no permissions
|
||||
that other apps could request.
|
||||
Agendula declares two permissions that other apps can request. Android asks you
|
||||
before granting either, and you can revoke them at any time in the system
|
||||
settings.
|
||||
|
||||
- `de.jeanlucmakiola.agendula.permission.READ_TASKS`: an app that holds it can
|
||||
read your task lists, tasks, reminders and tags from Agendula's own database.
|
||||
- `de.jeanlucmakiola.agendula.permission.WRITE_TASKS`: an app that holds it can
|
||||
add, change, complete and delete those tasks, reminders and tags. It cannot
|
||||
change or delete task lists. Its changes sync to your CalDAV server like your
|
||||
own edits.
|
||||
|
||||
Neither gives access to anything while Agendula works on top of an external
|
||||
tasks provider. What an app does with your tasks is governed by its own privacy
|
||||
policy.
|
||||
|
||||
## 10. Distribution channels
|
||||
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 0b44d353d2...68d6432434
Reference in New Issue
Block a user