From 42e44eafdc5512bc7b56386320f87e685873fe24 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 14 Feb 2022 17:19:39 -0600 Subject: [PATCH] Merge remote changes before pushing local changes Only applies to native CalDAV and native EteSync --- .../java/org/tasks/TestUtilities.kt | 6 +- .../java/org/tasks/makers/TaskMaker.kt | 7 + .../java/org/tasks/makers/iCalMaker.kt | 73 ++ .../org/tasks/caldav/CaldavSynchronizer.kt | 12 +- .../main/java/org/tasks/caldav/GeoUtils.kt | 9 +- .../main/java/org/tasks/caldav/iCalendar.kt | 107 ++- .../java/org/tasks/caldav/iCalendarMerge.kt | 132 ++++ .../org/tasks/etebase/EtebaseSynchronizer.kt | 18 +- .../tasks/opentasks/OpenTasksSynchronizer.kt | 48 +- .../main/java/org/tasks/time/DateTime.java | 6 + .../main/java/org/tasks/time/DateTimeUtils.kt | 2 + .../org/tasks/caldav/iCalendarMergeTest.kt | 660 ++++++++++++++++++ 12 files changed, 978 insertions(+), 102 deletions(-) create mode 100644 app/src/commonTest/java/org/tasks/makers/iCalMaker.kt create mode 100644 app/src/main/java/org/tasks/caldav/iCalendarMerge.kt create mode 100644 app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt diff --git a/app/src/commonTest/java/org/tasks/TestUtilities.kt b/app/src/commonTest/java/org/tasks/TestUtilities.kt index d252d1e52..2ac21a004 100644 --- a/app/src/commonTest/java/org/tasks/TestUtilities.kt +++ b/app/src/commonTest/java/org/tasks/TestUtilities.kt @@ -4,7 +4,7 @@ import android.content.Context import at.bitfire.ical4android.Task.Companion.tasksFromReader import com.todoroo.astrid.data.Task import kotlinx.coroutines.runBlocking -import org.tasks.caldav.iCalendar.Companion.applyRemote +import org.tasks.caldav.applyRemote import org.tasks.caldav.iCalendar.Companion.reminders import org.tasks.data.Alarm import org.tasks.data.CaldavTask @@ -42,7 +42,7 @@ object TestUtilities { fun vtodo(path: String): Task { val task = Task() - task.applyRemote(fromResource(path)) + task.applyRemote(fromResource(path), null) return task } @@ -53,7 +53,7 @@ object TestUtilities { val task = Task() val vtodo = readFile(path) val remote = fromString(vtodo) - task.applyRemote(remote) + task.applyRemote(remote, null) return Triple(task, CaldavTask().apply { this.vtodo = vtodo }, remote) } diff --git a/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt b/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt index 7ca837c99..70ef880e3 100644 --- a/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt +++ b/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt @@ -6,6 +6,7 @@ import com.natpryce.makeiteasy.Property.newProperty import com.natpryce.makeiteasy.PropertyLookup import com.natpryce.makeiteasy.PropertyValue import com.todoroo.astrid.data.Task +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY import com.todoroo.astrid.data.Task.Companion.NO_UUID import org.tasks.Strings import org.tasks.date.DateTimeUtils @@ -16,6 +17,7 @@ object TaskMaker { val ID: Property = newProperty() val DUE_DATE: Property = newProperty() val DUE_TIME: Property = newProperty() + val START_DATE: Property = newProperty() val REMINDER_LAST: Property = newProperty() val HIDE_TYPE: Property = newProperty() val REMINDERS: Property = newProperty() @@ -30,6 +32,7 @@ object TaskMaker { val PARENT: Property = newProperty() val UUID: Property = newProperty() val COLLAPSED: Property = newProperty() + val DESCRIPTION: Property = newProperty() private val instantiator = Instantiator { lookup: PropertyLookup -> val task = Task() @@ -61,6 +64,9 @@ object TaskMaker { if (deletedTime != null) { task.deletionDate = deletedTime.millis } + lookup.valueOf(START_DATE, null as DateTime?)?.let { + task.hideUntil = task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY, it.millis) + } val hideType = lookup.valueOf(HIDE_TYPE, -1) if (hideType >= 0) { task.hideUntil = task.createHideUntil(hideType, 0) @@ -76,6 +82,7 @@ object TaskMaker { lookup.valueOf(RECUR, null as String?)?.let { task.setRecurrence(it, lookup.valueOf(AFTER_COMPLETE, false)) } + task.notes = lookup.valueOf(DESCRIPTION, null as String?) task.isCollapsed = lookup.valueOf(COLLAPSED, false) task.uuid = lookup.valueOf(UUID, NO_UUID) val creationTime = lookup.valueOf(CREATION_TIME, DateTimeUtils.newDateTime()) diff --git a/app/src/commonTest/java/org/tasks/makers/iCalMaker.kt b/app/src/commonTest/java/org/tasks/makers/iCalMaker.kt new file mode 100644 index 000000000..5c49f547b --- /dev/null +++ b/app/src/commonTest/java/org/tasks/makers/iCalMaker.kt @@ -0,0 +1,73 @@ +package org.tasks.makers + +import at.bitfire.ical4android.Task +import com.natpryce.makeiteasy.Instantiator +import com.natpryce.makeiteasy.Property +import com.natpryce.makeiteasy.Property.newProperty +import com.natpryce.makeiteasy.PropertyLookup +import com.natpryce.makeiteasy.PropertyValue +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Priority +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.order +import org.tasks.caldav.iCalendar.Companion.parent +import org.tasks.time.DateTime +import org.tasks.time.DateTimeUtils.toDate + +@Suppress("ClassName") +object iCalMaker { + val TITLE: Property = newProperty() + val DESCRIPTION: Property = newProperty() + val DUE_DATE: Property = newProperty() + val DUE_TIME: Property = newProperty() + val START_DATE: Property = newProperty() + val START_TIME: Property = newProperty() + val CREATED_AT: Property = newProperty() + val COMPLETED_AT: Property = newProperty() + val ORDER: Property = newProperty() + val PARENT: Property = newProperty() + val PRIORITY: Property = newProperty() + val COLLAPSED: Property = newProperty() + val RRULE: Property = newProperty() + val STATUS: Property = newProperty() + + private val instantiator = Instantiator { lookup: PropertyLookup -> + val task = Task() + lookup.valueOf(CREATED_AT, null as DateTime?)?.let { + task.createdAt = it.millis + } + lookup.valueOf(DUE_DATE, null as DateTime?)?.let { + task.due = Due(it.millis.toDate()) + } + lookup.valueOf(DUE_TIME, null as DateTime?)?.let { + task.due = Due(iCalendar.getDateTime(it.millis)) + } + lookup.valueOf(START_DATE, null as DateTime?)?.let { + task.dtStart = DtStart(it.millis.toDate()) + } + lookup.valueOf(START_TIME, null as DateTime?)?.let { + task.dtStart = DtStart(iCalendar.getDateTime(it.millis)) + } + lookup.valueOf(COMPLETED_AT, null as DateTime?)?.let { + task.completedAt = Completed(iCalendar.getDateTime(it.millis)) + task.status = Status.VTODO_COMPLETED + } + task.order = lookup.valueOf(ORDER, null as Long?) + task.summary = lookup.valueOf(TITLE, null as String?) + task.parent = lookup.valueOf(PARENT, null as String?) + task.description = lookup.valueOf(DESCRIPTION, null as String?) + task.priority = lookup.valueOf(PRIORITY, Priority.UNDEFINED.level) + 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 + } + fun newIcal(vararg properties: PropertyValue): Task { + return Maker.make(instantiator, *properties) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt index 63e4afe31..8b15d0351 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt @@ -196,7 +196,10 @@ class CaldavSynchronizer @Inject constructor( resource .principals(account, calendar) .let { principalDao.deleteRemoved(calendar.id, it.map(PrincipalAccess::id)) } - sync(calendar, resource, caldavClient.httpClient) + fetchChanges(calendar, resource, caldavClient.httpClient) + if (calendar.access != ACCESS_READ_ONLY) { + pushLocalChanges(calendar, caldavClient.httpClient, resource.href) + } } setError(account, "") } @@ -218,20 +221,17 @@ class CaldavSynchronizer @Inject constructor( } } - private suspend fun sync( + private suspend fun fetchChanges( caldavCalendar: CaldavCalendar, resource: Response, httpClient: OkHttpClient) { - Timber.d("sync(%s)", caldavCalendar) val httpUrl = resource.href - if (caldavCalendar.access != ACCESS_READ_ONLY) { - pushLocalChanges(caldavCalendar, httpClient, httpUrl) - } val remoteCtag = resource.ctag if (caldavCalendar.ctag?.equals(remoteCtag) == true) { Timber.d("%s up to date", caldavCalendar.name) return } + Timber.d("updating $caldavCalendar") val davCalendar = DavCalendar(httpClient, httpUrl) val members = ArrayList() davCalendar.calendarQuery("VTODO", null, null) { response, relation -> diff --git a/app/src/main/java/org/tasks/caldav/GeoUtils.kt b/app/src/main/java/org/tasks/caldav/GeoUtils.kt index 224c8a04b..e74c8f6db 100644 --- a/app/src/main/java/org/tasks/caldav/GeoUtils.kt +++ b/app/src/main/java/org/tasks/caldav/GeoUtils.kt @@ -2,15 +2,14 @@ package org.tasks.caldav import net.fortuna.ical4j.model.property.Geo import org.tasks.data.Location +import org.tasks.data.Place import java.math.BigDecimal import kotlin.math.min object GeoUtils { - fun toGeo(location: Location?) = if (location == null) { - null - } else { - Geo("${location.latitude};${location.longitude}") - } + fun toGeo(location: Location?) = location?.place?.toGeo() + + fun Place.toGeo() = Geo("$latitude;$longitude") fun Geo.latitudeLike() = latitude.toLikeString() diff --git a/app/src/main/java/org/tasks/caldav/iCalendar.kt b/app/src/main/java/org/tasks/caldav/iCalendar.kt index a20a86d8e..9640b2166 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendar.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendar.kt @@ -3,7 +3,6 @@ package org.tasks.caldav import at.bitfire.ical4android.DateUtils.ical4jTimeZone import at.bitfire.ical4android.Task import at.bitfire.ical4android.Task.Companion.tasksFromReader -import com.todoroo.andlib.utility.DateUtilities import com.todoroo.astrid.alarms.AlarmService import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY @@ -17,7 +16,6 @@ import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.RelType -import net.fortuna.ical4j.model.parameter.Related.* import net.fortuna.ical4j.model.property.Action import net.fortuna.ical4j.model.property.Completed import net.fortuna.ical4j.model.property.DateProperty @@ -53,7 +51,6 @@ import org.tasks.location.GeofenceApi import org.tasks.preferences.Preferences import org.tasks.repeats.RecurrenceUtils.newRRule import org.tasks.repeats.RecurrenceUtils.newRecur -import org.tasks.time.DateTime.UTC import org.tasks.time.DateTimeUtils.startOfDay import org.tasks.time.DateTimeUtils.startOfMinute import org.tasks.time.DateTimeUtils.toDate @@ -139,14 +136,10 @@ class iCalendar @Inject constructor( remoteModel = Task() } - toVtodo(caldavTask, task, remoteModel) - - val os = ByteArrayOutputStream() - remoteModel.write(os) - return os.toByteArray() + return toVtodo(caldavTask, task, remoteModel) } - suspend fun toVtodo(caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task, remoteModel: Task) { + suspend fun toVtodo(caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task, remoteModel: Task): ByteArray { remoteModel.applyLocal(caldavTask, task) val categories = remoteModel.categories categories.clear() @@ -167,6 +160,9 @@ class iCalendar @Inject constructor( val alarms = alarmDao.getAlarms(task.id) remoteModel.snooze = alarms.find { it.type == TYPE_SNOOZE }?.time remoteModel.alarms.addAll(alarms.toVAlarms()) + val os = ByteArrayOutputStream() + remoteModel.write(os) + return os.toByteArray() } suspend fun fromVtodo( @@ -175,29 +171,47 @@ class iCalendar @Inject constructor( remote: Task, vtodo: String?, obj: String? = null, - eTag: String? = null) { + eTag: String? = null + ) { val task = existing?.task?.let { taskDao.fetch(it) } ?: taskCreator.createWithValues("").apply { taskDao.createNew(this) existing?.task = id } val caldavTask = existing ?: CaldavTask(task.id, calendar.uuid, remote.uid, obj) - task.applyRemote(remote) - setPlace(task.id, remote.geoPosition) - tagDao.applyTags(task, tagDataDao, getTags(remote.categories)) - val randomReminders = alarmDao.getAlarms(task.id).filter { it.type == TYPE_RANDOM } - alarmService.synchronizeAlarms( - caldavTask.task, - remote.reminders.plus(randomReminders).toMutableSet() - ) + val dirty = task.modificationDate > caldavTask.lastSync || caldavTask.lastSync == 0L + val local = caldavTask.vtodo?.let { fromVtodo(it) } + task.applyRemote(remote, local) + caldavTask.applyRemote(remote, local) + + val place = locationDao.getPlaceForTask(task.id) + if (place?.toGeo() == local?.geoPosition) { + setPlace(task.id, remote.geoPosition) + } + + val tags = tagDataDao.getTagDataForTask(task.id) + val localTags = getTags(local?.categories ?: emptyList()) + if (tags.toSet() == localTags.toSet()) { + tagDao.applyTags(task, tagDataDao, getTags(remote.categories)) + } + + val alarms = alarmDao.getAlarms(task.id) + val randomReminders = alarms.filter { it.type == TYPE_RANDOM } + val localReminders = + local?.reminders?.plus(randomReminders) ?: randomReminders + if (alarms.toSet() == localReminders.toSet()) { + val remoteReminders = remote.reminders.plus(randomReminders) + alarmService.synchronizeAlarms(caldavTask.task, remoteReminders.toMutableSet()) + } + task.suppressSync() task.suppressRefresh() taskDao.save(task) caldavTask.vtodo = vtodo caldavTask.etag = eTag - caldavTask.lastSync = task.modificationDate - caldavTask.remoteParent = remote.parent - caldavTask.order = remote.order + if (!dirty) { + caldavTask.lastSync = task.modificationDate + } if (caldavTask.id == com.todoroo.astrid.data.Task.NO_ID) { caldavTask.id = caldavDao.insert(caldavTask) Timber.d("NEW %s", caldavTask) @@ -227,26 +241,32 @@ class iCalendar @Inject constructor( private val IS_MOZ_LASTACK = { x: Property? -> x?.name.equals(MOZ_LASTACK, true) } fun Due?.apply(task: com.todoroo.astrid.data.Task) { - task.dueDate = when (this?.date) { + task.dueDate = toMillis() + } + + fun Due?.toMillis() = + when (this?.date) { null -> 0 is DateTime -> com.todoroo.astrid.data.Task.createDueDate( - URGENCY_SPECIFIC_DAY_TIME, - getLocal(this) + URGENCY_SPECIFIC_DAY_TIME, + getLocal(this) ) else -> com.todoroo.astrid.data.Task.createDueDate( - URGENCY_SPECIFIC_DAY, - getLocal(this) + URGENCY_SPECIFIC_DAY, + getLocal(this) ) } - } fun DtStart?.apply(task: com.todoroo.astrid.data.Task) { - task.hideUntil = when (this?.date) { + task.hideUntil = toMillis(task) + } + + fun DtStart?.toMillis(task: com.todoroo.astrid.data.Task) = + when (this?.date) { null -> 0 is DateTime -> task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY_TIME, getLocal(this)) else -> task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY, getLocal(this)) } - } internal fun getLocal(property: DateProperty): Long = org.tasks.time.DateTime.from(property.date)?.toLocal()?.millis ?: 0 @@ -334,35 +354,6 @@ class iCalendar @Inject constructor( ?: unknownProperties.removeIf(IS_MOZ_SNOOZE_TIME) } - fun com.todoroo.astrid.data.Task.applyRemote(remote: Task) { - val completedAt = remote.completedAt - if (completedAt != null) { - completionDate = getLocal(completedAt) - } else if (remote.status === Status.VTODO_COMPLETED) { - if (!isCompleted) { - completionDate = DateUtilities.now() - } - } else { - completionDate = 0L - } - remote.createdAt?.let { - creationDate = newDateTime(it, UTC).toLocal().millis - } - title = remote.summary - notes = remote.description - priority = when (remote.priority) { - // https://tools.ietf.org/html/rfc5545#section-3.8.1.9 - in 1..4 -> com.todoroo.astrid.data.Task.Priority.HIGH - 5 -> com.todoroo.astrid.data.Task.Priority.MEDIUM - in 6..9 -> com.todoroo.astrid.data.Task.Priority.LOW - else -> com.todoroo.astrid.data.Task.Priority.NONE - } - setRecurrence(remote.rRule?.recur) - remote.due.apply(this) - remote.dtStart.apply(this) - isCollapsed = remote.collapsed - } - fun Task.applyLocal(caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task) { createdAt = newDateTime(task.creationDate).toUTC().millis summary = task.title diff --git a/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt b/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt new file mode 100644 index 000000000..89d70cb8a --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/iCalendarMerge.kt @@ -0,0 +1,132 @@ +package org.tasks.caldav + +import at.bitfire.ical4android.Task +import com.todoroo.andlib.utility.DateUtilities +import com.todoroo.astrid.data.Task.Companion.withoutFrom +import com.todoroo.astrid.data.Task.Priority.Companion.HIGH +import com.todoroo.astrid.data.Task.Priority.Companion.LOW +import com.todoroo.astrid.data.Task.Priority.Companion.MEDIUM +import com.todoroo.astrid.data.Task.Priority.Companion.NONE +import net.fortuna.ical4j.model.property.Status +import org.tasks.caldav.iCalendar.Companion.collapsed +import org.tasks.caldav.iCalendar.Companion.getLocal +import org.tasks.caldav.iCalendar.Companion.order +import org.tasks.caldav.iCalendar.Companion.parent +import org.tasks.caldav.iCalendar.Companion.toMillis +import org.tasks.data.CaldavTask +import org.tasks.date.DateTimeUtils.newDateTime +import org.tasks.time.DateTime.UTC +import org.tasks.time.DateTimeUtils.startOfMinute +import org.tasks.time.DateTimeUtils.startOfSecond + +fun com.todoroo.astrid.data.Task.applyRemote( + remote: Task, + local: Task? +): com.todoroo.astrid.data.Task { + applyCompletedAt(remote, local) + applyCreatedAt(remote, local) + applyTitle(remote, local) + applyDescription(remote, local) + applyPriority(remote, local) + applyRecurrence(remote, local) + applyDue(remote, local) + applyStart(remote, local) + applyCollapsed(remote, local) + return this +} + +fun CaldavTask.applyRemote(remote: Task, local: Task?): CaldavTask { + applyParent(remote, local) + applyOrder(remote, local) + return this +} + +private fun com.todoroo.astrid.data.Task.applyCompletedAt(remote: Task, local: Task?) { + if (local == null || + (local.completedAt?.let { getLocal(it) } ?: 0) == completionDate.startOfSecond() && + (local.status == Status.VTODO_COMPLETED) == isCompleted + ) { + val completedAt = remote.completedAt + if (completedAt != null) { + completionDate = getLocal(completedAt) + } else if (remote.status === Status.VTODO_COMPLETED) { + if (!isCompleted) { + completionDate = DateUtilities.now() + } + } else { + completionDate = 0L + } + } +} + +private fun com.todoroo.astrid.data.Task.applyCreatedAt(remote: Task, local: Task?) { + val localCreated = local?.createdAt?.let { newDateTime(it, UTC) }?.toLocal()?.millis + if (localCreated == null || localCreated == creationDate) { + remote.createdAt?.let { + creationDate = newDateTime(it, UTC).toLocal().millis + } + } +} + +private fun com.todoroo.astrid.data.Task.applyTitle(remote: Task, local: Task?) { + if (local == null || local.summary == title) { + title = remote.summary + } +} + +private fun com.todoroo.astrid.data.Task.applyDescription(remote: Task, local: Task?) { + if (local == null || local.description == notes) { + notes = remote.description + } +} + +private fun com.todoroo.astrid.data.Task.applyPriority(remote: Task, local: Task?) { + if (local == null || local.tasksPriority == priority) { + priority = remote.tasksPriority + } +} + +private fun com.todoroo.astrid.data.Task.applyRecurrence(remote: Task, local: Task?) { + if (local == null || local.rRule?.recur?.toString() == recurrence.withoutFrom()) { + setRecurrence(remote.rRule?.recur) + } +} + +private fun com.todoroo.astrid.data.Task.applyDue(remote: Task, local: Task?) { + if (local == null || local.due.toMillis() == dueDate) { + dueDate = remote.due.toMillis() + } +} + +private fun com.todoroo.astrid.data.Task.applyStart(remote: Task, local: Task?) { + if (local == null || local.dtStart.toMillis(this) == hideUntil) { + hideUntil = remote.dtStart.toMillis(this) + } +} + +private fun com.todoroo.astrid.data.Task.applyCollapsed(remote: Task, local: Task?) { + if (local == null || isCollapsed == local.collapsed) { + isCollapsed = remote.collapsed + } +} + +private fun CaldavTask.applyOrder(remote: Task, local: Task?) { + if (local == null || local.order == order) { + order = remote.order + } +} + +private fun CaldavTask.applyParent(remote: Task, local: Task?) { + if (local == null || local.parent == remoteParent) { + remoteParent = remote.parent + } +} + +private val Task.tasksPriority: Int + get() = when (this.priority) { + // https://tools.ietf.org/html/rfc5545#section-3.8.1.9 + in 1..4 -> HIGH + 5 -> MEDIUM + in 6..9 -> LOW + else -> NONE + } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/etebase/EtebaseSynchronizer.kt b/app/src/main/java/org/tasks/etebase/EtebaseSynchronizer.kt index 290267e40..6de7c5d55 100644 --- a/app/src/main/java/org/tasks/etebase/EtebaseSynchronizer.kt +++ b/app/src/main/java/org/tasks/etebase/EtebaseSynchronizer.kt @@ -5,7 +5,11 @@ import android.graphics.Color import at.bitfire.ical4android.ICalendar.Companion.prodId import com.etebase.client.Collection import com.etebase.client.Item -import com.etebase.client.exceptions.* +import com.etebase.client.exceptions.ConnectionException +import com.etebase.client.exceptions.PermissionDeniedException +import com.etebase.client.exceptions.ServerErrorException +import com.etebase.client.exceptions.TemporaryServerErrorException +import com.etebase.client.exceptions.UnauthorizedException import com.todoroo.astrid.helper.UUIDHelper import com.todoroo.astrid.service.TaskDeleter import dagger.hilt.android.qualifiers.ApplicationContext @@ -22,7 +26,6 @@ import org.tasks.data.CaldavCalendar import org.tasks.data.CaldavDao import org.tasks.time.DateTimeUtils.currentTimeMillis import timber.log.Timber -import java.util.* import javax.inject.Inject class EtebaseSynchronizer @Inject constructor( @@ -92,7 +95,8 @@ class EtebaseSynchronizer @Inject constructor( caldavDao.update(calendar) localBroadcastManager.broadcastRefreshList() } - sync(client, calendar, collection) + fetchChanges(client, calendar, collection) + pushLocalChanges(client, calendar, collection) } setError(account, "") } @@ -109,18 +113,16 @@ class EtebaseSynchronizer @Inject constructor( } } - private suspend fun sync( + private suspend fun fetchChanges( client: EtebaseClient, caldavCalendar: CaldavCalendar, collection: Collection ) { - Timber.d("sync(%s)", caldavCalendar) - pushLocalChanges(client, caldavCalendar, collection) - val localCtag = caldavCalendar.ctag - if (localCtag != null && localCtag == collection.stoken) { + if (caldavCalendar.ctag?.equals(collection.stoken) == true) { Timber.d("${caldavCalendar.name} up to date") return } + Timber.d("updating $caldavCalendar") client.fetchItems(collection, caldavCalendar) { (stoken, items) -> applyEntries(caldavCalendar, items, stoken) client.updateCache(collection, items) diff --git a/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt index 1c9bdda94..21488e1f0 100644 --- a/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt +++ b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt @@ -15,14 +15,18 @@ import org.tasks.analytics.Constants import org.tasks.analytics.Firebase import org.tasks.billing.Inventory import org.tasks.caldav.iCalendar -import org.tasks.data.* +import org.tasks.data.CaldavAccount +import org.tasks.data.CaldavCalendar +import org.tasks.data.CaldavDao +import org.tasks.data.CaldavTask +import org.tasks.data.MyAndroidTask +import org.tasks.data.OpenTaskDao import org.tasks.data.OpenTaskDao.Companion.isDavx5 import org.tasks.data.OpenTaskDao.Companion.isDecSync import org.tasks.data.OpenTaskDao.Companion.isEteSync import org.tasks.data.OpenTaskDao.Companion.newAccounts import org.tasks.data.OpenTaskDao.Companion.toLocalCalendar import timber.log.Timber -import java.util.* import javax.inject.Inject import javax.inject.Singleton @@ -86,7 +90,9 @@ class OpenTasksSynchronizer @Inject constructor( .forEach { taskDeleter.delete(it) } lists.forEach { val calendar = toLocalCalendar(it) - sync(account, calendar, it.ctag, it.id) + val isEteSync = account.uuid?.isEteSync() == true + pushChanges(isEteSync, calendar, it.id) + fetchChanges(isEteSync, calendar, it.ctag, it.id) } } @@ -107,37 +113,35 @@ class OpenTasksSynchronizer @Inject constructor( return local } - private suspend fun sync( - account: CaldavAccount, - calendar: CaldavCalendar, - ctag: String?, - listId: Long - ) { - Timber.d("SYNC $calendar") - val isEteSync = account.uuid?.isEteSync() == true - + private suspend fun pushChanges(isEteSync: Boolean, calendar: CaldavCalendar, listId: Long) { val moved = caldavDao.getMoved(calendar.uuid!!) val (deleted, updated) = taskDao - .getCaldavTasksToPush(calendar.uuid!!) - .partition { it.isDeleted } + .getCaldavTasksToPush(calendar.uuid!!) + .partition { it.isDeleted } (moved + deleted.map(Task::id).let { caldavDao.getTasks(it) }) - .mapNotNull { it.remoteId } - .map { openTaskDao.delete(listId, it) } - .let { openTaskDao.batch(it) } + .mapNotNull { it.remoteId } + .map { openTaskDao.delete(listId, it) } + .let { openTaskDao.batch(it) } caldavDao.delete(moved) taskDeleter.delete(deleted.map { it.id }) updated.forEach { push(it, listId, isEteSync) } + } - ctag?.let { - if (ctag == calendar.ctag) { - Timber.d("UP TO DATE: $calendar") - return@sync - } + private suspend fun fetchChanges( + isEteSync: Boolean, + calendar: CaldavCalendar, + ctag: String?, + listId: Long + ) { + if (calendar.ctag?.equals(ctag) == true) { + Timber.d("UP TO DATE: $calendar") + return } + Timber.d("SYNC $calendar") val etags = openTaskDao.getEtags(listId) etags.forEach { (uid, sync1, version) -> diff --git a/app/src/main/java/org/tasks/time/DateTime.java b/app/src/main/java/org/tasks/time/DateTime.java index 0c8cd5c61..874a9edfe 100644 --- a/app/src/main/java/org/tasks/time/DateTime.java +++ b/app/src/main/java/org/tasks/time/DateTime.java @@ -134,6 +134,12 @@ public class DateTime { return new DateTime(calendar); } + public DateTime startOfSecond() { + Calendar calendar = getCalendar(); + calendar.set(Calendar.MILLISECOND, 0); + return new DateTime(calendar); + } + public DateTime endOfMinute() { Calendar calendar = getCalendar(); calendar.set(Calendar.SECOND, 59); diff --git a/app/src/main/java/org/tasks/time/DateTimeUtils.kt b/app/src/main/java/org/tasks/time/DateTimeUtils.kt index b09003112..0555c8844 100644 --- a/app/src/main/java/org/tasks/time/DateTimeUtils.kt +++ b/app/src/main/java/org/tasks/time/DateTimeUtils.kt @@ -40,6 +40,8 @@ object DateTimeUtils { fun Long.startOfMinute(): Long = if (this > 0) toDateTime().startOfMinute().millis else 0 + fun Long.startOfSecond(): Long = if (this > 0) toDateTime().startOfSecond().millis else 0 + fun Long.millisOfDay(): Int = if (this > 0) toDateTime().millisOfDay else 0 fun Long.toDate(): net.fortuna.ical4j.model.Date? = this.toDateTime().toDate() diff --git a/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt b/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt new file mode 100644 index 000000000..7d5d9a4b2 --- /dev/null +++ b/app/src/test/java/org/tasks/caldav/iCalendarMergeTest.kt @@ -0,0 +1,660 @@ +@file:Suppress("ClassName") + +package org.tasks.caldav + +import com.natpryce.makeiteasy.MakeItEasy.with +import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY +import com.todoroo.astrid.data.Task.Companion.createDueDate +import com.todoroo.astrid.data.Task.Priority.Companion.HIGH +import com.todoroo.astrid.data.Task.Priority.Companion.LOW +import com.todoroo.astrid.data.Task.Priority.Companion.MEDIUM +import net.fortuna.ical4j.model.property.Status +import org.junit.Assert.* +import org.junit.Test +import org.tasks.date.DateTimeUtils.newDateTime +import org.tasks.makers.CaldavTaskMaker.REMOTE_ORDER +import org.tasks.makers.CaldavTaskMaker.REMOTE_PARENT +import org.tasks.makers.CaldavTaskMaker.newCaldavTask +import org.tasks.makers.TaskMaker +import org.tasks.makers.TaskMaker.COMPLETION_TIME +import org.tasks.makers.TaskMaker.CREATION_TIME +import org.tasks.makers.TaskMaker.newTask +import org.tasks.makers.iCalMaker.COLLAPSED +import org.tasks.makers.iCalMaker.COMPLETED_AT +import org.tasks.makers.iCalMaker.CREATED_AT +import org.tasks.makers.iCalMaker.DESCRIPTION +import org.tasks.makers.iCalMaker.DUE_DATE +import org.tasks.makers.iCalMaker.ORDER +import org.tasks.makers.iCalMaker.PARENT +import org.tasks.makers.iCalMaker.PRIORITY +import org.tasks.makers.iCalMaker.RRULE +import org.tasks.makers.iCalMaker.START_DATE +import org.tasks.makers.iCalMaker.STATUS +import org.tasks.makers.iCalMaker.TITLE +import org.tasks.makers.iCalMaker.newIcal +import org.tasks.time.DateTime + +class iCalendarMergeTest { + @Test + fun applyTitleNewTask() = + newTask() + .applyRemote( + remote = newIcal(with(TITLE, "Title")), + local = null + ) + .let { + assertEquals("Title", it.title) + } + + @Test + fun remoteUpdatedTitle() = + newTask(with(TaskMaker.TITLE, "Title")) + .applyRemote( + remote = newIcal(with(TITLE, "Title2")), + local = newIcal(with(TITLE, "Title")), + ) + .let { + assertEquals("Title2", it.title) + } + + @Test + fun localBeatsRemoteTitle() = + newTask(with(TaskMaker.TITLE, "Title3")) + .applyRemote( + remote = newIcal(with(TITLE, "Title2")), + local = newIcal(with(TITLE, "Title")), + ) + .let { + assertEquals("Title3", it.title) + } + + @Test + fun remoteRemovesTitle() = + newTask(with(TaskMaker.TITLE, "Title")) + .applyRemote( + remote = newIcal(with(TITLE, null as String?)), + local = newIcal(with(TITLE, "Title")), + ) + .let { + assertNull(it.title) + } + + @Test + fun localRemovesTitle() = + newTask(with(TaskMaker.TITLE, null as String?)) + .applyRemote( + remote = newIcal(with(TITLE, "Title")), + local = newIcal(with(TITLE, "Title")) + ) + .let { + assertNull(it.title) + } + + @Test + fun applyNewDescription() = + newTask() + .applyRemote( + remote = newIcal(with(DESCRIPTION, "Description")), + local = null + ) + .let { + assertEquals("Description", it.notes) + } + + @Test + fun localBeatsRemoteDescription() = + newTask(with(TaskMaker.DESCRIPTION, "Description3")) + .applyRemote( + remote = newIcal(with(DESCRIPTION, "Description2")), + local = newIcal(with(DESCRIPTION, "Description")) + ) + .let { + assertEquals("Description3", it.notes) + } + + @Test + fun remoteUpdatesDescription() { + newTask(with(TaskMaker.DESCRIPTION, "Description")) + .applyRemote( + remote = newIcal(with(DESCRIPTION, "Description2")), + local = newIcal(with(DESCRIPTION, "Description")) + ) + .let { + assertEquals("Description2", it.notes) + } + } + + @Test + fun localRemovedDescription() = + newTask(with(TaskMaker.DESCRIPTION, null as String?)) + .applyRemote( + remote = newIcal(with(DESCRIPTION, "Description")), + local = newIcal(with(DESCRIPTION, "Description")) + ) + .let { + assertNull(it.notes) + } + + @Test + fun remoteRemovedDescription() = + newTask(with(TaskMaker.DESCRIPTION, "Description")) + .applyRemote( + remote = newIcal(with(DESCRIPTION, null as String?)), + local = newIcal(with(DESCRIPTION, "Description")) + ) + .let { + assertNull(it.notes) + } + + @Test + fun applyPriorityNewTask() = + newTask(with(TaskMaker.PRIORITY, HIGH)) + .applyRemote( + remote = newIcal(with(PRIORITY, 5)), + local = null + ) + .let { + assertEquals(MEDIUM, it.priority) + } + + @Test + fun localUpdatedPriority() = + newTask(with(TaskMaker.PRIORITY, LOW)) + .applyRemote( + remote = newIcal(with(PRIORITY, 5)), + local = newIcal(with(PRIORITY, 5)) + ) + .let { + assertEquals(LOW, it.priority) + } + + @Test + fun remoteUpdatedPriority() = + newTask(with(TaskMaker.PRIORITY, MEDIUM)) + .applyRemote( + remote = newIcal(with(PRIORITY, 1)), + local = newIcal(with(PRIORITY, 5)) + ) + .let { + assertEquals(HIGH, it.priority) + } + + @Test + fun localBeatsRemotePriority() = + newTask(with(TaskMaker.PRIORITY, HIGH)) + .applyRemote( + remote = newIcal(with(PRIORITY, 1)), + local = newIcal(with(PRIORITY, 5)) + ) + .let { + assertEquals(HIGH, it.priority) + } + + @Test + fun dueDateNewTask() { + val due = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(DUE_DATE, due)), + local = null + ) + .let { + assertEquals(due.allDay(), it.dueDate) + } + } + + @Test + fun remoteAddsDueDate() { + val due = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(DUE_DATE, due)), + local = newIcal() + ) + .let { + assertEquals(due.allDay(), it.dueDate) + } + } + + @Test + fun remoteUpdatesDueDate() { + val due = newDateTime() + newTask(with(TaskMaker.DUE_DATE, due)) + .applyRemote( + remote = newIcal(with(DUE_DATE, due.plusDays(1))), + local = newIcal(with(DUE_DATE, due)) + ) + .let { + assertEquals(due.plusDays(1).allDay(), it.dueDate) + } + } + + @Test + fun remoteRemovesDueDate() { + val due = newDateTime() + newTask(with(TaskMaker.DUE_DATE, due)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(DUE_DATE, due)) + ) + .let { + assertEquals(0, it.dueDate) + } + } + + @Test + fun localRemovesDueDate() { + val due = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(DUE_DATE, due)), + local = newIcal(with(DUE_DATE, due)) + ) + .let { + assertEquals(0, it.dueDate) + } + } + + @Test + fun localBeatsRemoteDueDate() { + val due = newDateTime() + newTask(with(TaskMaker.DUE_DATE, due.plusDays(2))) + .applyRemote( + remote = newIcal(with(DUE_DATE, due.plusDays(1))), + local = newIcal(with(DUE_DATE, due)) + ) + .let { + assertEquals(due.plusDays(2).allDay(), it.dueDate) + } + } + + @Test + fun startDateNewTask() { + val start = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(START_DATE, start)), + local = null + ) + .let { + assertEquals(start.startOfDay().millis, it.hideUntil) + } + } + + @Test + fun remoteAddsStartDate() { + val start = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(START_DATE, start)), + local = newIcal() + ) + .let { + assertEquals(start.startOfDay().millis, it.hideUntil) + } + } + + @Test + fun remoteUpdatesStartDate() { + val start = newDateTime() + newTask(with(TaskMaker.START_DATE, start)) + .applyRemote( + remote = newIcal(with(START_DATE, start.plusDays(1))), + local = newIcal(with(START_DATE, start)) + ) + .let { + assertEquals(start.plusDays(1).startOfDay().millis, it.hideUntil) + } + } + + @Test + fun remoteRemovesStartDate() { + val start = newDateTime() + newTask(with(TaskMaker.START_DATE, start)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(START_DATE, start)) + ) + .let { + assertEquals(0, it.hideUntil) + } + } + + @Test + fun localRemovesStartDate() { + val start = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(START_DATE, start)), + local = newIcal(with(START_DATE, start)) + ) + .let { + assertEquals(0, it.hideUntil) + } + } + + @Test + fun localBeatsRemoteStartDate() { + val start = newDateTime() + newTask(with(TaskMaker.START_DATE, start.plusDays(2))) + .applyRemote( + remote = newIcal(with(START_DATE, start.plusDays(1))), + local = newIcal(with(START_DATE, start)) + ) + .let { + assertEquals(start.plusDays(2).startOfDay().millis, it.hideUntil) + } + } + + @Test + fun remoteAddsCreationDate() { + val created = newDateTime() + newTask(with(CREATION_TIME, created.minusMinutes(1))) + .applyRemote( + remote = newIcal(with(CREATED_AT, created.toUTC())), + local = null + ) + .let { + assertEquals(created.millis, it.creationDate) + } + } + + @Test + fun remoteSetsRecurrence() = + newTask() + .applyRemote( + remote = newIcal(with(RRULE, "FREQ=DAILY")), + local = null + ) + .let { + assertEquals("FREQ=DAILY", it.recurrence) + } + + @Test + fun remoteUpdatesRecurrence() = + newTask(with(TaskMaker.RECUR, "FREQ=DAILY")) + .applyRemote( + remote = newIcal(with(RRULE, "FREQ=MONTHLY")), + local = newIcal(with(RRULE, "FREQ=DAILY")) + ) + .let { + assertEquals("FREQ=MONTHLY", it.recurrence) + } + + @Test + fun remoteRemovesRecurrence() = + newTask(with(TaskMaker.RECUR, "FREQ=DAILY")) + .applyRemote( + remote = newIcal(), + local = newIcal(with(RRULE, "FREQ=DAILY")) + ) + .let { + assertNull(it.recurrence) + } + + @Test + fun localRemovesRecurrence() = + newTask() + .applyRemote( + remote = newIcal(with(RRULE, "FREQ=DAILY")), + local = newIcal(with(RRULE, "FREQ=DAILY")) + ) + .let { + assertNull(it.recurrence) + } + + @Test + fun localBeatsRemoteRecurrence() = + newTask(with(TaskMaker.RECUR, "FREQ=WEEKLY")) + .applyRemote( + remote = newIcal(with(RRULE, "FREQ=MONTHLY")), + local = newIcal(with(RRULE, "FREQ=DAILY")) + ) + .let { + assertEquals("FREQ=WEEKLY", it.recurrence) + } + + @Test + fun remoteSetsCompletedStatus() = + newTask() + .applyRemote( + remote = newIcal(with(STATUS, Status.VTODO_COMPLETED)), + local = null + ) + .let { + assertTrue(it.isCompleted) + } + + @Test + fun remoteUpdatesCompletedStatus() = + newTask() + .applyRemote( + remote = newIcal(with(STATUS, Status.VTODO_COMPLETED)), + local = newIcal(with(STATUS, Status.VTODO_IN_PROCESS)) + ) + .let { + assertTrue(it.isCompleted) + } + + @Test + fun remoteRemovesCompletedStatus() { + val now = newDateTime() + newTask(with(COMPLETION_TIME, now)) + .applyRemote( + remote = newIcal(), + local = newIcal( + with(STATUS, Status.VTODO_COMPLETED), + with(COMPLETED_AT, now) + ) + ) + .let { + assertFalse(it.isCompleted) + } + } + + @Test + fun remoteSetsCompletedAt() { + val now = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(COMPLETED_AT, now.toUTC())), + local = null + ) + .let { + assertEquals(now.startOfSecond().millis, it.completionDate) + } + } + + @Test + fun remoteUpdatesCompletedAt() { + val now = newDateTime() + newTask(with(COMPLETION_TIME, now)) + .applyRemote( + remote = newIcal(with(COMPLETED_AT, now.plusMinutes(5).toUTC())), + local = newIcal( + with(COMPLETED_AT, now.toUTC()), + with(STATUS, Status.VTODO_COMPLETED) + ) + ) + .let { + assertEquals(now.plusMinutes(5).startOfSecond().millis, it.completionDate) + } + } + + @Test + fun remoteRemovesCompletedAt() { + val now = newDateTime() + newTask(with(COMPLETION_TIME, now)) + .applyRemote( + remote = newIcal(), + local = newIcal( + with(COMPLETED_AT, now.toUTC()), + with(STATUS, Status.VTODO_COMPLETED) + ) + ) + .let { + assertFalse(it.isCompleted) + } + } + + @Test + fun localRemovesCompletedAt() { + val now = newDateTime() + newTask() + .applyRemote( + remote = newIcal(with(COMPLETED_AT, now.toUTC())), + local = newIcal( + with(COMPLETED_AT, now.toUTC()), + with(STATUS, Status.VTODO_COMPLETED) + ) + ) + .let { + assertFalse(it.isCompleted) + } + } + + @Test + fun localBeatsRemoteCompletedAt() { + val now = newDateTime() + newTask(with(COMPLETION_TIME, now.plusMinutes(2))) + .applyRemote( + remote = newIcal(with(COMPLETED_AT, now.plusMinutes(1).toUTC())), + local = newIcal( + with(COMPLETED_AT, now.toUTC()), + with(STATUS, Status.VTODO_COMPLETED) + ) + ) + .let { + assertEquals(now.plusMinutes(2).millis, it.completionDate) + } + } + + @Test + fun remoteSetsCollapsed() { + newTask() + .applyRemote( + remote = newIcal(with(COLLAPSED, true)), + local = null + ) + .let { + assertTrue(it.isCollapsed) + } + } + + @Test + fun remoteRemovesCollapsed() { + newTask(with(TaskMaker.COLLAPSED, true)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(COLLAPSED, true)) + ) + .let { + assertFalse(it.isCollapsed) + } + } + + @Test + fun localBeatsRemoteCollapsed() { + newTask(with(TaskMaker.COLLAPSED, true)) + .applyRemote( + remote = newIcal(with(COLLAPSED, false)), + local = newIcal(with(COLLAPSED, false)) + ) + .let { + assertTrue(it.isCollapsed) + } + } + + @Test + fun remoteSetsOrder() = + newCaldavTask() + .applyRemote( + remote = newIcal(with(ORDER, 1234)), + local = null + ) + .let { + assertEquals(1234L, it.order) + } + + @Test + fun remoteRemovesOrder() = + newCaldavTask(with(REMOTE_ORDER, 1234)) + .applyRemote( + remote = newIcal(), + local = newIcal(with(ORDER, 1234)) + ) + .let { + assertNull(it.order) + } + + @Test + fun localRemovesOrder() = + newCaldavTask() + .applyRemote( + remote = newIcal(with(ORDER, 1234)), + local = newIcal(with(ORDER, 1234)) + ) + .let { + assertNull(it.order) + } + + @Test + fun localBeatsRemoteOrder() = + newCaldavTask(with(REMOTE_ORDER, 789)) + .applyRemote( + remote = newIcal(with(ORDER, 456)), + local = newIcal(with(ORDER, 123)) + ) + .let { + assertEquals(789L, it.order) + } + + @Test + fun remoteSetsParent() = + newCaldavTask() + .applyRemote( + remote = newIcal(with(PARENT, "1234")), + local = null + ) + .let { + assertEquals("1234", it.remoteParent) + } + + @Test + fun remoteRemovesParent() = + newCaldavTask(with(REMOTE_PARENT, "1234")) + .applyRemote( + remote = newIcal(), + local = newIcal(with(PARENT, "1234")) + ) + .let { + assertNull(it.remoteParent) + } + + @Test + fun localRemovesParent() = + newCaldavTask() + .applyRemote( + remote = newIcal(with(PARENT, "1234")), + local = newIcal(with(PARENT, "1234")) + ) + .let { + assertNull(it.remoteParent) + } + + @Test + fun localBeatsRemoteParent() = + newCaldavTask(with(REMOTE_PARENT, "789")) + .applyRemote( + remote = newIcal(with(PARENT, "456")), + local = newIcal(with(PARENT, "123")) + ) + .let { + assertEquals("789", it.remoteParent) + } + + companion object { + private fun DateTime.allDay() = + createDueDate(URGENCY_SPECIFIC_DAY, millis) + } +} \ No newline at end of file