package org.tasks.caldav import at.bitfire.ical4android.Task import at.bitfire.ical4android.Task.Companion.tasksFromReader import at.bitfire.ical4android.util.DateUtils.ical4jTimeZone 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 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.service.TaskCreator import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms import com.todoroo.astrid.service.TaskCreator.Companion.setDefaultReminders import net.fortuna.ical4j.model.DateTime 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.property.Action import net.fortuna.ical4j.model.property.Completed import net.fortuna.ical4j.model.property.DateProperty import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.Geo import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.RelatedTo import net.fortuna.ical4j.model.property.Status import net.fortuna.ical4j.model.property.XProperty import org.tasks.BuildConfig import org.tasks.caldav.GeoUtils.equalish import org.tasks.caldav.GeoUtils.toGeo import org.tasks.caldav.GeoUtils.toLikeString import org.tasks.caldav.extensions.toAlarms import org.tasks.caldav.extensions.toVAlarms import org.tasks.data.Alarm import org.tasks.data.Alarm.Companion.TYPE_RANDOM import org.tasks.data.Alarm.Companion.TYPE_SNOOZE import org.tasks.data.AlarmDao import org.tasks.data.CaldavAccount import org.tasks.data.CaldavCalendar import org.tasks.data.CaldavCalendar.Companion.ACCESS_READ_ONLY import org.tasks.data.CaldavDao import org.tasks.data.CaldavTask import org.tasks.data.Geofence import org.tasks.data.LocationDao import org.tasks.data.Place import org.tasks.data.TagDao import org.tasks.data.TagData import org.tasks.data.TagDataDao import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.date.DateTimeUtils.toDateTime import org.tasks.date.DateTimeUtils.toLocal import org.tasks.jobs.WorkManager import org.tasks.location.GeofenceApi import org.tasks.notifications.NotificationManager import org.tasks.preferences.Preferences import org.tasks.repeats.RecurrenceUtils.newRRule import org.tasks.time.DateTimeUtils.startOfDay import org.tasks.time.DateTimeUtils.startOfMinute import org.tasks.time.DateTimeUtils.toDate import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.StringReader import java.text.ParseException import java.util.TimeZone import javax.inject.Inject import kotlin.math.max import kotlin.math.min @Suppress("ClassName") class iCalendar @Inject constructor( private val tagDataDao: TagDataDao, private val preferences: Preferences, private val locationDao: LocationDao, private val workManager: WorkManager, private val geofenceApi: GeofenceApi, private val taskCreator: TaskCreator, private val tagDao: TagDao, private val taskDao: TaskDao, private val caldavDao: CaldavDao, private val alarmDao: AlarmDao, private val alarmService: AlarmService, private val vtodoCache: VtodoCache, private val notificationManager: NotificationManager, ) { suspend fun setPlace(taskId: Long, geo: Geo?) { if (geo == null) { locationDao.getActiveGeofences(taskId).forEach { locationDao.delete(it.geofence) geofenceApi.update(it.place) } return } var place: Place? = locationDao.findPlace( geo.latitude.toLikeString(), geo.longitude.toLikeString() ) if (place == null) { place = Place( latitude = geo.latitude.toDouble(), longitude = geo.longitude.toDouble(), ).let { it.copy(id = locationDao.insert(it)) } workManager.reverseGeocode(place) } val existing = locationDao.getGeofences(taskId) if (existing == null) { locationDao.insert( Geofence( place.uid, preferences ).copy(task = taskId) ) } else if (place != existing.place) { val geofence = existing.geofence.copy(place = place.uid) locationDao.update(geofence) geofenceApi.update(existing.place) } geofenceApi.update(place) } suspend fun getTags(categories: List): List { if (categories.isEmpty()) { return emptyList() } val tags = tagDataDao.getTags(categories).toMutableList() val existing = tags.map(TagData::name) val toCreate = categories subtract existing for (name in toCreate) { val tag = TagData(name) tagDataDao.createNew(tag) tags.add(tag) } return tags } suspend fun toVtodo( account: CaldavAccount, calendar: CaldavCalendar, caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task ): ByteArray { var remoteModel: Task? = null try { val vtodo = vtodoCache.getVtodo(calendar, caldavTask) if (vtodo?.isNotBlank() == true) { remoteModel = fromVtodo(vtodo) } } catch (e: java.lang.Exception) { Timber.e(e) } if (remoteModel == null) { remoteModel = Task() } return toVtodo(account, caldavTask, task, remoteModel) } suspend fun toVtodo( account: CaldavAccount, caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task, remoteModel: Task ): ByteArray { remoteModel.applyLocal(caldavTask, task) val categories = remoteModel.categories categories.clear() categories.addAll(tagDataDao.getTagDataForTask(task.id).map { it.name!! }) if (BuildConfig.DEBUG && caldavTask.remoteId.isNullOrBlank()) { throw IllegalStateException() } remoteModel.uid = caldavTask.remoteId val location = locationDao.getGeofences(task.id) val localGeo = toGeo(location) if (localGeo == null || !localGeo.equalish(remoteModel.geoPosition)) { remoteModel.geoPosition = localGeo } if (account.reminderSync) { remoteModel.alarms.removeAll(remoteModel.alarms.filtered) 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( account: CaldavAccount, calendar: CaldavCalendar, existing: CaldavTask?, remote: Task, vtodo: String?, obj: String? = null, eTag: String? = null ) { val task = existing?.task ?.let { taskDao.fetch(it) } ?: taskCreator.createWithValues("").apply { readOnly = calendar.access == ACCESS_READ_ONLY taskDao.createNew(this) } val caldavTask = existing ?.copy(task = task.id) ?: CaldavTask( task = task.id, calendar = calendar.uuid, remoteId = remote.uid, `object` = obj ) val isNew = caldavTask.id == com.todoroo.astrid.data.Task.NO_ID val dirty = task.modificationDate > caldavTask.lastSync || caldavTask.lastSync == 0L val local = vtodoCache.getVtodo(calendar, caldavTask)?.let { fromVtodo(it) } task.applyRemote(remote, local) caldavTask.applyRemote(remote, local) if ((remote.lastAck ?: 0) > task.reminderLast) { notificationManager.cancel(task.id) } 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)) } if ( isNew && remote.reminders.isEmpty() && !calendar.ctag.isNullOrBlank() && // not initial sync !remote.prodId().supportsReminders() // other client doesn't support reminder sync ) { task.setDefaultReminders(preferences) alarmService.synchronizeAlarms(task.id, task.getDefaultAlarms().toMutableSet()) } else if (account.reminderSync) { val alarms = alarmDao.getAlarms(task.id).onEach { it.id = 0 it.task = 0 } 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) val changed = alarmService.synchronizeAlarms(caldavTask.task, remoteReminders.toMutableSet()) if (changed) { task.modificationDate = DateUtilities.now() } } } task.suppressSync() task.suppressRefresh() taskDao.save(task) vtodoCache.putVtodo(calendar, caldavTask, vtodo) caldavTask.etag = eTag if (!dirty) { caldavTask.lastSync = task.modificationDate } if (isNew) { caldavDao.insert(caldavTask) Timber.d("NEW %s", caldavTask) } else { caldavDao.update(caldavTask) Timber.d("UPDATE %s", caldavTask) } } companion object { private const val APPLE_SORT_ORDER = "X-APPLE-SORT-ORDER" private const val OC_HIDESUBTASKS = "X-OC-HIDESUBTASKS" private const val MOZ_SNOOZE_TIME = "X-MOZ-SNOOZE-TIME" private const val MOZ_LASTACK = "X-MOZ-LASTACK" private const val HIDE_SUBTASKS = "1" // VALARM extensions: https://datatracker.ietf.org/doc/html/rfc9074 private val IGNORE_ALARM = DateTime("19760401T005545Z") private val IS_PARENT = { r: RelatedTo -> r.parameters.getParameter(Parameter.RELTYPE).let { it === RelType.PARENT || it == null || it.value.isNullOrBlank() } } 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) } fun Due?.apply(task: com.todoroo.astrid.data.Task) { 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) ) else -> com.todoroo.astrid.data.Task.createDueDate( URGENCY_SPECIFIC_DAY, getLocal(this) ) } fun DtStart?.apply(task: com.todoroo.astrid.data.Task) { 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)) } // this isn't necessarily the task originator but its the best we can do fun ProdId.supportsReminders(): Boolean = CLIENTS_WITH_REMINDER_SYNC.any { value.contains(it) } private val CLIENTS_WITH_REMINDER_SYNC = listOf( "tasks.org", "Mozilla.org", "Apple Inc.", ) internal fun getLocal(property: DateProperty): Long = org.tasks.time.DateTime.from(property.date)?.toLocal()?.millis ?: 0 fun fromVtodo(vtodo: String): Task? { try { val tasks = tasksFromReader(StringReader(vtodo)) if (tasks.size == 1) { return tasks[0] } } catch (e: Exception) { Timber.e(e) } return null } var Task.parent: String? get() = relatedTo.find(IS_PARENT)?.value set(value) { val parents = relatedTo.filter(IS_PARENT) when { value.isNullOrBlank() -> relatedTo.removeAll(parents) parents.isEmpty() -> relatedTo.add(RelatedTo(value)) else -> { if (parents.size > 1) { relatedTo.removeAll(parents.drop(1)) } parents[0].let { it.value = value it.parameters.replace(RelType.PARENT) } } } } var Task.order: Long? get() = unknownProperties.find(IS_APPLE_SORT_ORDER).let { it?.value?.toLongOrNull() } set(order) { if (order == null) { unknownProperties.removeIf(IS_APPLE_SORT_ORDER) } else { unknownProperties .find(IS_APPLE_SORT_ORDER) ?.let { it.value = order.toString() } ?: unknownProperties.add(XProperty(APPLE_SORT_ORDER, order.toString())) } } var Task.collapsed: Boolean get() = unknownProperties.find(IS_OC_HIDESUBTASKS).let { it?.value == HIDE_SUBTASKS } set(collapsed) { if (collapsed) { unknownProperties .find(IS_OC_HIDESUBTASKS) ?.let { it.value = HIDE_SUBTASKS } ?: unknownProperties.add(XProperty(OC_HIDESUBTASKS, HIDE_SUBTASKS)) } else { unknownProperties.removeIf(IS_OC_HIDESUBTASKS) } } var Task.lastAck: Long? get() = unknownProperties.find(IS_MOZ_LASTACK)?.value?.let { org.tasks.time.DateTime.from(DateTime(it)).toLocal().millis } set(value) { value ?.toDateTime() ?.toUTC() ?.let { DateTime(true).apply { time = it.millis } } ?.let { utc -> unknownProperties.find(IS_MOZ_LASTACK) ?.let { it.value = utc.toString() } ?: unknownProperties.add( XProperty(MOZ_LASTACK, utc.toString()) ) } } var Task.snooze: Long? get() = unknownProperties.find(IS_MOZ_SNOOZE_TIME)?.value?.let { org.tasks.time.DateTime.from(DateTime(it)).toLocal().millis } set(value) { value ?.toDateTime() ?.takeIf { it.isAfterNow } ?.toUTC() ?.let { DateTime(true).apply { time = it.millis } } ?.let { utc -> unknownProperties.find(IS_MOZ_SNOOZE_TIME) ?.let { it.value = utc.toString() } ?: unknownProperties.add( XProperty(MOZ_SNOOZE_TIME, utc.toString()) ) lastAck = lastModified?.toLocal() } ?: unknownProperties.removeIf(IS_MOZ_SNOOZE_TIME) } fun Task.applyLocal(caldavTask: CaldavTask, task: com.todoroo.astrid.data.Task) { createdAt = newDateTime(task.creationDate).toUTC().millis summary = task.title description = task.notes val allDay = !task.hasDueTime() && !task.hasStartTime() val dueDate = if (task.hasDueTime()) task.dueDate else task.dueDate.startOfDay() var startDate = if (task.hasStartTime()) { task.hideUntil.startOfMinute() } else { task.hideUntil.startOfDay() } due = if (dueDate > 0) { startDate = min(dueDate, startDate) Due(if (allDay) dueDate.toDate() else getDateTime(dueDate)) } else { null } dtStart = if (startDate > 0) { DtStart(if (allDay) startDate.toDate() else getDateTime(startDate)) } else { null } if (task.isCompleted) { completedAt = Completed(DateTime(task.completionDate)) status = Status.VTODO_COMPLETED percentComplete = 100 } else if (completedAt != null) { completedAt = null status = null percentComplete = null } rRule = if (task.isRecurring) { try { newRRule(task.recurrence!!) } catch (e: ParseException) { Timber.e(e) null } } else { null } lastModified = newDateTime(task.modificationDate).toUTC().millis priority = when (task.priority) { com.todoroo.astrid.data.Task.Priority.NONE -> 0 com.todoroo.astrid.data.Task.Priority.MEDIUM -> 5 com.todoroo.astrid.data.Task.Priority.HIGH -> if (priority < 5) max(1, priority) else 1 else -> if (priority > 5) min(9, priority) else 9 } parent = if (task.parent == 0L) null else caldavTask.remoteParent order = task.order collapsed = task.isCollapsed } val List.filtered: List get() = filter { it.action == Action.DISPLAY || it.action == Action.AUDIO } .filterNot { it.trigger.dateTime == IGNORE_ALARM } val Task.reminders: List get() = alarms.filtered.toAlarms().let { alarms -> snooze?.let { time -> alarms.plus(Alarm(0, time, TYPE_SNOOZE))} ?: alarms } internal fun getDateTime(timestamp: Long): DateTime { val tz = ical4jTimeZone(TimeZone.getDefault().id) val dateTime = DateTime(if (tz != null) timestamp else org.tasks.time.DateTime(timestamp).toUTC().millis) dateTime.timeZone = tz return dateTime } } }