diff --git a/app/src/main/java/com/todoroo/astrid/data/Task.kt b/app/src/main/java/com/todoroo/astrid/data/Task.kt index 1e6a2e327..310bc0672 100644 --- a/app/src/main/java/com/todoroo/astrid/data/Task.kt +++ b/app/src/main/java/com/todoroo/astrid/data/Task.kt @@ -192,6 +192,8 @@ class Task : Parcelable { val isHidden get() = hideUntil > DateUtilities.now() + fun hasStartTime() = hasDueTime(hideUntil) + fun hasStartDate() = hideUntil > 0 /** Checks whether task is done. Requires DUE_DATE */ @@ -209,7 +211,7 @@ class Task : Parcelable { HIDE_UNTIL_DUE, HIDE_UNTIL_DUE_TIME -> dueDate HIDE_UNTIL_DAY_BEFORE -> dueDate - DateUtilities.ONE_DAY HIDE_UNTIL_WEEK_BEFORE -> dueDate - DateUtilities.ONE_WEEK - HIDE_UNTIL_SPECIFIC_DAY, HIDE_UNTIL_SPECIFIC_DAY_TIME -> customDate.startOfDay() + HIDE_UNTIL_SPECIFIC_DAY, HIDE_UNTIL_SPECIFIC_DAY_TIME -> customDate else -> throw IllegalArgumentException("Unknown setting $setting") } if (date <= 0) { @@ -224,7 +226,7 @@ class Task : Parcelable { } /** Checks whether this due date has a due time or only a date */ - fun hasDueTime(): Boolean = hasDueDate() && hasDueTime(dueDate) + fun hasDueTime(): Boolean = hasDueTime(dueDate) val isOverdue: Boolean get() { @@ -368,6 +370,7 @@ class Task : Parcelable { false } else title == original.title && priority == original.priority + && hideUntil == original.hideUntil && dueDate == original.dueDate && completionDate == original.completionDate && deletionDate == original.deletionDate diff --git a/app/src/main/java/org/tasks/caldav/CaldavConverter.java b/app/src/main/java/org/tasks/caldav/CaldavConverter.java index 74a482065..05fafb59b 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavConverter.java +++ b/app/src/main/java/org/tasks/caldav/CaldavConverter.java @@ -5,6 +5,7 @@ import static com.todoroo.astrid.data.Task.URGENCY_SPECIFIC_DAY; import static com.todoroo.astrid.data.Task.URGENCY_SPECIFIC_DAY_TIME; import static org.tasks.Strings.isNullOrEmpty; import static org.tasks.date.DateTimeUtils.newDateTime; +import static org.tasks.time.DateTimeUtils.startOfDay; import at.bitfire.ical4android.DateUtils; import com.todoroo.astrid.data.Task; @@ -26,7 +27,7 @@ import timber.log.Timber; public class CaldavConverter { - private static final DateFormat DUE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd", Locale.US); + static final DateFormat DUE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd", Locale.US); public static void apply(Task local, at.bitfire.ical4android.Task remote) { Completed completedAt = remote.getCompletedAt(); @@ -54,11 +55,10 @@ public class CaldavConverter { } else { Date dueDate = due.getDate(); if (dueDate instanceof DateTime) { - local.setDueDateAdjustingHideUntil( - Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, dueDate.getTime())); + local.setDueDate(Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, dueDate.getTime())); } else { try { - local.setDueDateAdjustingHideUntil( + local.setDueDate( Task.createDueDate( URGENCY_SPECIFIC_DAY, DUE_DATE_FORMAT.parse(due.getValue()).getTime())); } catch (ParseException e) { @@ -66,6 +66,7 @@ public class CaldavConverter { } } } + iCalendar.Companion.apply(remote.getDtStart(), local); } public static @Priority int fromRemote(int remotePriority) { @@ -110,19 +111,20 @@ public class CaldavConverter { remote.setCreatedAt(newDateTime(task.getCreationDate()).toUTC().getMillis()); remote.setSummary(task.getTitle()); remote.setDescription(task.getNotes()); - if (task.hasDueTime()) { - net.fortuna.ical4j.model.TimeZone tz = - DateUtils.INSTANCE.ical4jTimeZone(TimeZone.getDefault().getID()); - DateTime dateTime = new DateTime(tz != null - ? task.getDueDate() - : new org.tasks.time.DateTime(task.getDueDate()).toUTC().getMillis()); - dateTime.setTimeZone(tz); - remote.setDue(new Due(dateTime)); - } else if (task.hasDueDate()) { - remote.setDue(new Due(new Date(task.getDueDate()))); + boolean allDay = !task.hasDueTime() && !task.hasStartTime(); + long dueDate = task.hasDueTime() ? task.getDueDate() : startOfDay(task.getDueDate()); + long startDate = task.hasStartTime() ? task.getHideUntil() : startOfDay(task.getHideUntil()); + if (dueDate > 0) { + startDate = Math.min(dueDate, startDate); + remote.setDue(new Due(allDay ? new Date(dueDate) : getDateTime(dueDate))); } else { remote.setDue(null); } + if (startDate > 0) { + remote.setDtStart(new DtStart(allDay ? new Date(startDate) : getDateTime(startDate))); + } else { + remote.setDtStart(null); + } if (task.isCompleted()) { remote.setCompletedAt(new Completed(new DateTime(task.getCompletionDate()))); remote.setStatus(Status.VTODO_COMPLETED); @@ -158,4 +160,14 @@ public class CaldavConverter { return remote; } + + private static DateTime getDateTime(long timestamp) { + net.fortuna.ical4j.model.TimeZone tz = + DateUtils.INSTANCE.ical4jTimeZone(TimeZone.getDefault().getID()); + DateTime dateTime = new DateTime(tz != null + ? timestamp + : new org.tasks.time.DateTime(timestamp).toUTC().getMillis()); + dateTime.setTimeZone(tz); + return dateTime; + } } diff --git a/app/src/main/java/org/tasks/caldav/iCalendar.kt b/app/src/main/java/org/tasks/caldav/iCalendar.kt index a7f67232a..ca998f06f 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendar.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendar.kt @@ -3,15 +3,20 @@ package org.tasks.caldav import at.bitfire.ical4android.Task import at.bitfire.ical4android.Task.Companion.tasksFromReader import com.todoroo.astrid.dao.TaskDao +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY_TIME import com.todoroo.astrid.helper.UUIDHelper import com.todoroo.astrid.service.TaskCreator +import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Geo import net.fortuna.ical4j.model.property.RelatedTo import net.fortuna.ical4j.model.property.XProperty import org.tasks.Strings.isNullOrEmpty +import org.tasks.caldav.CaldavConverter.DUE_DATE_FORMAT import org.tasks.caldav.GeoUtils.equalish import org.tasks.caldav.GeoUtils.toGeo import org.tasks.caldav.GeoUtils.toLikeString @@ -22,6 +27,7 @@ import org.tasks.preferences.Preferences import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.StringReader +import java.text.ParseException import javax.inject.Inject @Suppress("ClassName") @@ -158,6 +164,23 @@ class iCalendar @Inject constructor( x?.name.equals(APPLE_SORT_ORDER, true) } + fun DtStart?.apply(task: com.todoroo.astrid.data.Task) { + when (this?.date) { + null -> 0 + is DateTime -> task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY_TIME, date.time) + else -> try { + DUE_DATE_FORMAT.parse(value)?.let { + task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY, it.time) + } + } catch (e: ParseException) { + Timber.e(e) + null + } + }?.let { + task.hideUntil = it + } + } + fun fromVtodo(vtodo: String): Task? { try { val tasks = tasksFromReader(StringReader(vtodo)) diff --git a/app/src/main/java/org/tasks/data/CaldavTaskContainer.kt b/app/src/main/java/org/tasks/data/CaldavTaskContainer.kt index 5f316a6e2..c511113a8 100644 --- a/app/src/main/java/org/tasks/data/CaldavTaskContainer.kt +++ b/app/src/main/java/org/tasks/data/CaldavTaskContainer.kt @@ -8,6 +8,9 @@ class CaldavTaskContainer { @Embedded lateinit var task: Task @Embedded lateinit var caldavTask: CaldavTask + val id: Long + get() = task.id + val remoteId: String? get() = caldavTask.remoteId @@ -20,5 +23,8 @@ class CaldavTaskContainer { val sortOrder: Long get() = caldavTask.order ?: DateTime(task.creationDate).toAppleEpoch() + val startDate: Long + get() = task.hideUntil + override fun toString(): String = "CaldavTaskContainer{task=$task, caldavTask=$caldavTask}" } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt index 95f6d0e21..68b8393f4 100644 --- a/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt +++ b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt @@ -7,13 +7,14 @@ import android.database.Cursor import com.todoroo.andlib.utility.DateUtilities.now import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.data.Task +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_SPECIFIC_DAY_TIME import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY_TIME import com.todoroo.astrid.data.Task.Companion.sanitizeRRule import com.todoroo.astrid.service.TaskCreator import com.todoroo.astrid.service.TaskDeleter import dagger.hilt.android.qualifiers.ApplicationContext -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.property.Geo import net.fortuna.ical4j.model.property.RRule import org.dmfs.tasks.contract.TaskContract.Tasks @@ -34,9 +35,9 @@ 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.date.DateTimeUtils.newDateTime import org.tasks.time.DateTime import org.tasks.time.DateTimeUtils.currentTimeMillis +import org.tasks.time.DateTimeUtils.startOfDay import timber.log.Timber import java.util.* import javax.inject.Inject @@ -237,17 +238,23 @@ class OpenTasksSynchronizer @Inject constructor( } RRule(rrule.value.sanitizeRRule()).value } else null) - values.put(Tasks.IS_ALLDAY, if (task.hasDueDate() && !task.hasDueTime()) 1 else 0) + val allDay = !task.hasDueTime() && !task.hasStartTime() + values.put(Tasks.IS_ALLDAY, if (allDay) 1 else 0) values.put(Tasks.DUE, when { - task.hasDueTime() -> newDateTime(task.dueDate).toDateTime().time - task.hasDueDate() -> Date(task.dueDate).time + task.hasDueTime() -> task.dueDate + task.hasDueDate() -> task.dueDate.startOfDay() + else -> null + }) + values.put(Tasks.DTSTART, when { + task.hasStartTime() -> task.hideUntil + task.hasStartDate() -> task.hideUntil.startOfDay() else -> null }) values.put(Tasks.COMPLETED_IS_ALLDAY, 0) values.put(Tasks.COMPLETED, if (task.isCompleted) task.completionDate else null) values.put(Tasks.STATUS, if (task.isCompleted) Tasks.STATUS_COMPLETED else null) values.put(Tasks.PERCENT_COMPLETE, if (task.isCompleted) 100 else null) - if (task.hasDueTime() || task.isCompleted) { + if (!allDay || task.isCompleted) { values.put(Tasks.TZ, TimeZone.getDefault().id) } values.put(Tasks.PARENT_ID, null as Long?) @@ -315,14 +322,19 @@ class OpenTasksSynchronizer @Inject constructor( task.notes = it.getString(Tasks.DESCRIPTION) task.modificationDate = currentTimeMillis() task.creationDate = it.getLong(Tasks.CREATED).toLocal() - task.setDueDateAdjustingHideUntil(it.getLong(Tasks.DUE).let { due -> - when { - due == 0L -> 0 - it.getBoolean(Tasks.IS_ALLDAY) -> - Task.createDueDate(URGENCY_SPECIFIC_DAY, due - DateTime(due).offset) - else -> Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, due) - } - }) + val allDay = it.getBoolean(Tasks.IS_ALLDAY) + val due = it.getLong(Tasks.DUE) + task.dueDate = when { + due == 0L -> 0 + allDay -> Task.createDueDate(URGENCY_SPECIFIC_DAY, due - DateTime(due).offset) + else -> Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, due) + } + val start = it.getLong(Tasks.DTSTART) + task.hideUntil = when { + start == 0L -> 0 + allDay -> task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY, start - DateTime(start).offset) + else -> task.createHideUntil(HIDE_UNTIL_SPECIFIC_DAY_TIME, start) + } iCalendar.setPlace(task.id, it.getString(Tasks.GEO).toGeo()) task.setRecurrence(it.getString(Tasks.RRULE).toRRule()) val tagNames = openTaskDao.getTags(listId, caldavTask) diff --git a/app/src/main/java/org/tasks/time/DateTimeUtils.kt b/app/src/main/java/org/tasks/time/DateTimeUtils.kt index b81158b85..648a2f219 100644 --- a/app/src/main/java/org/tasks/time/DateTimeUtils.kt +++ b/app/src/main/java/org/tasks/time/DateTimeUtils.kt @@ -34,6 +34,7 @@ object DateTimeUtils { "%dh %dm %ds", seconds / 3600L, (seconds % 3600L / 60L).toInt(), (seconds % 60L).toInt()) } else millis.toString() + @JvmStatic fun Long.startOfDay(): Long = if (this > 0) toDateTime().startOfDay().millis else 0 fun Long.millisOfDay(): Int = if (this > 0) toDateTime().millisOfDay else 0 diff --git a/app/src/test/java/org/tasks/caldav/ThunderbirdTests.kt b/app/src/test/java/org/tasks/caldav/ThunderbirdTests.kt index 139cea540..fdc5e1d8f 100644 --- a/app/src/test/java/org/tasks/caldav/ThunderbirdTests.kt +++ b/app/src/test/java/org/tasks/caldav/ThunderbirdTests.kt @@ -102,6 +102,13 @@ class ThunderbirdTests { remote.rRule!!.value) } + @Test + fun startDateTime() { + assertEquals( + DateTime(2021, 1, 12, 11, 0, 1).millis, + vtodo("thunderbird/start_date_time.txt").hideUntil) + } + @Test @Ignore fun dontCrashOnMultipleTasks() { diff --git a/app/src/test/resources/thunderbird/start_date_time.txt b/app/src/test/resources/thunderbird/start_date_time.txt new file mode 100644 index 000000000..05b6ae1dc --- /dev/null +++ b/app/src/test/resources/thunderbird/start_date_time.txt @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/Chicago +BEGIN:DAYLIGHT +TZOFFSETFROM:-0600 +TZOFFSETTO:-0500 +TZNAME:CDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0500 +TZOFFSETTO:-0600 +TZNAME:CST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +END:STANDARD +END:VTIMEZONE +BEGIN:VTODO +CREATED:20210112T165621Z +LAST-MODIFIED:20210112T165658Z +DTSTAMP:20210112T165658Z +UID:bb7afc40-8800-bb44-be1d-48f3c2909580 +SUMMARY:Start datetime test +DTSTART;TZID=America/Chicago:20210112T110000 +END:VTODO +END:VCALENDAR