Skip to content

Commit

Permalink
feat: sync collection in the background
Browse files Browse the repository at this point in the history
aimed only to be used with autosync, so the user can leave the app without having to wait the synchronization to be complete.

to avoid hanging, the ideal solution would be to show a progress dialog if the worker is running.

Right now, that isn't possible because of the synchronized way of building most of the app's entry points (DeckPicker, CardBrowser, Reviewer, etc)

Instead the background sync is simply cancelled.

Since syncing is really fast with the new backend, and that background sync is intended to be done only when the user wants to leave the app, I believe that most cases will get unnoticed
  • Loading branch information
BrayanDSO committed Apr 1, 2024
1 parent 7ae66cc commit 15e4ae9
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 11 deletions.
19 changes: 14 additions & 5 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ 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
Expand Down Expand Up @@ -1202,7 +1203,7 @@ open class DeckPicker :
}
}

private suspend fun automaticSync() {
private suspend fun automaticSync(runInBackground: Boolean = false) {
/**
* @return whether there are collection changes to be sync.
*
Expand Down Expand Up @@ -1250,8 +1251,14 @@ open class DeckPicker :
setLastSyncTimeToNow()
}
else -> {
Timber.i("autoSync: start")
sync()
if (runInBackground) {
Timber.i("autoSync: starting background")
val auth = syncAuth() ?: return
SyncWorker.start(this, auth, shouldFetchMedia(sharedPrefs()))
} else {
Timber.i("autoSync: starting foreground")
sync()
}
}
}
}
Expand All @@ -1271,8 +1278,10 @@ open class DeckPicker :
false
) || backButtonPressedToExit
) {
launchCatchingTask {
automaticSync()
// can't use launchCatchingTask because any errors
// would need to be shown in the UI
lifecycleScope.launch {
automaticSync(runInBackground = true)
}.invokeOnCompletion {
finish()
}
Expand Down
18 changes: 15 additions & 3 deletions AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.ichi2.anki.dialogs.DialogHandlerMessage
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.servicelayer.ScopedStorageService
import com.ichi2.anki.services.ReminderService
import com.ichi2.anki.worker.SyncWorker
import com.ichi2.annotations.NeedsTest
import com.ichi2.themes.Themes
import com.ichi2.themes.Themes.disableXiaomiForceDarkMode
Expand Down Expand Up @@ -72,7 +73,14 @@ class IntentHandler : Activity() {
// #6157 - We want to block actions that need permissions we don't have, but not the default case
// as this requires nothing
val runIfStoragePermissions = { runnable: () -> Unit -> performActionIfStorageAccessible(reloadIntent, action) { runnable() } }
when (getLaunchType(intent)) {
val launchType = getLaunchType(intent)
// TODO block the UI with some kind of ProgressDialog instead of cancelling the sync work
if (launchType != LaunchType.COPY_DEBUG_INFO) {
// copy debug is the only type that doesn't need a Collection instance, and
// consequently won't be hanged by SyncWorker's collection block
SyncWorker.cancel(this)
}
when (launchType) {
LaunchType.FILE_IMPORT -> runIfStoragePermissions {
handleFileImport(fileIntent, reloadIntent, action)
finish()
Expand All @@ -85,8 +93,12 @@ class IntentHandler : Activity() {
handleImageImport(intent)
finish()
}
LaunchType.SYNC -> runIfStoragePermissions { handleSyncIntent(reloadIntent, action) }
LaunchType.REVIEW -> runIfStoragePermissions { handleReviewIntent(intent) }
LaunchType.SYNC -> runIfStoragePermissions {
handleSyncIntent(reloadIntent, action)
}
LaunchType.REVIEW -> runIfStoragePermissions {
handleReviewIntent(intent)
}
LaunchType.DEFAULT_START_APP_IF_NEW -> {
Timber.d("onCreate() performing default action")
launchDeckPickerIfNoOtherTasks(reloadIntent)
Expand Down
4 changes: 2 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ private fun updateLogin(context: Context, username: String, hkey: String?) {
}
}

private fun cancelSync(backend: Backend) {
fun cancelSync(backend: Backend) {
backend.setWantsAbort()
backend.abortSync()
}
Expand All @@ -209,7 +209,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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
package com.ichi2.anki.notifications

object NotificationId {
const val SYNC_MEDIA = 123
const val SYNC = 101
const val SYNC_MEDIA = 102
}
248 changes: 248 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/*
* Copyright (c) 2024 Brayan Oliveira <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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.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.cancelSync
import com.ichi2.anki.notifications.NotificationId
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.setLastSyncTimeToNow
import com.ichi2.anki.utils.ext.trySetForeground
import com.ichi2.libanki.syncCollection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException

/**
* Syncs the collection in the background. That 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("SyncWorker::doWork")
trySetForeground(getForegroundInfo())

val auth = syncAuth {
hkey = inputData.getString(HKEY_KEY)!!
inputData.getString(ENDPOINT_KEY)?.let {
endpoint = it
}
}
val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false)

try {
syncCollection(auth, shouldSyncMedia)
} catch (cancellationException: CancellationException) {
cancelSync(CollectionManager.getBackend())
throw cancellationException
} catch (throwable: Throwable) {
Timber.w(throwable)
notify {
setContentTitle(applicationContext.getString(R.string.sync_error))
}
return Result.failure()
}

Timber.d("SyncWorker: success")
applicationContext.setLastSyncTimeToNow()
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(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) {
val syncAuth = if (response.hasNewEndpoint()) {
applicationContext.sharedPrefs().edit {
putString(SyncPreferences.CURRENT_SYNC_URI, response.newEndpoint)
}
syncAuth { hkey = auth.hkey; endpoint = response.newEndpoint }
} else {
auth
}
syncMedia(syncAuth)
}
}
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 fun syncMedia(auth: SyncAuth) {
Timber.i("Enqueuing SyncMediaWorker")
workManager.enqueueUniqueWork(
UniqueWorkNames.SYNC_MEDIA,
ExistingWorkPolicy.KEEP,
SyncMediaWorker.getWorkRequest(auth)
)
}

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_DEFERRED
}
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(progress: CharSequence): Notification {
val cancelTitle = applicationContext.getString(R.string.dialog_cancel)

return buildNotification {
setContentTitle(TR.syncSyncing())
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_MEDIA_KEY = "syncMedia"

fun start(context: Context, syncAuth: SyncAuth, 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_MEDIA_KEY, syncMedia)
.build()

val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(data)
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()

WorkManager.getInstance(context)
.enqueueUniqueWork(UniqueWorkNames.SYNC, ExistingWorkPolicy.KEEP, request)
}

fun cancel(context: Context) {
WorkManager.getInstance(context)
.cancelUniqueWork(UniqueWorkNames.SYNC)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
package com.ichi2.anki.worker

object UniqueWorkNames {
const val SYNC = "sync"
const val SYNC_MEDIA = "syncMedia"
}

0 comments on commit 15e4ae9

Please sign in to comment.