You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tasks/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt

265 lines
9.7 KiB
Kotlin

package org.tasks.opentasks
import android.content.Context
import at.bitfire.ical4android.BatchOperation
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Constants
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.caldav.iCalendar
import org.tasks.data.MyAndroidTask
import org.tasks.data.OpenTaskDao
import org.tasks.data.OpenTaskDao.Companion.filterActive
import org.tasks.data.OpenTaskDao.Companion.isDavx5
import org.tasks.data.OpenTaskDao.Companion.isDavx5Managed
import org.tasks.data.OpenTaskDao.Companion.isDecSync
import org.tasks.data.OpenTaskDao.Companion.isEteSync
import org.tasks.data.OpenTaskDao.Companion.toLocalCalendar
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavTask
import org.tasks.data.entity.Task
import org.tasks.data.entity.Task.Companion.NO_ID
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OpenTasksSynchronizer @Inject constructor(
@ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDao: TaskDao,
private val firebase: Firebase,
private val iCalendar: iCalendar,
private val openTaskDao: OpenTaskDao,
private val inventory: Inventory) {
suspend fun sync() {
Timber.d("Starting OpenTasks sync...")
val lists = openTaskDao.getListsByAccount().filterActive(caldavDao)
lists.keys
.filter { caldavDao.getAccountByUuid(it) == null }
.map {
CaldavAccount(
name = it.split(":")[1],
uuid = it,
accountType = CaldavAccount.TYPE_OPENTASKS,
)
}
.onEach { caldavDao.insert(it) }
.forEach {
firebase.logEvent(
R.string.event_sync_add_account,
R.string.param_type to when {
it.uuid.isDavx5() -> Constants.SYNC_TYPE_DAVX5
it.uuid.isDavx5Managed() -> Constants.SYNC_TYPE_DAVX5_MANAGED
it.uuid.isEteSync() -> Constants.SYNC_TYPE_ETESYNC_OT
it.uuid.isDecSync() -> Constants.SYNC_TYPE_DECSYNC
else -> throw IllegalArgumentException()
}
)
}
caldavDao.getAccounts(CaldavAccount.TYPE_OPENTASKS).forEach { account ->
val entries = lists[account.uuid!!]
if (entries == null) {
Timber.d("Removing $account")
taskDeleter.delete(account)
} else if (!inventory.hasPro) {
setError(account, context.getString(R.string.requires_pro_subscription))
} else {
try {
sync(account, entries)
setError(account, null)
} catch (e: Exception) {
firebase.reportException(e)
setError(account, e.message)
}
}
}
}
private suspend fun sync(account: CaldavAccount, lists: List<CaldavCalendar>) {
Timber.d("Synchronizing $account")
val uuid = account.uuid!!
caldavDao
.findDeletedCalendars(uuid, lists.mapNotNull { it.url })
.forEach {
Timber.d("Deleting $it")
taskDeleter.delete(it)
}
lists.forEach {
val calendar = toLocalCalendar(it)
if (calendar.access != CaldavCalendar.ACCESS_READ_ONLY) {
pushChanges(account, calendar, it.id)
}
fetchChanges(account, calendar, it.ctag, it.id)
}
}
private suspend fun toLocalCalendar(remote: CaldavCalendar): CaldavCalendar {
val local = caldavDao.getCalendarByUrl(remote.account!!, remote.url!!)
?: remote.toLocalCalendar()
if (local.id == NO_ID) {
caldavDao.insert(local)
Timber.d("Created calendar: $local")
localBroadcastManager.broadcastRefreshList()
} else if (
local.name != remote.name ||
local.color != remote.color ||
local.access != remote.access
) {
local.color = remote.color
local.name = remote.name
local.access = remote.access
caldavDao.update(local)
Timber.d("Updated calendar: $local")
localBroadcastManager.broadcastRefreshList()
}
return local
}
private suspend fun pushChanges(
account: CaldavAccount,
calendar: CaldavCalendar,
listId: Long
) {
val moved = caldavDao.getMoved(calendar.uuid!!)
val (deleted, updated) = taskDao
.getCaldavTasksToPush(calendar.uuid!!)
.partition { it.isDeleted }
if (moved.isEmpty() && deleted.isEmpty() && updated.isEmpty()) {
return
}
Timber.d("Pushing changes: updated=${updated.size} moved=${moved.size} deleted=${deleted.size}")
(moved + deleted.map(Task::id)
.let { caldavDao.getTasks(it) })
.mapNotNull { it.remoteId }
.takeIf { it.isNotEmpty() }
?.map { openTaskDao.delete(listId, it) }
?.let {
Timber.d("Deleting ${it.size} from content provider")
openTaskDao.batch(it)
}
caldavDao.delete(moved)
taskDeleter.delete(deleted.map { it.id })
updated.forEach {
push(account, it, listId)
}
}
private suspend fun fetchChanges(
account: CaldavAccount,
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) ->
val caldavTask = caldavDao.getTaskByRemoteId(calendar.uuid!!, uid)
val etag = if (account.isEteSync || account.isDecSync) version else sync1
if (caldavTask?.etag == null || caldavTask.etag != etag) {
applyChanges(account, calendar, listId, uid, etag, caldavTask)
}
}
removeDeleted(calendar.uuid!!, etags.map { it.first })
calendar.ctag = ctag
Timber.d("UPDATE $calendar")
caldavDao.update(calendar)
caldavDao.updateParents(calendar.uuid!!)
localBroadcastManager.broadcastRefresh()
}
private suspend fun removeDeleted(calendar: String, uids: List<String>) {
caldavDao
.getRemoteIds(calendar)
.subtract(uids)
.takeIf { it.isNotEmpty() }
?.let {
Timber.d("DELETED $it")
val tasks = caldavDao.getTasksByRemoteId(calendar, it.toList())
taskDeleter.delete(tasks.map { it.task })
}
}
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
localBroadcastManager.broadcastRefreshList()
if (!message.isNullOrBlank()) {
Timber.e(message)
}
}
private suspend fun push(account: CaldavAccount, task: Task, listId: Long) {
val caldavTask = caldavDao.getTask(task.id) ?: return
val uid = caldavTask.remoteId!!
val androidTask = openTaskDao.getTask(listId, uid)
?: MyAndroidTask(at.bitfire.ical4android.Task())
iCalendar.toVtodo(account, caldavTask, task, androidTask.task!!)
val operations = ArrayList<BatchOperation.CpoBuilder>()
val builder = androidTask.toBuilder(openTaskDao.tasks)
val idxTask = if (androidTask.isNew) {
if (account.isEteSync) {
builder.withValue(Tasks.SYNC2, uid)
}
builder.withValue(Tasks.LIST_ID, listId)
0
} else {
// remove associated rows which are added later again
operations.add(BatchOperation.CpoBuilder
.newDelete(openTaskDao.properties)
.withSelection(
"${TaskContract.Properties.TASK_ID}=?",
arrayOf(androidTask.id.toString())
)
)
null
}
operations.add(builder)
androidTask.enqueueProperties(openTaskDao.properties, operations, idxTask)
operations.map { it.build() }.let { openTaskDao.batch(it) }
caldavTask.lastSync = task.modificationDate
caldavDao.update(caldavTask)
Timber.d("SENT $caldavTask")
}
private suspend fun applyChanges(
account: CaldavAccount,
calendar: CaldavCalendar,
listId: Long,
uid: String,
etag: String?,
existing: CaldavTask?
) {
openTaskDao.getTask(listId, uid)?.let {
iCalendar.fromVtodo(account, calendar, existing, it.task!!, null, null, etag)
}
}
companion object {
private val CaldavAccount.isEteSync: Boolean
get() = uuid?.isEteSync() == true
private val CaldavAccount.isDecSync: Boolean
get() = uuid?.isDecSync() == true
}
}