mirror of https://github.com/tasks/tasks
Microsoft sync - WIP
parent
8475be4683
commit
983727cbc8
@ -0,0 +1,413 @@
|
||||
package org.tasks.sync.microsoft
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import com.todoroo.astrid.dao.TaskDao
|
||||
import com.todoroo.astrid.service.TaskCreator
|
||||
import com.todoroo.astrid.service.TaskDeleter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.tasks.LocalBroadcastManager
|
||||
import org.tasks.R
|
||||
import org.tasks.Strings.isNullOrEmpty
|
||||
import org.tasks.analytics.Firebase
|
||||
import org.tasks.billing.Inventory
|
||||
import org.tasks.caldav.VtodoCache
|
||||
import org.tasks.data.dao.CaldavDao
|
||||
import org.tasks.data.dao.TagDao
|
||||
import org.tasks.data.dao.TagDataDao
|
||||
import org.tasks.data.entity.CaldavAccount
|
||||
import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED
|
||||
import org.tasks.data.entity.CaldavCalendar
|
||||
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER
|
||||
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE
|
||||
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_UNKNOWN
|
||||
import org.tasks.data.entity.CaldavTask
|
||||
import org.tasks.data.entity.TagData
|
||||
import org.tasks.data.entity.Task
|
||||
import org.tasks.http.HttpClientFactory
|
||||
import org.tasks.preferences.Preferences
|
||||
import org.tasks.sync.microsoft.Error.Companion.toMicrosoftError
|
||||
import org.tasks.sync.microsoft.MicrosoftConverter.applyRemote
|
||||
import org.tasks.sync.microsoft.MicrosoftConverter.toRemote
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.KeyManagementException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
|
||||
class MicrosoftSynchronizer @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val caldavDao: CaldavDao,
|
||||
private val taskDao: TaskDao,
|
||||
private val localBroadcastManager: LocalBroadcastManager,
|
||||
private val taskDeleter: TaskDeleter,
|
||||
private val inventory: Inventory,
|
||||
private val firebase: Firebase,
|
||||
private val taskCreator: TaskCreator,
|
||||
private val httpClientFactory: HttpClientFactory,
|
||||
private val tagDao: TagDao,
|
||||
private val tagDataDao: TagDataDao,
|
||||
private val preferences: Preferences,
|
||||
private val vtodoCache: VtodoCache,
|
||||
) {
|
||||
suspend fun sync(account: CaldavAccount) {
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
if (!inventory.hasPro) {
|
||||
setError(account, context.getString(R.string.requires_pro_subscription))
|
||||
return
|
||||
}
|
||||
if (isNullOrEmpty(account.password)) {
|
||||
setError(account, ERROR_UNAUTHORIZED)
|
||||
return
|
||||
}
|
||||
try {
|
||||
synchronize(account)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: SSLException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: ConnectException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: UnknownHostException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: UnauthorizedException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: ServiceUnavailableException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: KeyManagementException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: IOException) {
|
||||
setError(account, e.message)
|
||||
} catch (e: HttpException) {
|
||||
val message = when(e.code) {
|
||||
402, in 500..599 -> e.message
|
||||
else -> {
|
||||
firebase.reportException(e)
|
||||
e.message
|
||||
}
|
||||
}
|
||||
setError(account, message)
|
||||
} catch (e: Exception) {
|
||||
setError(account, e.message)
|
||||
firebase.reportException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun synchronize(account: CaldavAccount) {
|
||||
Timber.d("Synchronize $account")
|
||||
val microsoft = httpClientFactory.getMicrosoftService(account)
|
||||
val taskLists = getTaskLists(account, microsoft) ?: return
|
||||
for (calendar in caldavDao.findDeletedCalendars(account.uuid!!, taskLists.map { it.id!! })) {
|
||||
taskDeleter.delete(calendar)
|
||||
}
|
||||
for (remote in taskLists) {
|
||||
var local = caldavDao.getCalendarByUrl(account.uuid!!, remote.id!!)
|
||||
val remoteName = remote.displayName
|
||||
val access = when {
|
||||
remote.isOwner == true -> ACCESS_OWNER
|
||||
remote.isShared == true -> ACCESS_READ_WRITE
|
||||
else -> ACCESS_UNKNOWN
|
||||
}
|
||||
if (local == null) {
|
||||
local = CaldavCalendar(
|
||||
account = account.uuid,
|
||||
).apply {
|
||||
remote.applyTo(this)
|
||||
}
|
||||
caldavDao.insert(local)
|
||||
} else if (local.name != remoteName || local.access != access) {
|
||||
remote.applyTo(local)
|
||||
caldavDao.update(local)
|
||||
localBroadcastManager.broadcastRefreshList()
|
||||
}
|
||||
if (local.ctag?.isNotBlank() == true) {
|
||||
deltaSync(account, local, remote, microsoft)
|
||||
} else {
|
||||
fullSync(account, local, remote, microsoft)
|
||||
}
|
||||
pushLocalChanges(local, microsoft)
|
||||
}
|
||||
setError(account, "")
|
||||
}
|
||||
|
||||
private suspend fun pushLocalChanges(
|
||||
local: CaldavCalendar,
|
||||
microsoft: MicrosoftService,
|
||||
) {
|
||||
for (task in caldavDao.getMoved(local.uuid!!)) {
|
||||
deleteRemoteResource(microsoft, local, task)
|
||||
}
|
||||
for (task in taskDao.getCaldavTasksToPush(local.uuid!!)) {
|
||||
try {
|
||||
pushTask(local, task, microsoft)
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteRemoteResource(
|
||||
microsoft: MicrosoftService,
|
||||
list: CaldavCalendar,
|
||||
task: CaldavTask,
|
||||
): Boolean {
|
||||
val listId = list.uuid
|
||||
val taskId = task.remoteId
|
||||
val success = when {
|
||||
task.lastSync == 0L -> true
|
||||
listId.isNullOrBlank() -> false
|
||||
taskId.isNullOrBlank() -> false
|
||||
else -> {
|
||||
microsoft.deleteTask(listId, taskId)
|
||||
true
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
vtodoCache.delete(list, task)
|
||||
caldavDao.delete(task)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
private suspend fun pushTask(
|
||||
list: CaldavCalendar,
|
||||
task: Task,
|
||||
microsoft: MicrosoftService,
|
||||
) {
|
||||
val caldavTask = caldavDao.getTask(task.id) ?: return
|
||||
if (task.isDeleted) {
|
||||
if (deleteRemoteResource(microsoft, list, caldavTask)) {
|
||||
taskDeleter.delete(taskDao.getChildren(task.id) + task.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
val remoteTask = task.toRemote(caldavTask, tagDataDao.getTagDataForTask(task.id))
|
||||
try {
|
||||
val result: Tasks.Task = if (caldavTask.lastSync == 0L) {
|
||||
Timber.d("Uploading new task: $task")
|
||||
microsoft.createTask(list.uuid!!, remoteTask)
|
||||
} else {
|
||||
Timber.d("Updating existing task: $task")
|
||||
microsoft.updateTask(list.uuid!!, caldavTask.remoteId!!, remoteTask)
|
||||
}
|
||||
caldavTask.remoteId = result.id
|
||||
caldavTask.obj = "${result.id}.json"
|
||||
caldavTask.lastSync = task.modificationDate
|
||||
vtodoCache.putVtodo(list, caldavTask, json.encodeToString(result))
|
||||
caldavDao.update(caldavTask)
|
||||
} catch (e: HttpException) {
|
||||
if (e.code == 404) {
|
||||
Timber.d("404, deleting $task")
|
||||
vtodoCache.delete(list, caldavTask)
|
||||
taskDeleter.delete(taskDao.getChildren(task.id) + task.id)
|
||||
} else {
|
||||
Timber.e(e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deltaSync(
|
||||
account: CaldavAccount,
|
||||
list: CaldavCalendar,
|
||||
remoteList: TaskLists.TaskList,
|
||||
microsoft: MicrosoftService
|
||||
) {
|
||||
Timber.d("delta update: $list")
|
||||
val tasks = getTasks(account, list, remoteList, microsoft) ?: return
|
||||
for (remote in tasks) {
|
||||
if (remote.removed == null) {
|
||||
updateTask(list, remote)
|
||||
} else {
|
||||
val caldavTasks = caldavDao.getTasksByRemoteId(list.uuid!!, listOf(remote.id!!))
|
||||
vtodoCache.delete(list, caldavTasks)
|
||||
val taskIds = caldavTasks.map { it.id }.flatMap { taskDao.getChildren(it) + it }
|
||||
taskDeleter.delete(taskIds)
|
||||
}
|
||||
}
|
||||
Timber.d("UPDATE $list")
|
||||
caldavDao.update(list)
|
||||
localBroadcastManager.broadcastRefresh()
|
||||
}
|
||||
|
||||
private suspend fun getTaskLists(
|
||||
account: CaldavAccount,
|
||||
microsoft: MicrosoftService,
|
||||
): List<TaskLists.TaskList>? {
|
||||
val taskLists = ArrayList<TaskLists.TaskList>()
|
||||
var nextPageToken: String? = null
|
||||
do {
|
||||
val response = try {
|
||||
if (nextPageToken == null) {
|
||||
microsoft.getLists()
|
||||
} else {
|
||||
microsoft.paginateLists(nextPageToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val error = e.message ?: "Sync failed"
|
||||
Timber.e(e)
|
||||
setError(account, error)
|
||||
return null
|
||||
}
|
||||
taskLists.addAll(response.value)
|
||||
nextPageToken = response.nextPage
|
||||
Timber.d("nextPageToken: $nextPageToken")
|
||||
} while (nextPageToken?.isNotBlank() == true)
|
||||
Timber.d("response: $taskLists")
|
||||
return taskLists
|
||||
}
|
||||
|
||||
private suspend fun getTasks(
|
||||
account: CaldavAccount,
|
||||
local: CaldavCalendar,
|
||||
remoteList: TaskLists.TaskList,
|
||||
microsoft: MicrosoftService,
|
||||
): List<Tasks.Task>? {
|
||||
val tasks = ArrayList<Tasks.Task>()
|
||||
var nextPageToken: String? = null
|
||||
do {
|
||||
val response = if (nextPageToken == null) {
|
||||
local.ctag
|
||||
?.let { microsoft.paginateTasks(it) }
|
||||
?: microsoft.getTasks(remoteList.id!!)
|
||||
} else {
|
||||
microsoft.paginateTasks(nextPageToken)
|
||||
}
|
||||
if (!response.status.isSuccess()) {
|
||||
response.toMicrosoftError()?.let { error ->
|
||||
when (error.error.code) {
|
||||
"ResourceNotFound",
|
||||
"syncStateNotFound" -> {
|
||||
Timber.e("${local.name}: ${error.error.message}")
|
||||
local.ctag = null
|
||||
caldavDao.update(local)
|
||||
return null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
Timber.e("failed: ${response.status.value} - ${response.status.description}")
|
||||
setError(account, response.status.description)
|
||||
return null
|
||||
}
|
||||
val body = response.body<Tasks>()
|
||||
tasks.addAll(body.value)
|
||||
nextPageToken = body.nextPage
|
||||
body.nextDelta?.let { local.ctag = it}
|
||||
} while (nextPageToken?.isNotBlank() == true)
|
||||
return tasks
|
||||
}
|
||||
|
||||
private suspend fun fullSync(
|
||||
account: CaldavAccount,
|
||||
list: CaldavCalendar,
|
||||
remoteList: TaskLists.TaskList,
|
||||
microsoft: MicrosoftService,
|
||||
) {
|
||||
Timber.d("full update: $list")
|
||||
val tasks = getTasks(account, list, remoteList, microsoft) ?: return
|
||||
tasks.forEach { updateTask(list, it) }
|
||||
caldavDao
|
||||
.getRemoteIds(list.uuid!!)
|
||||
.subtract(tasks.map { it.id }.toSet())
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
Timber.d("DELETED $it")
|
||||
val caldavTasks = caldavDao.getTasksByRemoteId(list.uuid!!, it.filterNotNull())
|
||||
vtodoCache.delete(list, caldavTasks)
|
||||
val taskIds = caldavTasks.map { it.id }.flatMap { taskDao.getChildren(it) + it }
|
||||
taskDeleter.delete(taskIds)
|
||||
}
|
||||
Timber.d("UPDATE $list")
|
||||
caldavDao.update(list)
|
||||
localBroadcastManager.broadcastRefresh()
|
||||
}
|
||||
|
||||
private suspend fun updateTask(list: CaldavCalendar, remote: Tasks.Task) {
|
||||
val existing = caldavDao.getTaskByRemoteId(list.uuid!!, remote.id!!)
|
||||
val task = existing?.task?.let { taskDao.fetch(it) }
|
||||
?: taskCreator.createWithValues("").apply {
|
||||
taskDao.createNew(this)
|
||||
}
|
||||
val caldavTask =
|
||||
existing
|
||||
?.copy(task = task.id)
|
||||
?: CaldavTask(
|
||||
task = task.id,
|
||||
calendar = list.uuid,
|
||||
remoteId = remote.id,
|
||||
obj = "${remote.id}.json"
|
||||
)
|
||||
val dirty = existing != null && task.modificationDate > existing.lastSync
|
||||
task.applyRemote(remote, preferences.defaultPriority)
|
||||
val existingSubtasks: List<CaldavTask> = taskDao.getChildren(task.id).let { caldavDao.getTasks(it) }
|
||||
val remoteSubtaskIds = remote.checklistItems?.map { it.id } ?: emptyList()
|
||||
existingSubtasks
|
||||
.filter { it.remoteId?.isNotBlank() == true && !remoteSubtaskIds.contains(it.remoteId) }
|
||||
.let { taskDeleter.delete(it.map { it.task }) }
|
||||
task.suppressSync()
|
||||
task.suppressRefresh()
|
||||
taskDao.save(task)
|
||||
vtodoCache.putVtodo(list, caldavTask, json.encodeToString(remote))
|
||||
tagDao.applyTags(task, tagDataDao, getTags(remote.categories ?: emptyList()))
|
||||
remote.checklistItems?.forEach {
|
||||
// TODO: handle checklist items
|
||||
}
|
||||
caldavTask.etag = remote.etag
|
||||
if (!dirty) {
|
||||
caldavTask.lastSync = task.modificationDate
|
||||
}
|
||||
if (caldavTask.id == Task.NO_ID) {
|
||||
caldavDao.insert(caldavTask)
|
||||
Timber.d("NEW $caldavTask")
|
||||
} else {
|
||||
caldavDao.update(caldavTask)
|
||||
Timber.d("UPDATE $caldavTask")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getTags(categories: List<String>): List<TagData> {
|
||||
if (categories.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val tags = tagDataDao.getTags(categories).toMutableList()
|
||||
val existing = tags.map(TagData::name)
|
||||
val toCreate = categories subtract existing.toSet()
|
||||
for (name in toCreate) {
|
||||
val tag = TagData(name = name)
|
||||
tagDataDao.insert(tag)
|
||||
tags.add(tag)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
private suspend fun setError(account: CaldavAccount, message: String?) {
|
||||
account.error = message
|
||||
caldavDao.update(account)
|
||||
localBroadcastManager.broadcastRefreshList()
|
||||
if (!isNullOrEmpty(message)) {
|
||||
Timber.e(message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
{
|
||||
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(microsoft.graph.todoTask)",
|
||||
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/todo/lists/AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoALgAAA8dKmrSa60tBjeiKoPukmoQBAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAA==/tasks/delta?$deltatoken=l7WI41swwioT5csv4k99nsI1g9edRWmhEdD3bNOZ7-rc46AMZg2MtbDgi16FTUf1hytpwqSoQPivt3HosbzK8NGe2b80hqMvQytfL_dKubI.QhlhA0l59_5SfjuT4sFgtaW-eNwTl9S-gU61XOXMX9w",
|
||||
"value": [
|
||||
{
|
||||
"@odata.etag": "W/\"SRPGnpbHYES1XW8UlYrtsgAHuEeQXA==\"",
|
||||
"importance": "normal",
|
||||
"isReminderOn": false,
|
||||
"status": "notStarted",
|
||||
"title": "Task with subtasks",
|
||||
"createdDateTime": "2024-12-28T08:44:02.0198785Z",
|
||||
"lastModifiedDateTime": "2024-12-28T09:22:34.6256622Z",
|
||||
"hasAttachments": false,
|
||||
"categories": [],
|
||||
"id": "AQMkADAwATNiZmYAZC04OABiMC0xZDlkLTAwAi0wMAoARgAAA8dKmrSa60tBjeiKoPukmoQHAEkTxp6Wx2BEtV1vFJWK7bIAAAIBEgAAAEkTxp6Wx2BEtV1vFJWK7bIAB7gay5sAAAA=",
|
||||
"body": {
|
||||
"content": "\r\n",
|
||||
"contentType": "text"
|
||||
},
|
||||
"checklistItems": [
|
||||
{
|
||||
"displayName": "Step 1",
|
||||
"createdDateTime": "2024-12-28T08:44:10.5803039Z",
|
||||
"isChecked": false,
|
||||
"id": "e3709a6d-98ef-43ce-9548-ac195ba052f3"
|
||||
},
|
||||
{
|
||||
"displayName": "Step 2",
|
||||
"createdDateTime": "2024-12-28T08:44:16.2330419Z",
|
||||
"checkedDateTime": "2024-12-28T09:22:34.5800728Z",
|
||||
"isChecked": true,
|
||||
"id": "88741356-5b0c-4822-896f-047f3a977449"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue