mirror of
https://github.com/ankidroid/Anki-Android.git
synced 2024-09-20 12:02:16 +02:00
Fix UI being in bad state when storage migration fails (#13774)
When migration fails, we used to be stuck in a kind of a limbo: the migration is considered done (failed, but done) by the service, but `userMigrationIsInProgress` still returned true, so the UI was not updated to reflect the state of the migration. To overcome the issue, this introduces the state of “migration is paused due to an error”, which is saved in shared preferences. In this state, the toolbar shows the same migrate button that offers to resume migration, and the sync icon is hidden. A few TODOs were also added. Co-authored-by: Mike Hardy <github@mikehardy.net>
This commit is contained in:
parent
330e585985
commit
b268a933e4
@ -47,6 +47,7 @@ import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
@ -89,10 +90,12 @@ import com.ichi2.anki.preferences.AdvancedSettingsFragment
|
||||
import com.ichi2.anki.receiver.SdCardReceiver
|
||||
import com.ichi2.anki.servicelayer.*
|
||||
import com.ichi2.anki.servicelayer.SchedulerService.NextCard
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.collectionWillBeMadeInaccessibleAfterUninstall
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.userMigrationIsInProgress
|
||||
import com.ichi2.anki.services.MediaMigrationState
|
||||
import com.ichi2.anki.services.MigrationService
|
||||
import com.ichi2.anki.services.PREF_MIGRATION_ERROR_TEXT
|
||||
import com.ichi2.anki.services.getMediaMigrationState
|
||||
import com.ichi2.anki.services.withBoundTo
|
||||
import com.ichi2.anki.snackbar.showSnackbar
|
||||
import com.ichi2.anki.stats.AnkiStatsTaskHandler
|
||||
@ -118,6 +121,7 @@ import com.ichi2.utils.NetworkUtils.isActiveNetworkMetered
|
||||
import com.ichi2.utils.Permissions.hasStorageAccessPermission
|
||||
import com.ichi2.widget.WidgetStatus
|
||||
import kotlinx.coroutines.*
|
||||
import makeLinksClickable
|
||||
import net.ankiweb.rsdroid.BackendFactory
|
||||
import net.ankiweb.rsdroid.RustCleanup
|
||||
import org.json.JSONException
|
||||
@ -438,29 +442,36 @@ open class DeckPicker :
|
||||
*
|
||||
* See: #5304
|
||||
* @return true: Interrupt startup. `false`: continue as normal
|
||||
*
|
||||
* TODO BEFORE-RELEASE This always returns false.
|
||||
* Investigate why and either fix the method or make it return Unit.
|
||||
*/
|
||||
open fun startingStorageMigrationInterruptsStartup(): Boolean {
|
||||
val migrationStatus = ScopedStorageService.migrationStatus(this)
|
||||
Timber.i("migration status: %s", migrationStatus)
|
||||
when (migrationStatus) {
|
||||
ScopedStorageService.Status.NEEDS_MIGRATION -> {
|
||||
// TODO: we should propose a migration, but not yet (alpha users should opt in)
|
||||
// If the migration was proposed too soon, don't show it again and startup normally.
|
||||
// TODO: This logic needs thought
|
||||
// showDialogThatOffersToMigrateStorage(onPostpone = {
|
||||
// // Unblocks the UI if opened from changing the deck path
|
||||
// updateDeckList()
|
||||
// invalidateOptionsMenu()
|
||||
// handleStartup(skipStorageMigration = true)
|
||||
// })
|
||||
return false // TODO: Allow startup normally
|
||||
val mediaMigrationState = getMediaMigrationState()
|
||||
Timber.i("migration status: %s", mediaMigrationState)
|
||||
when (mediaMigrationState) {
|
||||
is MediaMigrationState.NotOngoing.Needed -> {
|
||||
// TODO BEFORE-RELEASE we should propose a migration, but not yet (alpha users should opt in)
|
||||
// If the migration was proposed too soon, don't show it again and startup normally.
|
||||
// TODO BEFORE-RELEASE This logic needs thought
|
||||
// showDialogThatOffersToMigrateStorage(onPostpone = {
|
||||
// // Unblocks the UI if opened from changing the deck path
|
||||
// updateDeckList()
|
||||
// invalidateOptionsMenu()
|
||||
// handleStartup(skipStorageMigration = true)
|
||||
// })
|
||||
return false // TODO BEFORE-RELEASE Allow startup normally
|
||||
}
|
||||
ScopedStorageService.Status.IN_PROGRESS -> {
|
||||
is MediaMigrationState.Ongoing.PausedDueToError -> {
|
||||
showDialogThatOffersToResumeMigrationAfterError(mediaMigrationState.errorText)
|
||||
return false
|
||||
}
|
||||
is MediaMigrationState.Ongoing.NotPaused -> {
|
||||
startMigrateUserDataService()
|
||||
return false
|
||||
}
|
||||
// App is already using Scoped Storage Directory for user data, no need to migrate & can proceed with startup
|
||||
ScopedStorageService.Status.COMPLETED, ScopedStorageService.Status.NOT_NEEDED -> return false
|
||||
is MediaMigrationState.NotOngoing.NotNeeded -> return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,9 +622,9 @@ open class DeckPicker :
|
||||
* relying instead on modifying it directly and/or using [onPrepareOptionsMenu].
|
||||
* Note an issue with the latter: https://github.com/ankidroid/Anki-Android/issues/7755
|
||||
*/
|
||||
private fun setupMigrationProgressMenuItem(menu: Menu, migrationInProgress: Boolean) {
|
||||
private fun setupMigrationProgressMenuItem(menu: Menu, mediaMigrationState: MediaMigrationState) {
|
||||
val migrationProgressMenuItem = menu.findItem(R.id.action_migration_progress)
|
||||
.apply { isVisible = migrationInProgress }
|
||||
.apply { isVisible = mediaMigrationState is MediaMigrationState.Ongoing.NotPaused }
|
||||
|
||||
suspend fun CircularProgressIndicator.publishProgress(progress: MigrationService.Progress) {
|
||||
when (progress) {
|
||||
@ -634,7 +645,7 @@ open class DeckPicker :
|
||||
}
|
||||
}
|
||||
|
||||
if (migrationInProgress) {
|
||||
if (mediaMigrationState is MediaMigrationState.Ongoing.NotPaused) {
|
||||
if (cachedMigrationProgressMenuItemActionView == null) {
|
||||
val actionView = migrationProgressMenuItem.actionView!!
|
||||
.also { cachedMigrationProgressMenuItemActionView = it }
|
||||
@ -708,9 +719,9 @@ open class DeckPicker :
|
||||
optionsMenuState?.run {
|
||||
menu.findItem(R.id.deck_picker_action_filter).isVisible = searchIcon
|
||||
updateUndoIconFromState(menu.findItem(R.id.action_undo), undoIcon)
|
||||
updateSyncIconFromState(menu.findItem(R.id.action_sync), syncIcon)
|
||||
menu.findItem(R.id.action_scoped_storage_migrate).isVisible = offerToMigrate
|
||||
setupMigrationProgressMenuItem(menu, migrationInProgress)
|
||||
updateSyncIconFromState(menu.findItem(R.id.action_sync), this)
|
||||
menu.findItem(R.id.action_scoped_storage_migrate).isVisible = shouldShowStartMigrationButton
|
||||
setupMigrationProgressMenuItem(menu, mediaMigrationState)
|
||||
}
|
||||
}
|
||||
|
||||
@ -725,34 +736,35 @@ open class DeckPicker :
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSyncIconFromState(menuItem: MenuItem, syncIcon: SyncIconState) {
|
||||
menuItem.setTitle(
|
||||
when (syncIcon) {
|
||||
SyncIconState.Normal -> R.string.button_sync
|
||||
SyncIconState.PendingChanges -> R.string.button_sync
|
||||
SyncIconState.FullSync -> R.string.sync_menu_title_full_sync
|
||||
SyncIconState.NotLoggedIn -> R.string.sync_menu_title_no_account
|
||||
SyncIconState.Disabled -> R.string.button_sync_disabled
|
||||
}
|
||||
)
|
||||
when (syncIcon) {
|
||||
SyncIconState.Normal -> {
|
||||
BadgeDrawableBuilder.removeBadge(menuItem)
|
||||
}
|
||||
SyncIconState.PendingChanges -> {
|
||||
BadgeDrawableBuilder(this)
|
||||
.withColor(ContextCompat.getColor(this@DeckPicker, R.color.badge_warning))
|
||||
.replaceBadge(menuItem)
|
||||
}
|
||||
SyncIconState.FullSync, SyncIconState.NotLoggedIn -> {
|
||||
BadgeDrawableBuilder(this)
|
||||
.withText('!')
|
||||
.withColor(ContextCompat.getColor(this@DeckPicker, R.color.badge_error))
|
||||
.replaceBadge(menuItem)
|
||||
}
|
||||
SyncIconState.Disabled -> {
|
||||
BadgeDrawableBuilder.removeBadge(menuItem)
|
||||
menuItem.isVisible = false
|
||||
private fun updateSyncIconFromState(menuItem: MenuItem, state: OptionsMenuState) {
|
||||
if (state.mediaMigrationState is MediaMigrationState.Ongoing) {
|
||||
menuItem.isVisible = false
|
||||
} else {
|
||||
menuItem.isVisible = true
|
||||
|
||||
menuItem.setTitle(
|
||||
when (state.syncIcon) {
|
||||
SyncIconState.Normal, SyncIconState.PendingChanges -> R.string.button_sync
|
||||
SyncIconState.FullSync -> R.string.sync_menu_title_full_sync
|
||||
SyncIconState.NotLoggedIn -> R.string.sync_menu_title_no_account
|
||||
}
|
||||
)
|
||||
|
||||
when (state.syncIcon) {
|
||||
SyncIconState.Normal -> {
|
||||
BadgeDrawableBuilder.removeBadge(menuItem)
|
||||
}
|
||||
SyncIconState.PendingChanges -> {
|
||||
BadgeDrawableBuilder(this)
|
||||
.withColor(ContextCompat.getColor(this@DeckPicker, R.color.badge_warning))
|
||||
.replaceBadge(menuItem)
|
||||
}
|
||||
SyncIconState.FullSync, SyncIconState.NotLoggedIn -> {
|
||||
BadgeDrawableBuilder(this)
|
||||
.withText('!')
|
||||
.withColor(ContextCompat.getColor(this@DeckPicker, R.color.badge_error))
|
||||
.replaceBadge(menuItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -763,32 +775,28 @@ open class DeckPicker :
|
||||
val searchIcon = decks.count() >= 10
|
||||
val undoIcon = undoName(resources).ifEmpty { null }
|
||||
val syncIcon = fetchSyncStatus(col)
|
||||
val offerToUpgrade = shouldOfferToUpgrade(context)
|
||||
val migrationInProgress = userMigrationIsInProgress(context)
|
||||
OptionsMenuState(searchIcon, undoIcon, syncIcon, offerToUpgrade, migrationInProgress)
|
||||
val mediaMigrationState = getMediaMigrationState()
|
||||
val shouldShowStartMigrationButton = shouldOfferToMigrate() ||
|
||||
mediaMigrationState is MediaMigrationState.Ongoing.PausedDueToError
|
||||
OptionsMenuState(searchIcon, undoIcon, syncIcon, shouldShowStartMigrationButton, mediaMigrationState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldOfferToUpgrade(context: Context): Boolean {
|
||||
// TODO BEFORE-RELEASE This doesn't offer to migrate data if not logged in.
|
||||
// This should be changed so that we offer to migrate regardless.
|
||||
private fun shouldOfferToMigrate(): Boolean {
|
||||
// ALLOW_UNSAFE_MIGRATION skips ensuring that the user is backed up to AnkiWeb
|
||||
if (!BuildConfig.ALLOW_UNSAFE_MIGRATION && !isLoggedIn()) {
|
||||
return false
|
||||
}
|
||||
return !userMigrationIsInProgress(context) && collectionWillBeMadeInaccessibleAfterUninstall(context)
|
||||
return getMediaMigrationState() is MediaMigrationState.NotOngoing.Needed
|
||||
}
|
||||
|
||||
private fun fetchSyncStatus(col: Collection): SyncIconState {
|
||||
val auth = syncAuth()
|
||||
return when (SyncStatus.getSyncStatus(col, this, auth)) {
|
||||
SyncStatus.BADGE_DISABLED, SyncStatus.NO_CHANGES -> {
|
||||
SyncIconState.Normal
|
||||
}
|
||||
SyncStatus.ONGOING_MIGRATION -> {
|
||||
SyncIconState.Disabled
|
||||
}
|
||||
SyncStatus.HAS_CHANGES -> {
|
||||
SyncIconState.PendingChanges
|
||||
}
|
||||
SyncStatus.BADGE_DISABLED, SyncStatus.NO_CHANGES -> SyncIconState.Normal
|
||||
SyncStatus.HAS_CHANGES -> SyncIconState.PendingChanges
|
||||
SyncStatus.NO_ACCOUNT -> SyncIconState.NotLoggedIn
|
||||
SyncStatus.FULL_SYNC -> SyncIconState.FullSync
|
||||
}
|
||||
@ -817,7 +825,12 @@ open class DeckPicker :
|
||||
}
|
||||
R.id.action_scoped_storage_migrate -> {
|
||||
Timber.i("DeckPicker:: migrate button pressed")
|
||||
showDialogThatOffersToMigrateStorage(shownAutomatically = false)
|
||||
val migrationState = getMediaMigrationState()
|
||||
if (migrationState is MediaMigrationState.Ongoing.PausedDueToError) {
|
||||
showDialogThatOffersToResumeMigrationAfterError(migrationState.errorText)
|
||||
} else {
|
||||
showDialogThatOffersToMigrateStorage(shownAutomatically = false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.action_import -> {
|
||||
@ -1098,7 +1111,7 @@ open class DeckPicker :
|
||||
|
||||
launchCatchingTask {
|
||||
val shownBackupDialog = BackupPromptDialog.showIfAvailable(this@DeckPicker)
|
||||
if (!shownBackupDialog && shouldOfferToUpgrade(this@DeckPicker) && timeToShowStorageMigrationDialog()) {
|
||||
if (!shownBackupDialog && shouldOfferToMigrate() && timeToShowStorageMigrationDialog()) {
|
||||
showDialogThatOffersToMigrateStorage(shownAutomatically = true)
|
||||
}
|
||||
}
|
||||
@ -2593,6 +2606,38 @@ open class DeckPicker :
|
||||
dialog.addScopedStorageLearnMoreLinkAndShow(message)
|
||||
}
|
||||
|
||||
private fun showDialogThatOffersToResumeMigrationAfterError(errorText: String) {
|
||||
val helpUrl = getString(R.string.link_migration_failed_dialog_learn_more_en)
|
||||
val message = getString(R.string.migration__resume_after_failed_dialog__message, errorText, helpUrl)
|
||||
.parseAsHtml()
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.scoped_storage_title)
|
||||
.setMessage(message)
|
||||
.setNegativeButton(R.string.dialog_cancel) { _, _ -> }
|
||||
.setPositiveButton(R.string.migration__resume_after_failed_dialog__button_positive) { _, _ ->
|
||||
getSharedPrefs(this@DeckPicker).edit { remove(PREF_MIGRATION_ERROR_TEXT) }
|
||||
startMigrateUserDataService()
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
.create()
|
||||
.makeLinksClickable()
|
||||
.show()
|
||||
}
|
||||
|
||||
// TODO BEFORE-RELEASE Fix the logic. As I understand, this works the following way,
|
||||
// which could make a little more sense:
|
||||
// if (media sync is not disabled,
|
||||
// and (either we sync media unconditionally or are on a suitable network),
|
||||
// and (either we are logged in, or unsafe migration is disallowed (the default))):
|
||||
// set flag migrate-after-media-synced, and
|
||||
// call sync, which may fail to actually sync or even fail to start syncing
|
||||
// (in these cases, migration might start unexpectedly after a successful sync);
|
||||
// else:
|
||||
// tell the user that migration is disabled in the settings (might not be true)
|
||||
// and tell them to sync & backup before continuing (which isn't possible),
|
||||
// and instead of offering them to force sync,
|
||||
// offer them to migrate regardless of the above.
|
||||
private fun performMediaSyncBeforeStorageMigration() {
|
||||
// if we allow an unsafe migration, the 'sync required' dialog shows an unsafe migration confirmation dialog
|
||||
val showUnsafeSyncDialog = (BuildConfig.ALLOW_UNSAFE_MIGRATION && !isLoggedIn())
|
||||
@ -2654,20 +2699,15 @@ data class OptionsMenuState(
|
||||
/** If undo is available, a string describing the action. */
|
||||
val undoIcon: String?,
|
||||
val syncIcon: SyncIconState,
|
||||
val offerToMigrate: Boolean,
|
||||
val migrationInProgress: Boolean
|
||||
val shouldShowStartMigrationButton: Boolean,
|
||||
val mediaMigrationState: MediaMigrationState
|
||||
)
|
||||
|
||||
enum class SyncIconState {
|
||||
Normal,
|
||||
PendingChanges,
|
||||
FullSync,
|
||||
NotLoggedIn,
|
||||
|
||||
/**
|
||||
* The icon should appear as disabled. Currently only occurs during scoped storage migration.
|
||||
*/
|
||||
Disabled
|
||||
NotLoggedIn
|
||||
}
|
||||
|
||||
class CollectionLoadingErrorDialog : DialogHandlerMessage(
|
||||
|
@ -153,6 +153,8 @@ object ScopedStorageService {
|
||||
* Whether a user data scoped storage migration is taking place
|
||||
* This refers to the [MigrateUserData] operation of copying media which can take a long time.
|
||||
*
|
||||
* DEPRECATED. Use [com.ichi2.anki.services.getMediaMigrationState] instead.
|
||||
*
|
||||
* @throws IllegalStateException If either [PREF_MIGRATION_SOURCE] or [PREF_MIGRATION_DESTINATION] is set (but not both)
|
||||
* It is a logic bug if only one is set
|
||||
*/
|
||||
@ -217,6 +219,10 @@ object ScopedStorageService {
|
||||
/**
|
||||
* Checks if current directory being used by AnkiDroid to store user data is a Legacy Storage Directory.
|
||||
* This directory is stored under [CollectionHelper.PREF_COLLECTION_PATH] in SharedPreferences
|
||||
*
|
||||
* DEPRECATED. Use either [com.ichi2.anki.services.getMediaMigrationState], or
|
||||
* [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp].
|
||||
*
|
||||
* @return `true` if AnkiDroid is storing user data in a Legacy Storage Directory.
|
||||
*/
|
||||
fun isLegacyStorage(context: Context): Boolean {
|
||||
@ -240,6 +246,10 @@ object ScopedStorageService {
|
||||
|
||||
/**
|
||||
* @return `true` if [currentDirPath] is a Legacy Storage Directory.
|
||||
*
|
||||
* DEPRECATED. Use either [com.ichi2.anki.services.getMediaMigrationState], or
|
||||
* [com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp].
|
||||
*
|
||||
*/
|
||||
fun isLegacyStorage(currentDirPath: String, context: Context): Boolean {
|
||||
val internalScopedDirPath = CollectionHelper.getAppSpecificInternalAnkiDroidDirectory(context)
|
||||
@ -272,29 +282,6 @@ object ScopedStorageService {
|
||||
return true
|
||||
}
|
||||
|
||||
fun migrationStatus(context: Context): Status {
|
||||
if ((!isLegacyStorage(context) && !userMigrationIsInProgress(context))) {
|
||||
return Status.COMPLETED
|
||||
}
|
||||
|
||||
if (userMigrationIsInProgress(context)) {
|
||||
return Status.IN_PROGRESS
|
||||
}
|
||||
|
||||
if (!collectionWillBeMadeInaccessibleAfterUninstall(context)) {
|
||||
return Status.NOT_NEEDED
|
||||
}
|
||||
|
||||
return Status.NEEDS_MIGRATION
|
||||
}
|
||||
|
||||
enum class Status {
|
||||
NEEDS_MIGRATION,
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
NOT_NEEDED
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user's current collection is now inaccessible due to a 'reinstall'
|
||||
*
|
||||
@ -332,6 +319,8 @@ object ScopedStorageService {
|
||||
/**
|
||||
* Whether the user's current collection will be inaccessible after uninstalling the app
|
||||
*
|
||||
* DEPRECATED. Use [com.ichi2.anki.services.getMediaMigrationState] instead.
|
||||
*
|
||||
* @return `false` if:
|
||||
* * ⚠️ The directory will be **removed** on uninstall
|
||||
* * The user installed with Android 11+, and is more likely to expect this behavior
|
||||
|
@ -29,10 +29,9 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.ichi2.anki.*
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_DESTINATION
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_SOURCE
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.userMigrationIsInProgress
|
||||
import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles
|
||||
import com.ichi2.anki.servicelayer.scopedstorage.MoveConflictedFile
|
||||
import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData
|
||||
@ -49,6 +48,10 @@ import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
|
||||
// Shared preferences key for user-readable text representing migration error.
|
||||
// If it is set, it means that media migration is ongoing, but currently paused due to an error.
|
||||
const val PREF_MIGRATION_ERROR_TEXT = "migrationErrorText"
|
||||
|
||||
/**
|
||||
* A foreground service responsible for migrating the collection
|
||||
* from a public directory to an app-private directory.
|
||||
@ -106,6 +109,8 @@ class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder<
|
||||
|
||||
var flowOfProgress: MutableStateFlow<Progress> = MutableStateFlow(Progress.CalculatingTransferSize)
|
||||
|
||||
private val preferences get() = AnkiDroidApp.getSharedPrefs(this)
|
||||
|
||||
private lateinit var migrateUserDataTask: MigrateUserData
|
||||
|
||||
private var serviceHasBeenStarted = false
|
||||
@ -113,7 +118,9 @@ class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder<
|
||||
// To simplify things by allowing binding to the service at any time,
|
||||
// make sure the service has the correct progress emitted even if it is not going to be started.
|
||||
override fun onCreate() {
|
||||
if (userMigrationHasSucceeded) flowOfProgress.tryEmit(Progress.Success)
|
||||
if (getMediaMigrationState() is MediaMigrationState.NotOngoing.NotNeeded) {
|
||||
flowOfProgress.tryEmit(Progress.Success)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@ -130,8 +137,7 @@ class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder<
|
||||
flowOfProgress.emit(Progress.CalculatingTransferSize)
|
||||
|
||||
try {
|
||||
migrateUserDataTask = MigrateUserData
|
||||
.createInstance(AnkiDroidApp.getSharedPrefs(this@MigrationService))
|
||||
migrateUserDataTask = MigrateUserData.createInstance(preferences)
|
||||
|
||||
val remainingTransferSize = getRemainingTransferSize(migrateUserDataTask)
|
||||
val totalBytesToTransfer = getOrSetTotalTransferSize(valueToPersistIfNotCalculated = remainingTransferSize)
|
||||
@ -153,7 +159,7 @@ class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder<
|
||||
// on *background* thread, and removed here in another *background* thread.
|
||||
// These are read from other threads, mostly via userMigrationIsInProgress,
|
||||
// which might be a race condition and lead to subtle bugs.
|
||||
AnkiDroidApp.getSharedPrefs(this@MigrationService).edit {
|
||||
preferences.edit {
|
||||
remove(PREF_MIGRATION_DESTINATION)
|
||||
remove(PREF_MIGRATION_SOURCE)
|
||||
remove(TOTAL_BYTES_TO_TRANSFER_KEY)
|
||||
@ -162,6 +168,11 @@ class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder<
|
||||
flowOfProgress.emit(Progress.Success)
|
||||
} catch (e: Exception) {
|
||||
CrashReportService.sendExceptionReport(e, "Storage migration failed")
|
||||
|
||||
preferences.edit {
|
||||
putString(PREF_MIGRATION_ERROR_TEXT, getUserFriendlyErrorText(e).toString())
|
||||
}
|
||||
|
||||
flowOfProgress.emit(Progress.Failure(e))
|
||||
}
|
||||
}
|
||||
@ -302,7 +313,8 @@ private fun Context.makeMigrationProgressNotification(progress: MigrationService
|
||||
|
||||
/**
|
||||
* A delegate for a property that yields:
|
||||
* * the [MigrationService] if the migration is in progress, and when the owner is started,
|
||||
* * the [MigrationService] if media migration is currently ongoing and not paused,
|
||||
* and when the owner is started,
|
||||
* * or `null` otherwise.
|
||||
*
|
||||
* Note: binding to the service happens fast, but not immediately,
|
||||
@ -314,7 +326,7 @@ fun <O> O.migrationServiceWhileStartedOrNull(): ReadOnlyProperty<Any?, Migration
|
||||
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
if (userMigrationIsInProgress(this@migrationServiceWhileStartedOrNull)) {
|
||||
if (getMediaMigrationState() is MediaMigrationState.Ongoing.NotPaused) {
|
||||
try {
|
||||
withBoundTo<MigrationService> {
|
||||
service = it
|
||||
@ -330,11 +342,57 @@ fun <O> O.migrationServiceWhileStartedOrNull(): ReadOnlyProperty<Any?, Migration
|
||||
return ReadOnlyProperty { _, _ -> service }
|
||||
}
|
||||
|
||||
/**************************************************************************************************/
|
||||
|
||||
/**
|
||||
* This assumes that the service is only created if the migration is, was, or is going to run,
|
||||
* that is when it can "succeed" at all.
|
||||
* This represents the overarching state of media migration as determined by:
|
||||
* * the build/flavor,
|
||||
* * the API level,
|
||||
* * some settings persisted in shared preferences.
|
||||
*
|
||||
* See also the logic in [com.ichi2.anki.DeckPicker.shouldOfferToUpgrade]
|
||||
* This is not determined by permissions or static variables.
|
||||
*/
|
||||
private val Context.userMigrationHasSucceeded get() =
|
||||
!userMigrationIsInProgress(this) && !isLegacyStorage(this)
|
||||
sealed interface MediaMigrationState {
|
||||
sealed interface NotOngoing : MediaMigrationState {
|
||||
sealed interface NotNeeded : NotOngoing {
|
||||
object CollectionIsInAppPrivateFolder : NotNeeded
|
||||
object CollectionIsInPublicFolderButWillRemainAccessible : NotNeeded
|
||||
}
|
||||
object Needed : NotOngoing
|
||||
}
|
||||
|
||||
sealed interface Ongoing : MediaMigrationState {
|
||||
object NotPaused : Ongoing
|
||||
class PausedDueToError(val errorText: String) : Ongoing
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Consider refactoring ScopedStorageService to remove its methods used here,
|
||||
// inlining them, and use this method throughout the app for media migration state.
|
||||
fun Context.getMediaMigrationState(): MediaMigrationState {
|
||||
val preferences = AnkiDroidApp.getSharedPrefs(this)
|
||||
|
||||
fun migrationIsOngoing() = ScopedStorageService.userMigrationIsInProgress(preferences)
|
||||
fun collectionIsInAppPrivateDirectory() = !ScopedStorageService.isLegacyStorage(this)
|
||||
fun collectionWillRemainAccessibleAfterReinstall() =
|
||||
!ScopedStorageService.collectionWillBeMadeInaccessibleAfterUninstall(this)
|
||||
|
||||
return if (migrationIsOngoing()) {
|
||||
val errorText = preferences.getString(PREF_MIGRATION_ERROR_TEXT, null)
|
||||
when {
|
||||
errorText.isNullOrBlank() ->
|
||||
MediaMigrationState.Ongoing.NotPaused
|
||||
else ->
|
||||
MediaMigrationState.Ongoing.PausedDueToError(errorText)
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
collectionIsInAppPrivateDirectory() ->
|
||||
MediaMigrationState.NotOngoing.NotNeeded.CollectionIsInAppPrivateFolder
|
||||
collectionWillRemainAccessibleAfterReinstall() ->
|
||||
MediaMigrationState.NotOngoing.NotNeeded.CollectionIsInPublicFolderButWillRemainAccessible
|
||||
else ->
|
||||
MediaMigrationState.NotOngoing.Needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,26 +22,18 @@ import anki.sync.SyncAuth
|
||||
import anki.sync.SyncStatusResponse
|
||||
import com.ichi2.anki.AnkiDroidApp
|
||||
import com.ichi2.anki.SyncPreferences
|
||||
import com.ichi2.anki.servicelayer.ScopedStorageService.userMigrationIsInProgress
|
||||
import com.ichi2.libanki.Collection
|
||||
import net.ankiweb.rsdroid.BackendFactory
|
||||
|
||||
// TODO Remove BADGE_DISABLED from this enum, it doesn't belong here
|
||||
enum class SyncStatus {
|
||||
NO_ACCOUNT, NO_CHANGES, HAS_CHANGES, FULL_SYNC, BADGE_DISABLED,
|
||||
|
||||
/**
|
||||
* Scope storage migration is ongoing. Sync should be disabled.
|
||||
*/
|
||||
ONGOING_MIGRATION;
|
||||
NO_ACCOUNT, NO_CHANGES, HAS_CHANGES, FULL_SYNC, BADGE_DISABLED;
|
||||
|
||||
companion object {
|
||||
private var sPauseCheckingDatabase = false
|
||||
private var sMarkedInMemory = false
|
||||
|
||||
fun getSyncStatus(col: Collection, context: Context, auth: SyncAuth?): SyncStatus {
|
||||
if (userMigrationIsInProgress(context)) {
|
||||
return ONGOING_MIGRATION
|
||||
}
|
||||
if (isDisabled) {
|
||||
return BADGE_DISABLED
|
||||
}
|
||||
|
@ -156,7 +156,6 @@
|
||||
<string name="sd_card_not_mounted">Device storage not mounted</string>
|
||||
<string name="empty_cloze_warning">Edit this note and add some cloze deletions. (%1$s)</string>
|
||||
<string name="button_sync" comment="Text of the sync button">Sync</string>
|
||||
<string name="button_sync_disabled" comment="Text of the sync button when disabled">Sync temporarily disabled</string>
|
||||
<string name="button_upgrade" comment="Text of the sync button">Migrate to scoped storage</string>
|
||||
<string name="cancel_sync_confirm">Do you want to cancel the sync?</string>
|
||||
<string name="continue_sync">Continue sync</string>
|
||||
@ -278,6 +277,13 @@
|
||||
<a href="%2$s">Learn more and get help</a>
|
||||
]]></string>
|
||||
|
||||
<string name="migration__resume_after_failed_dialog__message"><![CDATA[
|
||||
Storage migration is paused after failing with the error: %1$s.
|
||||
<br><br>
|
||||
<a href="%2$s">Learn more and get help</a>
|
||||
]]></string>
|
||||
<string name="migration__resume_after_failed_dialog__button_positive">Resume migration</string>
|
||||
|
||||
<string name="directory_inaccessible_after_uninstall" comment="Dialog title if AnkiDroid can't access the collection once the app is installed">Inaccessible collection</string>
|
||||
<string name="directory_inaccessible_after_uninstall_summary" comment="the parameter is the path to the AnkiDroid folder. Typically /storage/emulated/0/AnkiDroid">We are unable to access your collection after AnkiDroid is uninstalled due to a change in Play Store Policy\n\nYour data is safe and can be restored. It is located at\n%s\n\nSelect an option below to restore:</string>
|
||||
<string name="restore_data_from_ankiweb">Restore from AnkiWeb (recommended)</string>
|
||||
|
@ -410,6 +410,7 @@
|
||||
<string name="start_migration_progress_message">Starting storage migration. You may resume using AnkiDroid shortly.</string>
|
||||
<string name="migration_part_1_done_resume">You may resume using AnkiDroid.
|
||||
\nStorage migration will continue in the background.</string>
|
||||
|
||||
<!-- JS Addons -->
|
||||
<string name="not_valid_js_addon">%s is not a valid javascript addon package</string>
|
||||
<string name="could_not_create_dir">Could not create directory %s</string>
|
||||
|
Loading…
Reference in New Issue
Block a user