From 983727cbc85fd0b89d3a8662df19e865d47618ee Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 28 Dec 2024 02:48:02 -0600 Subject: [PATCH] Microsoft sync - WIP --- app/src/main/java/org/tasks/jobs/SyncWork.kt | 12 +- .../main/java/org/tasks/sync/SyncAdapters.kt | 7 +- .../sync/microsoft/MicrosoftSynchronizer.kt | 413 ++++++++++++++++++ .../java/org/tasks/sync/microsoft/Tasks.kt | 1 + .../java/org/tasks/ui/TaskEditViewModel.kt | 2 + .../task_with_completed_subtask.json | 37 ++ .../kotlin/org/tasks/data/entity/Task.kt | 18 + 7 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt create mode 100644 app/src/test/resources/microsoft/task_with_completed_subtask.json diff --git a/app/src/main/java/org/tasks/jobs/SyncWork.kt b/app/src/main/java/org/tasks/jobs/SyncWork.kt index 3b8bf55f1..44cae9180 100644 --- a/app/src/main/java/org/tasks/jobs/SyncWork.kt +++ b/app/src/main/java/org/tasks/jobs/SyncWork.kt @@ -22,18 +22,20 @@ import org.tasks.R import org.tasks.analytics.Firebase import org.tasks.billing.Inventory import org.tasks.caldav.CaldavSynchronizer +import org.tasks.data.OpenTaskDao +import org.tasks.data.dao.CaldavDao +import org.tasks.data.dao.GoogleTaskListDao import org.tasks.data.entity.CaldavAccount.Companion.TYPE_CALDAV import org.tasks.data.entity.CaldavAccount.Companion.TYPE_ETEBASE +import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT import org.tasks.data.entity.CaldavAccount.Companion.TYPE_TASKS -import org.tasks.data.dao.CaldavDao -import org.tasks.data.dao.GoogleTaskListDao -import org.tasks.data.OpenTaskDao import org.tasks.etebase.EtebaseSynchronizer import org.tasks.extensions.Context.hasNetworkConnectivity import org.tasks.gtasks.GoogleTaskSynchronizer import org.tasks.injection.BaseWorker import org.tasks.opentasks.OpenTasksSynchronizer import org.tasks.preferences.Preferences +import org.tasks.sync.microsoft.MicrosoftSynchronizer import org.tasks.time.DateTimeUtils2.currentTimeMillis @HiltWorker @@ -48,6 +50,7 @@ class SyncWork @AssistedInject constructor( private val etebaseSynchronizer: Lazy, private val googleTaskSynchronizer: Lazy, private val openTasksSynchronizer: Lazy, + private val microsoftSynchronizer: Lazy, private val googleTaskListDao: GoogleTaskListDao, private val openTaskDao: OpenTaskDao, private val inventory: Inventory @@ -136,6 +139,7 @@ class SyncWork @AssistedInject constructor( TYPE_ETEBASE -> etebaseSynchronizer.get().sync(it) TYPE_TASKS, TYPE_CALDAV -> caldavSynchronizer.get().sync(it) + TYPE_MICROSOFT -> microsoftSynchronizer.get().sync(it) } } } @@ -145,7 +149,7 @@ class SyncWork @AssistedInject constructor( googleTaskListDao.getAccounts() private suspend fun getCaldavAccounts() = - caldavDao.getAccounts(TYPE_CALDAV, TYPE_TASKS, TYPE_ETEBASE) + caldavDao.getAccounts(TYPE_CALDAV, TYPE_TASKS, TYPE_ETEBASE, TYPE_MICROSOFT) companion object { private val LOCK = Any() diff --git a/app/src/main/java/org/tasks/sync/SyncAdapters.kt b/app/src/main/java/org/tasks/sync/SyncAdapters.kt index 92ad4af0c..bca9c7b72 100644 --- a/app/src/main/java/org/tasks/sync/SyncAdapters.kt +++ b/app/src/main/java/org/tasks/sync/SyncAdapters.kt @@ -13,9 +13,11 @@ import org.tasks.data.dao.GoogleTaskDao import org.tasks.data.entity.CaldavAccount.Companion.TYPE_CALDAV import org.tasks.data.entity.CaldavAccount.Companion.TYPE_ETEBASE import org.tasks.data.entity.CaldavAccount.Companion.TYPE_GOOGLE_TASKS +import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT import org.tasks.data.entity.CaldavAccount.Companion.TYPE_OPENTASKS import org.tasks.data.entity.CaldavAccount.Companion.TYPE_TASKS import org.tasks.data.entity.FORCE_CALDAV_SYNC +import org.tasks.data.entity.FORCE_MICROSOFT_SYNC import org.tasks.data.entity.SUPPRESS_SYNC import org.tasks.data.entity.Task import org.tasks.jobs.WorkManager @@ -50,9 +52,11 @@ class SyncAdapters @Inject constructor( } val needsGoogleTaskSync = !task.googleTaskUpToDate(original) && googleTaskDao.getAllByTaskId(task.id).isNotEmpty() + val needsMicrosoftSync = (task.checkTransitory(FORCE_MICROSOFT_SYNC) || !task.microsoftUpToDate(original)) + && caldavDao.isAccountType(task.id, listOf(TYPE_MICROSOFT)) val needsIcalendarSync = (task.checkTransitory(FORCE_CALDAV_SYNC) || !task.caldavUpToDate(original)) && caldavDao.isAccountType(task.id, TYPE_ICALENDAR) - if (needsGoogleTaskSync || needsIcalendarSync) { + if (needsGoogleTaskSync || needsMicrosoftSync || needsIcalendarSync) { sync.sync(false) } } @@ -77,6 +81,7 @@ class SyncAdapters @Inject constructor( TYPE_CALDAV, TYPE_TASKS, TYPE_ETEBASE, + TYPE_MICROSOFT ) .isNotEmpty() diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt new file mode 100644 index 000000000..72ed59a62 --- /dev/null +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt @@ -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? { + val taskLists = ArrayList() + 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? { + val tasks = ArrayList() + 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.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 = 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): List { + 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 + } + } +} diff --git a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt index a9bf33602..f3a821195 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt @@ -104,6 +104,7 @@ data class Tasks( val displayName: String, val createdDateTime: String, val isChecked: Boolean, + val checkedDateTime: String? = null, ) enum class Importance { diff --git a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt index e94ea58aa..d070a6805 100644 --- a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt @@ -47,6 +47,7 @@ import org.tasks.data.entity.Alarm.Companion.whenStarted import org.tasks.data.entity.Attachment import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.FORCE_CALDAV_SYNC +import org.tasks.data.entity.FORCE_MICROSOFT_SYNC import org.tasks.data.entity.TagData import org.tasks.data.entity.Task import org.tasks.data.entity.Task.Companion.NOTIFY_MODE_FIVE @@ -303,6 +304,7 @@ class TaskEditViewModel @Inject constructor( geofenceApi.update(place) } task.putTransitory(FORCE_CALDAV_SYNC, true) + task.putTransitory(FORCE_MICROSOFT_SYNC, true) task.modificationDate = currentTimeMillis() } diff --git a/app/src/test/resources/microsoft/task_with_completed_subtask.json b/app/src/test/resources/microsoft/task_with_completed_subtask.json new file mode 100644 index 000000000..56cee5273 --- /dev/null +++ b/app/src/test/resources/microsoft/task_with_completed_subtask.json @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt b/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt index 628f0b992..19b10baa1 100644 --- a/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt +++ b/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt @@ -209,6 +209,24 @@ data class Task @OptIn(ExperimentalSerializationApi::class) constructor( && order == original.order } + fun microsoftUpToDate(original: Task?): Boolean { + if (this === original) { + return true + } + return if (original == null) { + false + } else title == original.title + && priority == original.priority + && dueDate == original.dueDate + && completionDate == original.completionDate + && deletionDate == original.deletionDate + && notes == original.notes + && recurrence == original.recurrence + } + + val isSaved: Boolean + get() = id != NO_ID + @Synchronized fun suppressSync() { putTransitory(SUPPRESS_SYNC, true)