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.

I combined the SyncMediaWorker into it so the same notification can be used and less noise is sent to the user
  • Loading branch information
BrayanDSO committed Mar 27, 2024
1 parent 027a96c commit 102288f
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 197 deletions.
76 changes: 55 additions & 21 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import anki.collection.OpChanges
import anki.sync.SyncStatusResponse
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.snackbar.BaseTransientBottomBar
Expand Down Expand Up @@ -1200,24 +1201,52 @@ open class DeckPicker :
}
}

private fun automaticSync() {
val preferences = baseContext.sharedPrefs()
private suspend fun automaticSync(conflictResolution: ConflictResolution? = null) {
Timber.v("automaticSync")

// Check whether the option is selected, the user is signed in, last sync was AUTOMATIC_SYNC_TIME ago
// (currently 10 minutes), and is not under a metered connection (if not allowed by preference)
val lastSyncTime = preferences.getLong("lastSyncTime", 0)
val autoSyncIsEnabled = preferences.getBoolean("automaticSyncMode", false)
val automaticSyncIntervalInMS = AUTOMATIC_SYNC_MINIMAL_INTERVAL_IN_MINUTES * 60 * 1000
val syncIntervalPassed =
TimeManager.time.intTimeMS() - lastSyncTime > automaticSyncIntervalInMS
val isNotBlockedByMeteredConnection = preferences.getBoolean(
getString(R.string.metered_sync_key),
false
) || !isActiveNetworkMetered()
val isMigratingStorage = mediaMigrationIsInProgress(this)
if (isLoggedIn() && autoSyncIsEnabled && NetworkUtils.isOnline && syncIntervalPassed && isNotBlockedByMeteredConnection && !isMigratingStorage) {
Timber.i("Triggering Automatic Sync")
sync()
suspend fun isSyncNecessary(): Boolean {
val auth = syncAuth() ?: return false
val status = withContext(Dispatchers.IO) {
CollectionManager.getBackend().syncStatus(auth)
}.required

return when (status) {
SyncStatusResponse.Required.NO_CHANGES,
SyncStatusResponse.Required.UNRECOGNIZED,
null -> false
SyncStatusResponse.Required.FULL_SYNC,
SyncStatusResponse.Required.NORMAL_SYNC -> true
}
}

/**
* Check whether the option is selected, the user is signed in, last sync was
* AUTOMATIC_SYNC_TIME ago (currently 10 minutes), and is not under a metered connection
* (if not allowed by preference)
*/
fun syncIntervalPassed(): Boolean {
val lastSyncTime = sharedPrefs().getLong("lastSyncTime", 0)
val automaticSyncIntervalInMS = AUTOMATIC_SYNC_MINIMAL_INTERVAL_IN_MINUTES * 60 * 1000
return TimeManager.time.intTimeMS() - lastSyncTime > automaticSyncIntervalInMS
}

val isAutoSyncEnabled = sharedPrefs().getBoolean("automaticSyncMode", false)

val isBlockedByMeteredConnection = !sharedPrefs().getBoolean(getString(R.string.metered_sync_key), false) &&
isActiveNetworkMetered()

when {
!isAutoSyncEnabled -> Timber.d("autoSync: not enabled")
isBlockedByMeteredConnection -> Timber.d("autoSync: blocked by metered connection")
!NetworkUtils.isOnline -> Timber.d("autoSync: offline")
!syncIntervalPassed() -> Timber.d("autoSync: sync interval not passed")
!isLoggedIn() -> Timber.d("autoSync: not logged in")
!isSyncNecessary() -> Timber.d("autoSync: sync not necessary")
mediaMigrationIsInProgress(this) -> Timber.d("autoSync: migrating storage")
else -> {
Timber.i("Triggering Automatic Sync")
sync(conflictResolution)
}
}
}

Expand All @@ -1236,8 +1265,11 @@ open class DeckPicker :
false
) || backButtonPressedToExit
) {
automaticSync()
finish()
launchCatchingTask {
automaticSync(ConflictResolution.BACKGROUND_SYNC)
}.invokeOnCompletion {
finish()
}
} else {
showSnackbar(R.string.back_pressed_once, Snackbar.LENGTH_SHORT)
}
Expand Down Expand Up @@ -1317,7 +1349,9 @@ open class DeckPicker :
dialogHandler.sendMessage(OneWaySyncDialog(message).toMessage())
}
}
automaticSync()
launchCatchingTask {
automaticSync()
}
}

private fun showCollectionErrorDialog() {
Expand Down Expand Up @@ -1681,7 +1715,7 @@ open class DeckPicker :

/**
* The mother of all syncing attempts. This might be called from sync() as first attempt to sync a collection OR
* from the mSyncConflictResolutionListener if the first attempt determines that a full-sync is required.
* from the syncConflictResolutionListener if the first attempt determines that a full-sync is required.
*/
override fun sync(conflict: ConflictResolution?) {
val preferences = baseContext.sharedPrefs()
Expand Down
17 changes: 11 additions & 6 deletions AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,7 +58,8 @@ object SyncPreferences {

enum class ConflictResolution {
FULL_DOWNLOAD,
FULL_UPLOAD;
FULL_UPLOAD,
BACKGROUND_SYNC;
}

data class SyncCompletion(val isSuccess: Boolean)
Expand Down Expand Up @@ -140,6 +141,7 @@ fun DeckPicker.handleNewSync(
when (conflict) {
ConflictResolution.FULL_DOWNLOAD -> handleDownload(deckPicker, auth, deckPicker.mediaUsnOnConflict)
ConflictResolution.FULL_UPLOAD -> handleUpload(deckPicker, auth, deckPicker.mediaUsnOnConflict)
ConflictResolution.BACKGROUND_SYNC -> SyncWorker.startCollectionSync(deckPicker, auth, syncMedia)
null -> {
handleNormalSync(deckPicker, auth, syncMedia)
}
Expand All @@ -150,6 +152,9 @@ fun DeckPicker.handleNewSync(
throw exc
}
withCol { notetypes._clear_cache() }
sharedPrefs().edit {
putLong("lastSyncTime", TimeManager.time.intTimeMS())
}
refreshState()
}
}
Expand Down Expand Up @@ -187,7 +192,7 @@ private fun updateLogin(context: Context, username: String, hkey: String?) {
}
}

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

Expand Down Expand Up @@ -286,7 +291,7 @@ private suspend fun handleDownload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
SyncMediaWorker.start(deckPicker, auth)
SyncWorker.startMediaSync(deckPicker, auth)
}
}

Expand All @@ -313,7 +318,7 @@ private suspend fun handleUpload(
}
deckPicker.refreshState()
if (mediaUsn != null) {
SyncMediaWorker.start(deckPicker, auth)
SyncWorker.startMediaSync(deckPicker, auth)
}
}
Timber.i("Full Upload Completed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
package com.ichi2.anki.notifications

object NotificationId {
const val SYNC_MEDIA = 123
const val SYNC = 123
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,24 @@ 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.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import makeLinksClickable

// TODO BEFORE-RELEASE Dismiss the related notification, if any, when the dialog is dismissed.
Expand Down Expand Up @@ -183,6 +195,42 @@ 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()
if (workInfo?.state == WorkInfo.State.RUNNING) {
val progress = backend.latestProgress()
if (!progress.hasNormalSync() || progress.hasMediaSync()) {
return@onEach
}
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) {
while (backend.latestProgress().hasNormalSync()) {
delay(100)
}
}
}
}.launchIn(activity.lifecycleScope)
}
})
}

companion object {
Expand Down
Loading

0 comments on commit 102288f

Please sign in to comment.