Skip to content

Commit

Permalink
TODO Scoped Storage: Sync before migration
Browse files Browse the repository at this point in the history
* Adds optional continuation to `sync` which displays a notification
 if executed in the background
* Adds a dialog box, triggered from the 'scoped storage' menu item

Issue 5304
  • Loading branch information
david-allison committed Feb 28, 2023
1 parent 7952f96 commit a86f5cd
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 10 deletions.
71 changes: 67 additions & 4 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ open class DeckPicker :

private fun fetchSyncStatus(col: Collection): SyncIconState {
val auth = syncAuth()
return when (SyncStatus.getSyncStatus(col, this, auth)) {
return when (SyncStatus.getSyncStatusForBadge(col, this, auth)) {
SyncStatus.BADGE_DISABLED, SyncStatus.NO_CHANGES -> {
SyncIconState.Normal
}
Expand Down Expand Up @@ -1609,11 +1609,15 @@ open class DeckPicker :
return false
}

override fun sync(conflict: ConflictResolution?) {
sync(conflict, onSuccess = null)
}

/**
* 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.
*/
override fun sync(conflict: ConflictResolution?) {
fun sync(conflict: ConflictResolution?, onSuccess: AsyncOperation? = null) {
val preferences = getSharedPrefs(baseContext)

if (!canSync(this)) {
Expand Down Expand Up @@ -1644,7 +1648,7 @@ open class DeckPicker :
handleNewSync(conflict, shouldFetchMedia())
} else {
val data = arrayOf(hkey, shouldFetchMedia(), conflict, HostNumFactory.getInstance(baseContext))
Connection.sync(createSyncListener(), Connection.Payload(data))
Connection.sync(createSyncListener(onSuccess), Connection.Payload(data))
}
}
// Warn the user in case the connection is metered
Expand Down Expand Up @@ -2521,6 +2525,7 @@ open class DeckPicker :
withProgress(getString(R.string.start_migration_progress_message)) {
withContext(Dispatchers.IO) {
loadDeckCounts?.cancel()
Thread.sleep(10000)
CollectionHelper.instance.closeCollection(false, "migration to scoped storage")
ScopedStorageService.migrateEssentialFiles(baseContext)

Expand Down Expand Up @@ -2601,7 +2606,8 @@ open class DeckPicker :
.setPositiveButton(
getString(R.string.scoped_storage_migrate)
) { _, _ ->
migrate()
Timber.i("User opted into a scoped storage migration")
displaySyncRequiredDialogOrMigrate()
}
.setNegativeButton(
getString(R.string.scoped_storage_postpone)
Expand All @@ -2610,10 +2616,67 @@ open class DeckPicker :
}.addScopedStorageLearnMoreLinkAndShow(message)
}

private fun displaySyncRequiredDialogOrMigrate() {
if (!requiresSyncBeforeMigration()) {
Timber.i("No Sync needed, proceeding with scoped storage migration")
migrate()
} else {
Timber.i("User needs to sync before a migration, requesting permission")
AlertDialog.Builder(this).show {
title(R.string.sync_required_title)
message(R.string.sync_required_message)
positiveButton(R.string.button_sync) {
Timber.i("User synced before a storage migration")
sync(conflict = null, onSuccess = MigrateStorageOnSyncSuccess(resources))
}
negativeButton(R.string.scoped_storage_postpone) {
Timber.i("User postponed a storage migration")
setMigrationWasLastPostponedAtToNow()
}
}
}
}

/**
* @return whether the user has pending changes which should be synced before a storage migration.
* `null` if an unexpected status occurred.
*/
private fun requiresSyncBeforeMigration(): Boolean {
// we can obtain the system sync status: `getSystemSyncStatus`,
// but this does not ensure media is synced
return true
}

// Scoped Storage migration
private fun setMigrationWasLastPostponedAtToNow() {
migrationWasLastPostponedAt = TimeManager.time.intTime()
}

fun performAsyncOperation(
operation: AsyncOperation,
channel: Channel
) {
fun showNotification() {
// Store a persistent message instructing AnkiDroid to perform the operation
DialogHandler.storeMessage(operation.handlerMessage.toMessage())
// Show a basic notification to the user in the notification bar in the meantime
val title = operation.notificationTitle
val message = operation.notificationMessage
showSimpleNotification(title, message, channel)
}

if (mActivityPaused) {
showNotification()
return
}

try {
operation.handlerMessage.handleAsyncMessage(this)
} catch (e: IllegalStateException) {
Timber.w(e)
showNotification()
}
}
}

/** Android's onCreateOptionsMenu does not play well with coroutines, as
Expand Down
4 changes: 3 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.afollestad.materialdialogs.MaterialDialog
import com.ichi2.anim.ActivityTransitionAnimation
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.dialogs.AsyncOperation
import com.ichi2.anki.dialogs.SyncErrorDialog
import com.ichi2.anki.servicelayer.ScopedStorageService
import com.ichi2.anki.snackbar.showSnackbar
Expand Down Expand Up @@ -352,7 +353,7 @@ private suspend fun handleMediaSync(
}
}

fun DeckPicker.createSyncListener() = object : Connection.CancellableTaskListener {
fun DeckPicker.createSyncListener(onSuccess: AsyncOperation?) = object : Connection.CancellableTaskListener {
private var mCurrentMessage: String? = null
private var mCountUp: Long = 0
private var mCountDown: Long = 0
Expand Down Expand Up @@ -721,6 +722,7 @@ fun DeckPicker.createSyncListener() = object : Connection.CancellableTaskListene
Timber.w(e, "Failed to load StudyOptionsFragment after sync.")
}
}
onSuccess?.let { performAsyncOperation(it, Channel.SYNC) }
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/dialogs/AsyncOperation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 David Allison <davidallisongithub@gmail.com>
*
* 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.dialogs

import android.content.res.Resources
import com.ichi2.anki.DeckPicker
import com.ichi2.anki.R

abstract class AsyncOperation {
abstract val notificationMessage: String?
abstract val notificationTitle: String
abstract val handlerMessage: DialogHandlerMessage
}

/**
* Called from [DeckPicker.showDialogThatOffersToMigrateStorage]
*/
class MigrateStorageOnSyncSuccess(res: Resources) : AsyncOperation() {
override val notificationMessage = res.getString(R.string.storage_migration_sync_notification)
override val notificationTitle = res.getString(R.string.sync_database_acknowledge)

override val handlerMessage: DialogHandlerMessage
get() = MigrateOnSyncSuccessHandler()

class MigrateOnSyncSuccessHandler : DialogHandlerMessage(
which = WhichDialogHandler.MSG_MIGRATE_ON_SYNC_SUCCESS,
analyticName = "SyncSuccessHandler"
) {
override fun handleAsyncMessage(deckPicker: DeckPicker) {
deckPicker.migrate()
}

override fun toMessage() = emptyMessage(this.what)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ abstract class DialogHandlerMessage protected constructor(val which: WhichDialog
WhichDialogHandler.MSG_SHOW_DATABASE_ERROR_DIALOG -> DatabaseErrorDialog.ShowDatabaseErrorDialog.fromMessage(message)
WhichDialogHandler.MSG_SHOW_FORCE_FULL_SYNC_DIALOG -> ForceFullSyncDialog.fromMessage(message)
WhichDialogHandler.MSG_DO_SYNC -> IntentHandler.Companion.DoSync()
WhichDialogHandler.MSG_MIGRATE_ON_SYNC_SUCCESS -> MigrateStorageOnSyncSuccess.MigrateOnSyncSuccessHandler()
}
}
}
Expand All @@ -116,7 +117,8 @@ abstract class DialogHandlerMessage protected constructor(val which: WhichDialog
MSG_SHOW_MEDIA_CHECK_COMPLETE_DIALOG(5),
MSG_SHOW_DATABASE_ERROR_DIALOG(6),
MSG_SHOW_FORCE_FULL_SYNC_DIALOG(7),
MSG_DO_SYNC(8);
MSG_DO_SYNC(8),
MSG_MIGRATE_ON_SYNC_SUCCESS(9);
companion object {
fun fromInt(value: Int) = WhichDialogHandler.values().first { it.what == value }
}
Expand Down
19 changes: 15 additions & 4 deletions AnkiDroid/src/main/java/com/ichi2/utils/SyncStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,24 @@ enum class SyncStatus {
private var sPauseCheckingDatabase = false
private var sMarkedInMemory = false

fun getSyncStatus(col: Collection, context: Context, auth: SyncAuth?): SyncStatus {
if (userMigrationIsInProgress(context)) {
return ONGOING_MIGRATION
}
/**
* Returns the user-facing sync status. Includes if user has disabled the badge
*/
fun getSyncStatusForBadge(col: Collection, context: Context, auth: SyncAuth?): SyncStatus {
if (isDisabled) {
return BADGE_DISABLED
}
return getSystemSyncStatus(col, context, auth)
}

/**
* Returns the underling sync status of AnkiDroid's collection (not media).
* @see [getSyncStatusForBadge] for the method used on the sync icon badge
*/
private fun getSystemSyncStatus(col: Collection, context: Context, auth: SyncAuth?): SyncStatus {
if (userMigrationIsInProgress(context)) {
return ONGOING_MIGRATION
}
if (auth == null) {
return NO_ACCOUNT
}
Expand Down
8 changes: 8 additions & 0 deletions AnkiDroid/src/main/res/values/02-strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,12 @@
comment="Displayed when trying to access functionality which cannot be run while storage is being moved to a new location">
This functionality is unavailable during a storage migration
</string>

<string name="storage_migration_sync_notification"
comment="Appears on a notification after the user has opted into a migration via
migration_update_request + provided consent to sync and AnkiDroid was minimised when the sync completed"
>Tap to start storage migration</string>

<string name="sync_required_title">Sync Required</string>
<string name="sync_required_message">You must sync before performing a storage migration</string>
</resources>

0 comments on commit a86f5cd

Please sign in to comment.