From 56c2d69c40c027f4f589f32d3b07e5aba0422b7a Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:35:32 -0300 Subject: [PATCH] feat: sync collection in the background aimed only to be used with autosync, so the user can leave the app without having to wait the synchronization to be complete. I combined the SyncMediaWorker into it to use the same notification and reduce noise --- .../main/java/com/ichi2/anki/DeckPicker.kt | 20 +- .../src/main/java/com/ichi2/anki/Sync.kt | 10 +- .../anki/notifications/NotificationId.kt | 2 +- .../ui/dialogs/ActivityAgnosticDialogs.kt | 59 ++++ .../com/ichi2/anki/worker/SyncMediaWorker.kt | 168 ----------- .../java/com/ichi2/anki/worker/SyncWorker.kt | 284 ++++++++++++++++++ .../com/ichi2/anki/worker/UniqueWorkNames.kt | 2 +- 7 files changed, 364 insertions(+), 181 deletions(-) delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index c7970110115f..9c7fdd927d97 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -116,7 +116,7 @@ import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.dialogs.storageMigrationFailedDialogIsShownOrPending import com.ichi2.anki.utils.SECONDS_PER_DAY import com.ichi2.anki.widgets.DeckAdapter -import com.ichi2.anki.worker.SyncMediaWorker +import com.ichi2.anki.worker.SyncWorker import com.ichi2.annotations.NeedsTest import com.ichi2.async.* import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat @@ -1202,7 +1202,7 @@ open class DeckPicker : } } - private suspend fun automaticSync() { + private suspend fun automaticSync(runInBackground: Boolean = false) { Timber.v("automaticSync()") /** @@ -1247,12 +1247,18 @@ open class DeckPicker : Timber.d("autoSync: no collection changes to sync. Syncing media if set") if (shouldFetchMedia(sharedPrefs())) { val auth = syncAuth() ?: return - SyncMediaWorker.start(this, auth) + SyncWorker.startMediaSync(this, auth) } } else -> { - Timber.i("Triggering Automatic Sync") - sync() + if (runInBackground) { + Timber.i("Triggering background automatic sync") + val auth = syncAuth() ?: return + SyncWorker.startCollectionSync(this, auth, shouldFetchMedia(sharedPrefs())) + } else { + Timber.i("Triggering foreground automatic sync") + sync() + } } } } @@ -1272,8 +1278,10 @@ open class DeckPicker : false ) || backButtonPressedToExit ) { + // can't use launchCatchingTask because any errors + // would need to be shown in the UI lifecycleScope.launch { - automaticSync() + automaticSync(runInBackground = true) }.invokeOnCompletion { finish() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index 4a0ce5268cfa..6cb8082398b5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -32,7 +32,7 @@ import com.ichi2.anki.dialogs.SyncErrorDialog import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.ScopedStorageService import com.ichi2.anki.snackbar.showSnackbar -import com.ichi2.anki.worker.SyncMediaWorker +import com.ichi2.anki.worker.SyncWorker import com.ichi2.async.AsyncOperation import com.ichi2.libanki.createBackup import com.ichi2.libanki.fullUploadOrDownload @@ -208,7 +208,7 @@ private suspend fun handleNormalSync( manualCancelButton = R.string.dialog_cancel ) { withCol { - syncCollection(auth2, media = false) // media is synced by SyncMediaWorker + syncCollection(auth2, media = false) // media is synced in the background by SyncWorker } } @@ -228,7 +228,7 @@ private suspend fun handleNormalSync( deckPicker.showSyncLogMessage(R.string.sync_database_acknowledge, output.serverMessage) deckPicker.refreshState() if (syncMedia) { - SyncMediaWorker.start(deckPicker, auth2) + SyncWorker.startMediaSync(deckPicker, auth2) } } @@ -286,7 +286,7 @@ private suspend fun handleDownload( } deckPicker.refreshState() if (mediaUsn != null) { - SyncMediaWorker.start(deckPicker, auth) + SyncWorker.startMediaSync(deckPicker, auth) } } @@ -313,7 +313,7 @@ private suspend fun handleUpload( } deckPicker.refreshState() if (mediaUsn != null) { - SyncMediaWorker.start(deckPicker, auth) + SyncWorker.startMediaSync(deckPicker, auth) } } Timber.i("Full Upload Completed") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notifications/NotificationId.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notifications/NotificationId.kt index 238486e2fdf4..a26c4a692999 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notifications/NotificationId.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notifications/NotificationId.kt @@ -16,5 +16,5 @@ package com.ichi2.anki.notifications object NotificationId { - const val SYNC_MEDIA = 123 + const val SYNC = 123 } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt index ba03e3373151..c195202dfb1d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/dialogs/ActivityAgnosticDialogs.kt @@ -27,13 +27,28 @@ import androidx.core.os.bundleOf import androidx.core.text.parseAsHtml import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager.getDefaultSharedPreferences +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.android.material.snackbar.Snackbar +import com.ichi2.anki.CollectionManager import com.ichi2.anki.R import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs.Companion.MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY import com.ichi2.anki.utils.getUserFriendlyErrorText +import com.ichi2.anki.withProgress +import com.ichi2.anki.worker.UniqueWorkNames import com.ichi2.utils.copyToClipboard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import makeLinksClickable +import timber.log.Timber // TODO BEFORE-RELEASE Dismiss the related notification, if any, when the dialog is dismissed. // Currently we are leaving the notification dangling after the migration has completed. @@ -183,6 +198,50 @@ class ActivityAgnosticDialogs private constructor(private val application: Appli } } }) + + // Shows a progress dialog if the user tries to open the app + // while the collection is being synced in the background + // COULD_BE_BETTER: get the progress directly from the Worker instead of + // retrieving it again from the backend + application.registerActivityLifecycleCallbacks(object : DefaultActivityLifecycleCallbacks { + override fun onActivityStarted(activity: Activity) { + if (activity !is FragmentActivity) return + + val workManager = WorkManager.getInstance(activity) + val workInfoFlow = workManager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC) + val backend = CollectionManager.getBackend() // just for checking progress + + workInfoFlow.flowWithLifecycle(activity.lifecycle).onEach { workInfoList -> + val workInfo = workInfoList.firstOrNull() + Timber.v("Sync last work: $workInfo") + + if (workInfo?.state == WorkInfo.State.RUNNING) { + val progress = withContext(Dispatchers.IO) { + backend.latestProgress() + } + if (!progress.hasNormalSync() || progress.hasMediaSync()) { + return@onEach + } + + Timber.d("Showing background sync dialog") + activity.withProgress(extractProgress = { + if (progress.hasNormalSync()) { + text = progress.normalSync.run { "$added\n$removed" } + } + }, onCancel = { + workManager.cancelWorkById(workInfo.id) + activity.showSnackbar(R.string.sync_cancelled, Snackbar.LENGTH_SHORT) + }, manualCancelButton = R.string.dialog_cancel) { + withContext(Dispatchers.IO) { + while (backend.latestProgress().hasNormalSync()) { + delay(100) + } + } + } + } + }.launchIn(activity.lifecycleScope) + } + }) } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt deleted file mode 100644 index c75228c1128d..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright (c) 2024 Brayan Oliveira - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -package com.ichi2.anki.worker - -import android.app.Notification -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import anki.sync.MediaSyncProgress -import anki.sync.SyncAuth -import anki.sync.syncAuth -import com.ichi2.anki.Channel -import com.ichi2.anki.CollectionManager -import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.R -import com.ichi2.anki.cancelMediaSync -import com.ichi2.anki.notifications.NotificationId -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import timber.log.Timber - -class SyncMediaWorker( - context: Context, - parameters: WorkerParameters -) : CoroutineWorker(context, parameters) { - - private val workManager = WorkManager.getInstance(context) - private val notificationManager = NotificationManagerCompat.from(context) - - override suspend fun doWork(): Result { - Timber.v("SyncMediaWorker::doWork") - setForeground(getForegroundInfo()) - - try { - val syncAuth = syncAuth { - hkey = inputData.getString(HKEY_KEY)!! - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - } - withCol { backend.syncMedia(syncAuth) } - val backend = CollectionManager.getBackend() - var syncProgress: MediaSyncProgress? = null - while (true) { - val status = backend.mediaSyncStatus() - if (!status.active) { - hideNotification() - return Result.success() - } - // avoid sending repeated notifications - if (syncProgress != status.progress) { - syncProgress = status.progress - // TODO display better the result. In tests, using setContentText lead to - // truncated text if it had more than two lines. - // `added`, `removed` and `checked` already come translated from the backend - val notificationText = syncProgress.run { "$added $removed $checked" } - notify(getProgressNotification(notificationText)) - } - delay(100) - } - } catch (cancellationException: CancellationException) { - Timber.w(cancellationException) - cancelMediaSync(CollectionManager.getBackend()) - hideNotification() - throw cancellationException - } catch (throwable: Throwable) { - Timber.w(throwable) - notify(buildNotification { setContentTitle(CollectionManager.TR.syncMediaFailed()) }) - return Result.failure() - } - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = buildNotification { - setContentTitle(applicationContext.getString(R.string.syncing_media)) - setOngoing(true) - setProgress(0, 0, true) - foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE - } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo(NotificationId.SYNC_MEDIA, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - ForegroundInfo(NotificationId.SYNC_MEDIA, notification) - } - } - - private fun notify(notification: Notification) = - notificationManager.notify(NotificationId.SYNC_MEDIA, notification) - - private fun hideNotification() = - notificationManager.cancel(NotificationId.SYNC_MEDIA) - - private fun buildNotification(block: NotificationCompat.Builder.() -> Unit): Notification { - return NotificationCompat.Builder(applicationContext, Channel.SYNC.id).apply { - priority = NotificationCompat.PRIORITY_LOW - setSmallIcon(R.drawable.ic_star_notify) - setCategory(NotificationCompat.CATEGORY_PROGRESS) - setSilent(true) - contentView - block() - }.build() - } - - private fun getProgressNotification(progress: CharSequence): Notification { - val title = applicationContext.getString(R.string.syncing_media) - - val cancelTitle = applicationContext.getString(R.string.dialog_cancel) - val cancelIntent = workManager.createCancelPendingIntent(id) - - return buildNotification { - setContentTitle(title) - setContentText(progress) - setOngoing(true) - addAction(R.drawable.close_icon, cancelTitle, cancelIntent) - } - } - - companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" - - fun start(context: Context, syncAuth: SyncAuth) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val data = Data.Builder() - .putString(HKEY_KEY, syncAuth.hkey) - .putString(ENDPOINT_KEY, syncAuth.endpoint) - .build() - - val request = OneTimeWorkRequestBuilder() - .setInputData(data) - .setConstraints(constraints) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - - WorkManager.getInstance(context) - .beginUniqueWork(UniqueWorkNames.SYNC_MEDIA, ExistingWorkPolicy.KEEP, request) - .enqueue() - } - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt new file mode 100644 index 000000000000..f03375583236 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.worker + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import anki.collection.Progress +import anki.sync.MediaSyncProgress +import anki.sync.SyncAuth +import anki.sync.SyncCollectionResponse +import anki.sync.syncAuth +import com.ichi2.anki.Channel +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.R +import com.ichi2.anki.SyncPreferences +import com.ichi2.anki.notifications.NotificationId +import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.libanki.syncCollection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException + +/** + * Syncs the collection in the background, which is useful when the user isn't interacting with + * the app, like when doing an automatic sync after leaving the app. + * + * The collection will be blocked while synchronizing, so any component that + * depends on it should be blocked as well until the task ends. + * + * Media can be synced without blocking the collection. + * + * Note that one-way syncs will be ignored, since they require user interaction. + */ +class SyncWorker( + context: Context, + parameters: WorkerParameters +) : CoroutineWorker(context, parameters) { + + private val workManager = WorkManager.getInstance(context) + private val notificationManager = NotificationManagerCompat.from(context) + private val cancelIntent = workManager.createCancelPendingIntent(id) + + override suspend fun doWork(): Result { + Timber.v("SyncCollectionWorker::doWork") + setForeground(getForegroundInfo()) + + val syncAuth = syncAuth { + hkey = inputData.getString(HKEY_KEY)!! + inputData.getString(ENDPOINT_KEY)?.let { + endpoint = it + } + } + val shouldSyncCollection = inputData.getBoolean(SYNC_MEDIA_KEY, false) + val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false) + + try { + if (shouldSyncCollection) { + syncCollection(syncAuth, shouldSyncMedia) + } else if (shouldSyncMedia) { + syncMedia(syncAuth) + } + } catch (cancellationException: CancellationException) { + with(CollectionManager.getBackend()) { + setWantsAbort() + abortSync() + abortMediaSync() + } + throw cancellationException + } catch (throwable: Throwable) { + Timber.w(throwable) + notify { + setContentTitle(applicationContext.getString(R.string.sync_error)) + } + return Result.failure() + } + return Result.success() + } + + private suspend fun syncCollection(auth: SyncAuth, syncMedia: Boolean) { + Timber.v("SyncWorker::syncCollection") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val monitor = scope.launch { + val backend = CollectionManager.getBackend() + var syncProgress: Progress.NormalSync? = null + while (true) { + val progress = backend.latestProgress() // avoid sending repeated notifications + if (progress.hasNormalSync() && syncProgress != progress.normalSync) { + syncProgress = progress.normalSync + val text = syncProgress.run { "$added\n$removed" } + notify(getProgressNotification(title = TR.syncSyncing(), progress = text)) + } + delay(100) + } + } + val response = try { + withCol { + syncCollection(auth, media = false) + } + } finally { + Timber.d("Collection sync completed. Cancelling monitor...") + monitor.cancel() + } + when (response.required) { + // a successful sync returns this value + SyncCollectionResponse.ChangesRequired.NO_CHANGES -> { + Timber.i("Sync: NO_CHANGES") + withCol { _loadScheduler() } // scheduler version may have changed + if (syncMedia) { + if (response.hasNewEndpoint()) { + applicationContext.sharedPrefs().edit { + putString(SyncPreferences.CURRENT_SYNC_URI, response.newEndpoint) + } + val newAuth = syncAuth { hkey = auth.hkey; endpoint = response.newEndpoint } + syncMedia(newAuth) + } else { + syncMedia(auth) + } + } + } + SyncCollectionResponse.ChangesRequired.FULL_SYNC, + SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD, + SyncCollectionResponse.ChangesRequired.FULL_UPLOAD -> { + Timber.d("One-way sync required: Skipping background sync") + } + SyncCollectionResponse.ChangesRequired.UNRECOGNIZED, + SyncCollectionResponse.ChangesRequired.NORMAL_SYNC, + null -> { + TODO("should never happen") + } + } + } + + private suspend fun syncMedia(auth: SyncAuth) { + Timber.v("SyncWorker::syncMedia") + + withCol { backend.syncMedia(auth) } + + val backend = CollectionManager.getBackend() + var syncProgress: MediaSyncProgress? = null + while (true) { + val status = backend.mediaSyncStatus() + if (!status.active) { + return + } + // avoid sending repeated notifications + if (syncProgress != status.progress) { + syncProgress = status.progress + // TODO display better the result. Using setContentText leads to + // truncated text if it there are more than two lines. + // `added`, `removed` and `checked` already come translated from the backend + val title = applicationContext.getString(R.string.syncing_media) + val text = syncProgress.run { "$added $removed $checked" } + notify(getProgressNotification(title = title, progress = text)) + } + delay(100) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val cancelTitle = applicationContext.getString(R.string.dialog_cancel) + val notification = buildNotification { + setContentTitle(TR.syncSyncing()) + setOngoing(true) + setProgress(0, 0, true) + addAction(R.drawable.close_icon, cancelTitle, cancelIntent) + foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_DEFAULT + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NotificationId.SYNC, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NotificationId.SYNC, notification) + } + } + + private fun notify(notification: Notification) { + notificationManager.notify(NotificationId.SYNC, notification) + } + + private fun notify(builder: NotificationCompat.Builder.() -> Unit) { + notify(buildNotification(builder)) + } + + private fun buildNotification(block: NotificationCompat.Builder.() -> Unit): Notification { + return NotificationCompat.Builder(applicationContext, Channel.SYNC.id).apply { + priority = NotificationCompat.PRIORITY_LOW + setSmallIcon(R.drawable.ic_star_notify) + setCategory(NotificationCompat.CATEGORY_PROGRESS) + setSilent(true) + block() + }.build() + } + + private fun getProgressNotification(title: CharSequence, progress: CharSequence): Notification { + val cancelTitle = applicationContext.getString(R.string.dialog_cancel) + + return buildNotification { + setContentTitle(title) + setContentText(progress) + setOngoing(true) + addAction(R.drawable.close_icon, cancelTitle, cancelIntent) + } + } + + companion object { + private const val HKEY_KEY = "hkey" + private const val ENDPOINT_KEY = "endpoint" + private const val SYNC_COLLECTION = "syncCollection" + private const val SYNC_MEDIA_KEY = "syncMedia" + + private fun start(context: Context, syncAuth: SyncAuth, syncCollection: Boolean, syncMedia: Boolean) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val data = Data.Builder() + .putString(HKEY_KEY, syncAuth.hkey) + .putString(ENDPOINT_KEY, syncAuth.endpoint) + .putBoolean(SYNC_COLLECTION, syncCollection) + .putBoolean(SYNC_MEDIA_KEY, syncMedia) + .build() + + val request = OneTimeWorkRequestBuilder() + .setInputData(data) + .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + WorkManager.getInstance(context) + .beginUniqueWork(UniqueWorkNames.SYNC, ExistingWorkPolicy.KEEP, request) + .enqueue() + } + + fun startCollectionSync(context: Context, syncAuth: SyncAuth, withMedia: Boolean) = + start( + context, + syncAuth, + syncCollection = true, + syncMedia = withMedia + ) + + fun startMediaSync(context: Context, syncAuth: SyncAuth) = + start( + context, + syncAuth, + syncCollection = false, + syncMedia = true + ) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/UniqueWorkNames.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/UniqueWorkNames.kt index 84f1124a9b77..67a94b79b9d5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/UniqueWorkNames.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/UniqueWorkNames.kt @@ -16,5 +16,5 @@ package com.ichi2.anki.worker object UniqueWorkNames { - const val SYNC_MEDIA = "syncMedia" + const val SYNC = "sync" }