From 2b9d8a2d3c84d82e99c0e463cdd37ff8bfe2ea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milosch=20F=C3=BCllgraf?= Date: Thu, 7 Aug 2025 21:25:55 +0200 Subject: [PATCH 1/3] Add support for syncing estimated and elapsed fields using CalDAV (#3780) --- .../main/java/org/tasks/caldav/iCalendar.kt | 37 +++++++++++++++++++ .../java/org/tasks/caldav/iCalendarMerge.kt | 16 ++++++++ 2 files changed, 53 insertions(+) diff --git a/app/src/main/java/org/tasks/caldav/iCalendar.kt b/app/src/main/java/org/tasks/caldav/iCalendar.kt index a98c3148a..654ce9ea7 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendar.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendar.kt @@ -296,11 +296,16 @@ class iCalendar @Inject constructor( it === RelType.PARENT || it == null || it.value.isNullOrBlank() } } + // custom X- properties to sync internal fields + private const val TASKS_ESTIMATED_SECONDS = "X-TASKS-ESTIMATED-SECONDS" + private const val TASKS_ELAPSED_SECONDS = "X-TASKS-ELAPSED-SECONDS" internal val IS_APPLE_SORT_ORDER = { x: Property? -> x?.name.equals(APPLE_SORT_ORDER, true) } private val IS_OC_HIDESUBTASKS = { x: Property? -> x?.name.equals(OC_HIDESUBTASKS, true) } private val IS_MOZ_SNOOZE_TIME = { x: Property? -> x?.name.equals(MOZ_SNOOZE_TIME, true) } private val IS_MOZ_LASTACK = { x: Property? -> x?.name.equals(MOZ_LASTACK, true) } + private val IS_TASKS_ESTIMATED_SECONDS = { x: Property? -> x?.name.equals(TASKS_ESTIMATED_SECONDS, true) } + private val IS_TASKS_ELAPSED_SECONDS = { x: Property? -> x?.name.equals(TASKS_ELAPSED_SECONDS, true) } fun Due?.apply(task: org.tasks.data.entity.Task) { task.dueDate = toMillis() @@ -442,6 +447,36 @@ class iCalendar @Inject constructor( ?: unknownProperties.removeIf(IS_MOZ_SNOOZE_TIME) } + var Task.estimatedSeconds: Int? + get() = unknownProperties.find(IS_TASKS_ESTIMATED_SECONDS)?.value?.toInt() + set(value) { + value + ?.takeIf { it != 0 } + ?.let { dur -> + unknownProperties.find(IS_TASKS_ESTIMATED_SECONDS) + ?.let { it.value = dur.toString() } + ?: unknownProperties.add( + XProperty(TASKS_ESTIMATED_SECONDS, dur.toString()) + ) + } + ?: unknownProperties.removeIf(IS_TASKS_ESTIMATED_SECONDS) + } + + var Task.elapsedSeconds: Int? + get() = unknownProperties.find(IS_TASKS_ELAPSED_SECONDS)?.value?.toInt() + set(value) { + value + ?.takeIf { it != 0 } + ?.let { dur -> + unknownProperties.find(IS_TASKS_ELAPSED_SECONDS) + ?.let { it.value = dur.toString() } + ?: unknownProperties.add( + XProperty(TASKS_ELAPSED_SECONDS, dur.toString()) + ) + } + ?: unknownProperties.removeIf(IS_TASKS_ELAPSED_SECONDS) + } + fun Task.applyLocal(caldavTask: CaldavTask, task: org.tasks.data.entity.Task) { createdAt = newDateTime(task.creationDate).toUTC().millis summary = task.title @@ -494,6 +529,8 @@ class iCalendar @Inject constructor( parent = if (task.parent == 0L) null else caldavTask.remoteParent order = task.order collapsed = task.isCollapsed + estimatedSeconds = task.estimatedSeconds + elapsedSeconds = task.elapsedSeconds } val List.filtered: List diff --git a/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt b/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt index a9070be91..64d8571f9 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt @@ -3,6 +3,8 @@ package org.tasks.caldav import at.bitfire.ical4android.Task import net.fortuna.ical4j.model.property.Status import org.tasks.caldav.iCalendar.Companion.collapsed +import org.tasks.caldav.iCalendar.Companion.elapsedSeconds +import org.tasks.caldav.iCalendar.Companion.estimatedSeconds import org.tasks.caldav.iCalendar.Companion.getLocal import org.tasks.caldav.iCalendar.Companion.order import org.tasks.caldav.iCalendar.Companion.parent @@ -32,6 +34,8 @@ fun org.tasks.data.entity.Task.applyRemote( applyStart(remote, local) applyCollapsed(remote, local) applyOrder(remote, local) + applyEstimatedSeconds(remote, local) + applyElapsedSeconds(remote, local) return this } @@ -121,6 +125,18 @@ private fun CaldavTask.applyParent(remote: Task, local: Task?) { } } +private fun org.tasks.data.entity.Task.applyEstimatedSeconds(remote: Task, local: Task?) { + if (local == null || local.estimatedSeconds == estimatedSeconds) { + estimatedSeconds = remote.estimatedSeconds ?: 0 + } +} + +private fun org.tasks.data.entity.Task.applyElapsedSeconds(remote: Task, local: Task?) { + if (local == null || local.elapsedSeconds == elapsedSeconds) { + elapsedSeconds = remote.elapsedSeconds ?: 0 + } +} + private val Task.tasksPriority: Int get() = when (this.priority) { // https://tools.ietf.org/html/rfc5545#section-3.8.1.9 From d745879e1ea381d2a1b3360b863dc806ca1715a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milosch=20F=C3=BCllgraf?= Date: Thu, 7 Aug 2025 21:58:08 +0200 Subject: [PATCH 2/3] Add tests for sync --- .../org/tasks/caldav/iCalendarMergeTest.kt | 88 +++++++++++++++++++ .../test/java/org/tasks/makers/TaskMaker.kt | 4 + .../test/java/org/tasks/makers/iCalMaker.kt | 6 ++ 3 files changed, 98 insertions(+) diff --git a/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt b/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt index 60d8a91b1..eccf925d7 100644 --- a/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt +++ b/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt @@ -655,6 +655,94 @@ class iCalendarMergeTest { assertEquals("789", it.remoteParent) } + @Test + fun remoteSetsEstimated() = + newTask() + .applyRemote( + remote = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 2*60*60)), + local = null + ) + .let { + assertEquals(2*60*60, it.estimatedSeconds) + } + + @Test + fun remoteRemovesEstimated() = + newTask(with(TaskMaker.ESTIMATED_SECONDS, 2*60*60)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 2*60*60)) + ) + .let { + assertEquals(0, it.estimatedSeconds) + } + + @Test + fun localResetsEstimated() = + newTask() + .applyRemote( + remote = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 2*60*60)), + local = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 2*60*60)) + ) + .let { + assertEquals(0, it.estimatedSeconds) + } + + @Test + fun localBeatsRemoteEstimated() = + newTask(with(TaskMaker.ESTIMATED_SECONDS, 2*60*60)) + .applyRemote( + remote = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 4*60*60)), + local = newIcal(with(iCalMaker.ESTIMATED_SECONDS, 3*60*60)) + ) + .let { + assertEquals(2*60*60, it.estimatedSeconds) + } + + @Test + fun remoteSetsElapsed() = + newTask() + .applyRemote( + remote = newIcal(with(iCalMaker.ELAPSED_SECONDS, 2*60*60)), + local = null + ) + .let { + assertEquals(2*60*60, it.elapsedSeconds) + } + + @Test + fun remoteRemovesElapsed() = + newTask(with(TaskMaker.ELAPSED_SECONDS, 2*60*60)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(iCalMaker.ELAPSED_SECONDS, 2*60*60)) + ) + .let { + assertEquals(0, it.elapsedSeconds) + } + + @Test + fun localResetsElapsed() = + newTask() + .applyRemote( + remote = newIcal(with(iCalMaker.ELAPSED_SECONDS, 2*60*60)), + local = newIcal(with(iCalMaker.ELAPSED_SECONDS, 2*60*60)) + ) + .let { + assertEquals(0, it.elapsedSeconds) + } + + @Test + fun localBeatsRemoteElapsed() = + newTask(with(TaskMaker.ELAPSED_SECONDS, 2*60*60)) + .applyRemote( + remote = newIcal(with(iCalMaker.ELAPSED_SECONDS, 4*60*60)), + local = newIcal(with(iCalMaker.ELAPSED_SECONDS, 3*60*60)) + ) + .let { + assertEquals(2*60*60, it.elapsedSeconds) + } + companion object { private fun DateTime.allDay() = createDueDate(URGENCY_SPECIFIC_DAY, millis) diff --git a/app/src/test/java/org/tasks/makers/TaskMaker.kt b/app/src/test/java/org/tasks/makers/TaskMaker.kt index ecf42fd7a..d005b3cfa 100644 --- a/app/src/test/java/org/tasks/makers/TaskMaker.kt +++ b/app/src/test/java/org/tasks/makers/TaskMaker.kt @@ -35,6 +35,8 @@ object TaskMaker { val COLLAPSED: Property = newProperty() val DESCRIPTION: Property = newProperty() val ORDER: Property = newProperty() + val ESTIMATED_SECONDS: Property = newProperty() + val ELAPSED_SECONDS: Property = newProperty() private val instantiator = Instantiator { lookup: PropertyLookup -> val creationTime = lookup.valueOf(CREATION_TIME, DateTimeUtils.newDateTime()) @@ -62,6 +64,8 @@ object TaskMaker { order = lookup.valueOf(ORDER, null as Long?), creationDate = creationTime.millis, modificationDate = lookup.valueOf(MODIFICATION_TIME, creationTime).millis, + estimatedSeconds = lookup.valueOf(ESTIMATED_SECONDS, 0), + elapsedSeconds = lookup.valueOf(ELAPSED_SECONDS, 0) ) lookup.valueOf(START_DATE, null as DateTime?)?.let { task.hideUntil = task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY, it.millis) diff --git a/app/src/test/java/org/tasks/makers/iCalMaker.kt b/app/src/test/java/org/tasks/makers/iCalMaker.kt index 5c49f547b..0e44dc574 100644 --- a/app/src/test/java/org/tasks/makers/iCalMaker.kt +++ b/app/src/test/java/org/tasks/makers/iCalMaker.kt @@ -14,6 +14,8 @@ import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.Status import org.tasks.caldav.iCalendar import org.tasks.caldav.iCalendar.Companion.collapsed +import org.tasks.caldav.iCalendar.Companion.elapsedSeconds +import org.tasks.caldav.iCalendar.Companion.estimatedSeconds import org.tasks.caldav.iCalendar.Companion.order import org.tasks.caldav.iCalendar.Companion.parent import org.tasks.time.DateTime @@ -35,6 +37,8 @@ object iCalMaker { val COLLAPSED: Property = newProperty() val RRULE: Property = newProperty() val STATUS: Property = newProperty() + val ESTIMATED_SECONDS: Property = newProperty() + val ELAPSED_SECONDS: Property = newProperty() private val instantiator = Instantiator { lookup: PropertyLookup -> val task = Task() @@ -65,6 +69,8 @@ object iCalMaker { task.collapsed = lookup.valueOf(COLLAPSED, false) task.rRule = lookup.valueOf(RRULE, null as String?)?.let { RRule(it) } task.status = lookup.valueOf(STATUS, null as Status?) + task.estimatedSeconds = lookup.valueOf(ESTIMATED_SECONDS, 0) + task.elapsedSeconds = lookup.valueOf(ELAPSED_SECONDS, 0) task } fun newIcal(vararg properties: PropertyValue): Task { From 30df00175d4d7159abfbaff630326be06a97d236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milosch=20F=C3=BCllgraf?= Date: Thu, 7 Aug 2025 21:59:17 +0200 Subject: [PATCH 3/3] Add migration to re-upload affected existing tasks --- app/src/main/java/com/todoroo/astrid/service/Upgrader.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt index 829bffc3b..704f67aca 100644 --- a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt +++ b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt @@ -150,6 +150,12 @@ class Upgrader @Inject constructor( } } } + run(from, V14_7_5) { + upgraderDao.tasksWithVtodos() + .filter { it.task.estimatedSeconds > 0 || it.task.elapsedSeconds > 0 } + .map { it.id } + .let { taskDao.touch(it) } + } run(from, V14_8) { WorkManager.getInstance(context).enqueueUniqueWork( uniqueWorkName = "upload_icons", @@ -421,6 +427,7 @@ class Upgrader @Inject constructor( const val V12_8 = 120800 const val V14_5_4 = 140516 const val V14_6_1 = 140602 + const val V14_7_5 = 140705 const val V14_8 = 140800 @JvmStatic