diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 28f75d14055c..4c84f0a7cbc1 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -60,7 +60,6 @@ android { buildConfigField "String", "ACRA_URL", '"https://ankidroid.org/acra/report"' buildConfigField "String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"" buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "false" - buildConfigField "Boolean", "ALLOW_UNSAFE_MIGRATION", "false" buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHash()}\"" buildConfigField "long", "BUILD_TIME", System.currentTimeMillis().toString() resValue "string", "app_name", "AnkiDroid" @@ -127,10 +126,6 @@ android { if (localProperties['enable_languages'] == "false") { android.defaultConfig.resConfigs "en" } - // allows the scoped storage migration when the user is not logged in - if (localProperties["allow_unsafe_migration"] != null) { - buildConfigField "Boolean", "ALLOW_UNSAFE_MIGRATION", localProperties["allow_unsafe_migration"] - } // allow disabling leak canary if (localProperties["enable_leak_canary"] != null) { buildConfigField "Boolean", "ENABLE_LEAK_CANARY", localProperties["enable_leak_canary"] diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 346f7dd839ef..6cb2b0ac3129 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -123,7 +123,6 @@ import com.ichi2.anki.reviewer.MotionEventHandler import com.ichi2.anki.reviewer.PreviousAnswerIndicator import com.ichi2.anki.servicelayer.LanguageHintService.applyLanguageHint import com.ichi2.anki.servicelayer.NoteService.isMarked -import com.ichi2.anki.services.migrationServiceWhileStartedOrNull import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar @@ -334,8 +333,6 @@ abstract class AbstractFlashcardViewer : displayCardAnswer() } - internal val migrationService by migrationServiceWhileStartedOrNull() - /** * Changes which were received when the viewer was in the background * which should be executed once the viewer is visible again @@ -2334,10 +2331,6 @@ abstract class AbstractFlashcardViewer : view: WebView, request: WebResourceRequest ): WebResourceResponse? { - val url = request.url - if (url.toString().startsWith("file://")) { - url.path?.let { path -> migrationService?.migrateFileImmediately(File(path)) } - } resourceHandler.shouldInterceptRequest(request)?.let { return it } return null } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt index e916c7ae56cd..99b941906014 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CollectionManager.kt @@ -17,13 +17,10 @@ package com.ichi2.anki import android.annotation.SuppressLint -import android.content.Context import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import anki.backend.backendError import com.ichi2.anki.common.utils.android.isRobolectric -import com.ichi2.anki.servicelayer.ValidatedMigrationSourceAndDestination -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles import com.ichi2.libanki.Collection import com.ichi2.libanki.Storage.collection import com.ichi2.libanki.importCollectionPackage @@ -377,26 +374,6 @@ object CollectionManager { } } - /** Migrate collection and media databases to scoped storage. - * * Closes the collection, and performs the work in our queue so no - * other code can open the collection while the operation runs. Reopens - * at the end, and rolls back the path change if reopening fails. - */ - suspend fun migrateEssentialFiles(context: Context, folders: ValidatedMigrationSourceAndDestination) { - withQueue { - ensureClosedInner() - val migrator = MigrateEssentialFiles(context, folders) - migrator.migrateFiles() - migrator.updateCollectionPath() - try { - ensureOpenInner() - } catch (e: Exception) { - migrator.restoreOldCollectionPath() - throw e - } - } - } - fun setTestDispatcher(dispatcher: CoroutineDispatcher) { // note: we avoid the call to .limitedParallelism() here, // as it does not seem to be compatible with the test scheduler diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index e9f77ab71bc7..345d7b4976c7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -45,7 +45,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewPropertyAnimator import android.widget.Filterable -import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.RelativeLayout @@ -58,7 +57,6 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.TooltipCompat import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat.OnRequestPermissionsResultCallback import androidx.core.content.ContextCompat @@ -67,7 +65,6 @@ 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.core.util.component1 import androidx.core.util.component2 import androidx.core.view.MenuItemCompat @@ -75,7 +72,6 @@ import androidx.core.view.OnReceiveContentListener import androidx.core.view.isVisible import androidx.draganddrop.DropHelper import androidx.fragment.app.commit -import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration @@ -87,7 +83,6 @@ import androidx.work.WorkManager 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.progressindicator.LinearProgressIndicator import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar @@ -125,11 +120,9 @@ import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ApkgImportResultLaunch import com.ichi2.anki.dialogs.ImportFileSelectionFragment.CsvImportResultLauncherProvider import com.ichi2.anki.dialogs.MediaCheckDialog import com.ichi2.anki.dialogs.MediaCheckDialog.MediaCheckDialogListener -import com.ichi2.anki.dialogs.MigrationProgressDialogFragment import com.ichi2.anki.dialogs.SyncErrorDialog import com.ichi2.anki.dialogs.SyncErrorDialog.Companion.newInstance import com.ichi2.anki.dialogs.SyncErrorDialog.SyncErrorDialogListener -import com.ichi2.anki.dialogs.addScopedStorageLearnMoreLinkAndShow import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyListener import com.ichi2.anki.dialogs.customstudy.CustomStudyDialogFactory @@ -148,25 +141,17 @@ import com.ichi2.anki.preferences.AdvancedSettingsFragment import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage -import com.ichi2.anki.servicelayer.ScopedStorageService.mediaMigrationIsInProgress import com.ichi2.anki.servicelayer.checkMedia -import com.ichi2.anki.services.MediaMigrationState -import com.ichi2.anki.services.MigrationService -import com.ichi2.anki.services.getMediaMigrationState import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar -import com.ichi2.anki.ui.dialogs.storageMigrationFailedDialogIsShownOrPending import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment -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.anki.worker.UniqueWorkNames import com.ichi2.annotations.NeedsTest import com.ichi2.async.deleteMedia -import com.ichi2.async.sendNotificationForAsyncOperation import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat @@ -204,17 +189,12 @@ import com.ichi2.widget.WidgetStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import makeLinksClickable import net.ankiweb.rsdroid.RustCleanup import org.json.JSONException import timber.log.Timber import java.io.File -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.measureTimedValue const val MIGRATION_WAS_LAST_POSTPONED_AT_SECONDS = "secondWhenMigrationWasPostponedLast" const val TIMES_STORAGE_MIGRATION_POSTPONED_KEY = "timesStorageMigrationPostponed" @@ -417,8 +397,6 @@ open class DeckPicker : } } - private var migrateStorageAfterMediaSyncCompleted = false - // stored for testing purposes @VisibleForTesting var createMenuJob: Job? = null @@ -592,8 +570,6 @@ open class DeckPicker : shortAnimDuration = resources.getInteger(android.R.integer.config_shortAnimTime) - launchShowingHidingEssentialFileMigrationProgressDialog() - InitialActivity.checkWebviewVersion(packageManager, this) supportFragmentManager.setFragmentResultListener(DeckPickerContextMenu.REQUEST_KEY_CONTEXT_MENU, this) { requestKey, arguments -> @@ -722,50 +698,6 @@ open class DeckPicker : } } - /** - * The first call in showing dialogs for startup - * - * Attempts startup if storage permission has been acquired, else, it requests the permission - * - * If the migration is in progress, it starts the service if not running - * - * 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 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 - } - is MediaMigrationState.Ongoing.PausedDueToError -> { - if (!storageMigrationFailedDialogIsShownOrPending(this)) { - showDialogThatOffersToResumeMigrationAfterError(mediaMigrationState.errorText) - } - return false - } - is MediaMigrationState.Ongoing.NotPaused -> { - MigrationService.start(baseContext) - return false - } - // App is already using Scoped Storage Directory for user data, no need to migrate & can proceed with startup - is MediaMigrationState.NotOngoing.NotNeeded -> return false - } - } - /** * The first call in showing dialogs for startup - error or success. * Attempts startup if storage permission has been acquired, else, it requests the permission @@ -780,8 +712,6 @@ open class DeckPicker : return } - if (startingStorageMigrationInterruptsStartup()) return - Timber.d("handleStartup: Continuing. unaffected by storage migration") val failure = InitialActivity.getStartupFailureType(this) startupError = if (failure == null) { @@ -922,9 +852,6 @@ open class DeckPicker : return super.onCreateOptionsMenu(menu) } - private var migrationProgressPublishingJob: Job? = null - private var cachedMigrationProgressMenuItemActionView: View? = null - fun setupMediaSyncMenuItem(menu: Menu) { // shouldn't be necessary, but `invalidateOptionsMenu()` is called way more than necessary syncMediaProgressJob?.cancel() @@ -950,91 +877,6 @@ open class DeckPicker : } } - /** - * Set up the menu item that shows circular progress of storage migration. - * Can be called multiple times without harm. - * - * Note that, somewhat unconventionally, AnkiDroid will often call [invalidateOptionsMenu], - * which results in [onCreateOptionsMenu] being called and the menu recreated from scratch, - * with the state of individual menu items reset. - * - * This also means that the view showing progress can be swapped for another view at any time. - * This is not a problem with [ProgressBar], but [CircularProgressIndicator], - * which comes with sensible drawables out of the box--including rounded corners-- - * seems to be failing to draw right away when its `progress` is set, resulting in a flicker. - * - * To overcome these issues, we: - * * set visibility of the menu item on every call, and - * * cache and reuse the view that shows progress. - * - * TODO Investigate whether we can stop recreating the menu, - * 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, mediaMigrationState: MediaMigrationState) { - val migrationProgressMenuItem = menu.findItem(R.id.action_migration_progress) - .apply { isVisible = mediaMigrationState is MediaMigrationState.Ongoing.NotPaused } - - fun CircularProgressIndicator.publishProgress(progress: MigrationService.Progress.MovingMediaFiles) { - when (progress) { - is MigrationService.Progress.MovingMediaFiles.CalculatingNumberOfBytesToMove -> { - this.isIndeterminate = true - } - - is MigrationService.Progress.MovingMediaFiles.MovingFiles -> { - this.isIndeterminate = false - this.progress = (progress.ratio * Int.MAX_VALUE).toInt() - } - } - } - - if (mediaMigrationState is MediaMigrationState.Ongoing.NotPaused) { - if (cachedMigrationProgressMenuItemActionView == null) { - val actionView = migrationProgressMenuItem.actionView!! - .also { cachedMigrationProgressMenuItemActionView = it } - - val progressIndicator = actionView - .findViewById(R.id.progress_indicator) - .apply { max = Int.MAX_VALUE } - - actionView.findViewById(R.id.button).also { button -> - button.setOnClickListener { warnNoSyncDuringMigration() } - TooltipCompat.setTooltipText(button, getText(R.string.show_migration_progress)) - } - - migrationProgressPublishingJob = lifecycleScope.launch { - MigrationService.flowOfProgress - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .filterNotNull() - .collect { progress -> - when (progress) { - is MigrationService.Progress.CopyingEssentialFiles -> { - // Button is not shown when transferring essential files - } - - is MigrationService.Progress.MovingMediaFiles -> { - progressIndicator.publishProgress(progress) - } - - is MigrationService.Progress.Done -> { - updateMenuState() - updateMenuFromState(menu) - updateSearchVisibilityFromState(menu) - } - } - } - } - } else { - migrationProgressMenuItem.actionView = cachedMigrationProgressMenuItemActionView - } - } else { - cachedMigrationProgressMenuItemActionView = null - - migrationProgressPublishingJob?.cancel() - migrationProgressPublishingJob = null - } - } - private fun setupSearchIcon(menuItem: MenuItem) { menuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { // When SearchItem is expanded @@ -1076,8 +918,6 @@ open class DeckPicker : optionsMenuState?.run { updateUndoLabelFromState(menu.findItem(R.id.action_undo), undoLabel, undoAvailable) updateSyncIconFromState(menu.findItem(R.id.action_sync), this) - menu.findItem(R.id.action_scoped_storage_migrate).isVisible = shouldShowStartMigrationButton - setupMigrationProgressMenuItem(menu, mediaMigrationState) } } @@ -1112,12 +952,6 @@ open class DeckPicker : } private fun updateSyncIconFromState(menuItem: MenuItem, state: OptionsMenuState) { - if (state.mediaMigrationState is MediaMigrationState.Ongoing) { - menuItem.isVisible = false - return - } - menuItem.isVisible = true - val provider = MenuItemCompat.getActionProvider(menuItem) as? SyncActionProvider ?: return val tooltipText = when (state.syncIcon) { @@ -1155,24 +989,8 @@ open class DeckPicker : Triple(searchIcon, undoLabel, undoAvailable) }?.let { (searchIcon, undoLabel, undoAvailable) -> val syncIcon = fetchSyncStatus() - val mediaMigrationState = getMediaMigrationState() - val shouldShowStartMigrationButton = shouldOfferToMigrate() || - mediaMigrationState is MediaMigrationState.Ongoing.PausedDueToError - OptionsMenuState(searchIcon, undoLabel, syncIcon, shouldShowStartMigrationButton, mediaMigrationState, undoAvailable) - } - } - - // 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. - // TODO BEFORE-RELEASE Stop offering to migrate on every activity recreation. - // Currently the dialog re-appears if you dismiss it and then e.g. toggle device dark theme. - 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 + OptionsMenuState(searchIcon, undoLabel, syncIcon, undoAvailable) } - return getMediaMigrationState() is MediaMigrationState.NotOngoing.Needed && - MigrationService.flowOfProgress.value !is MigrationService.Progress.Running } private suspend fun fetchSyncStatus(): SyncIconState { @@ -1211,16 +1029,6 @@ open class DeckPicker : } return true } - R.id.action_scoped_storage_migrate -> { - Timber.i("DeckPicker:: migrate button pressed") - val migrationState = getMediaMigrationState() - if (migrationState is MediaMigrationState.Ongoing.PausedDueToError) { - showDialogThatOffersToResumeMigrationAfterError(migrationState.errorText) - } else { - showDialogThatOffersToMigrateStorage(shownAutomatically = false) - } - return true - } R.id.action_import -> { Timber.i("DeckPicker:: Import button pressed") showImportDialog() @@ -1251,15 +1059,6 @@ open class DeckPicker : showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_CONFIRM_RESTORE_BACKUP) return true } - R.id.action_export_collection -> { - Timber.i("DeckPicker:: Export menu item selected") - if (mediaMigrationIsInProgress(this)) { - showSnackbar(R.string.functionality_disabled_during_storage_migration, Snackbar.LENGTH_SHORT) - return true - } - ExportDialogFragment.newInstance().show(supportFragmentManager, "exportDialog") - return true - } else -> return super.onOptionsItemSelected(item) } } @@ -1280,10 +1079,6 @@ open class DeckPicker : override fun exportDialogsFactory(): ExportDialogsFactory = exportingDelegate.dialogsFactory fun exportCollection() { - if (mediaMigrationIsInProgress(this)) { - showSnackbar(R.string.functionality_disabled_during_storage_migration, Snackbar.LENGTH_SHORT) - return - } ExportDialogFragment.newInstance().show(supportFragmentManager, "exportDialog") } @@ -1336,7 +1131,6 @@ open class DeckPicker : public override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean("mIsFABOpen", floatingActionMenu.isFABOpen) - outState.putBoolean("migrateStorageAfterMediaSyncCompleted", migrateStorageAfterMediaSyncCompleted) importColpkgListener?.let { if (it is DatabaseRestorationListener) { outState.getString("dbRestorationPath", it.newAnkiDroidDirectory) @@ -1350,7 +1144,6 @@ open class DeckPicker : public override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) floatingActionMenu.isFABOpen = savedInstanceState.getBoolean("mIsFABOpen") - migrateStorageAfterMediaSyncCompleted = savedInstanceState.getBoolean("migrateStorageAfterMediaSyncCompleted") savedInstanceState.getString("dbRestorationPath")?.let { path -> CollectionHelper.ankiDroidDirectoryOverride = path importColpkgListener = DatabaseRestorationListener(this, path) @@ -1418,7 +1211,6 @@ open class DeckPicker : !NetworkUtils.isOnline -> Timber.d("autoSync: offline") !runInBackground && !syncIntervalPassed() -> Timber.d("autoSync: interval not passed") !isLoggedIn() -> Timber.d("autoSync: not logged in") - mediaMigrationIsInProgress(this) -> Timber.d("autoSync: migrating storage") !areThereChangesToSync() -> { Timber.d("autoSync: no collection changes to sync. Syncing media if set") if (shouldFetchMedia(sharedPrefs())) { @@ -1631,15 +1423,7 @@ open class DeckPicker : */ private fun onFinishedStartup() { launchCatchingTask { - val shownBackupDialog = BackupPromptDialog.showIfAvailable(this@DeckPicker) - if ( - !shownBackupDialog && - shouldOfferToMigrate() && - timeToShowStorageMigrationDialog() && - !storageMigrationFailedDialogIsShownOrPending(this@DeckPicker) - ) { - showDialogThatOffersToMigrateStorage(shownAutomatically = true) - } + BackupPromptDialog.showIfAvailable(this@DeckPicker) } // Force a one-way sync if flag was set in upgrade path, asking the user to confirm if necessary @@ -1886,19 +1670,11 @@ open class DeckPicker : // Show dialogs to deal with database loading issues etc open fun showDatabaseErrorDialog(errorDialogType: DatabaseErrorDialogType) { - if (errorDialogType == DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK && mediaMigrationIsInProgress(this)) { - showSnackbar(R.string.functionality_disabled_during_storage_migration, Snackbar.LENGTH_SHORT) - return - } val newFragment: AsyncDialogFragment = DatabaseErrorDialog.newInstance(errorDialogType) showAsyncDialogFragment(newFragment) } override fun showMediaCheckDialog(dialogType: Int) { - if (dialogType == MediaCheckDialog.DIALOG_CONFIRM_MEDIA_CHECK && mediaMigrationIsInProgress(this)) { - showSnackbar(R.string.functionality_disabled_during_storage_migration, Snackbar.LENGTH_SHORT) - return - } showAsyncDialogFragment(MediaCheckDialog.newInstance(dialogType)) } @@ -1956,12 +1732,6 @@ open class DeckPicker : // Callback method to handle database integrity check override fun integrityCheck() { - if (mediaMigrationIsInProgress(this)) { - // The only path which can still display this is a sync error, which shouldn't be possible - showSnackbar(R.string.functionality_disabled_during_storage_migration, Snackbar.LENGTH_SHORT) - return - } - // #5852 - We were having issues with integrity checks where the users had run out of space. // display a dialog box if we don't have the space val status = CollectionIntegrityStorageCheck.createInstance(this) @@ -1994,7 +1764,7 @@ open class DeckPicker : */ override fun mediaCheck() { launchCatchingTask { - val mediaCheckResult = checkMedia() ?: return@launchCatchingTask + val mediaCheckResult = checkMedia() showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, mediaCheckResult) } } @@ -2047,11 +1817,6 @@ open class DeckPicker : override fun sync(conflict: ConflictResolution?) { val preferences = baseContext.sharedPrefs() - if (!canSync(this)) { - warnNoSyncDuringMigration() - return - } - val hkey = preferences.getString("hkey", "") if (hkey!!.isEmpty()) { Timber.w("User not logged in") @@ -2708,207 +2473,6 @@ open class DeckPicker : } } - /** - * Do the whole migration. - * Blocks the UI until essential files are migrated. - * Change the preferences related to storage - * Migrate the user data in a service - */ - fun migrate() { - migrateStorageAfterMediaSyncCompleted = false - - if (mediaMigrationIsInProgress(this) || !isLegacyStorage(this)) { - // This should not ever occurs. - return - } - - if (activityPaused) { - sendNotificationForAsyncOperation(MigrateStorageOnSyncSuccess(this.resources), Channel.SYNC) - return - } - - loadDeckCounts?.cancel() - - MigrationService.start(baseContext) - } - - private fun launchShowingHidingEssentialFileMigrationProgressDialog() = lifecycleScope.launch { - while (true) { - MigrationService.flowOfProgress - .first { it is MigrationService.Progress.CopyingEssentialFiles } - - val (progress, duration) = measureTimedValue { - withImmediatelyShownProgress(R.string.start_migration_progress_message) { - MigrationService.flowOfProgress - .first { it !is MigrationService.Progress.CopyingEssentialFiles } - } - } - - if (progress is MigrationService.Progress.MovingMediaFiles && duration > 800.milliseconds) { - showSnackbar(R.string.migration_part_1_done_resume) - } - - refreshState() - updateDeckList() - } - } - - /** - * Show a dialog that explains no sync can occur during migration. - */ - private fun warnNoSyncDuringMigration() { - MigrationProgressDialogFragment().show(supportFragmentManager, "MigrationProgressDialogFragment") - } - - /** - * Last time the user had chosen to postpone migration. Or 0 if never. - */ - private var migrationWasLastPostponedAt: Long - get() = baseContext.sharedPrefs().getLong(MIGRATION_WAS_LAST_POSTPONED_AT_SECONDS, 0L) - set(timeInSecond) = baseContext.sharedPrefs() - .edit { putLong(MIGRATION_WAS_LAST_POSTPONED_AT_SECONDS, timeInSecond) } - - /** - * The number of times the storage migration was postponed. -1 for 'disabled' - */ - private var timesStorageMigrationPostponed: Int - get() = baseContext.sharedPrefs().getInt(TIMES_STORAGE_MIGRATION_POSTPONED_KEY, 0) - set(value) = baseContext.sharedPrefs() - .edit { putInt(TIMES_STORAGE_MIGRATION_POSTPONED_KEY, value) } - - /** Whether the user has disabled the dialog from [showDialogThatOffersToMigrateStorage] */ - private val disabledScopedStorageReminder: Boolean - get() = timesStorageMigrationPostponed == -1 - - /** - * Show a dialog offering to migrate, postpone or learn more. - * @return shownAutomatically `true` if the dialog was shown automatically, `false` if the user - * pressed a button to open the dialog - */ - private fun showDialogThatOffersToMigrateStorage(shownAutomatically: Boolean) { - Timber.i("Displaying dialog to migrate storage") - if (mediaMigrationIsInProgress(baseContext)) { - // This should not occur. We should have not called the function in this case. - return - } - - val message = getString(R.string.migration_update_request_requires_media_sync) - - fun onPostponeOnce() { - if (shownAutomatically) { - timesStorageMigrationPostponed += 1 - } - setMigrationWasLastPostponedAtToNow() - } - - fun onPostponePermanently() { - BackupPromptDialog.showPermanentlyDismissDialog( - this, - onCancel = { onPostponeOnce() }, - onDisableReminder = { - this.sharedPrefs().edit { - putInt(TIMES_STORAGE_MIGRATION_POSTPONED_KEY, -1) - remove(MIGRATION_WAS_LAST_POSTPONED_AT_SECONDS) - } - } - ) - } - - var userCheckedDoNotShowAgain = false - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.scoped_storage_title) - .setMessage(message) - .setPositiveButton( - getString(R.string.scoped_storage_migrate) - ) { _, _ -> - performMediaSyncBeforeStorageMigration() - } - .setNegativeButton( - getString(R.string.scoped_storage_postpone) - ) { _, _ -> - if (userCheckedDoNotShowAgain) { - onPostponePermanently() - } else { - onPostponeOnce() - } - } - // allow the user to dismiss the automatic dialog after it's been seen twice - if (shownAutomatically && timesStorageMigrationPostponed > 1) { - dialog.checkBoxPrompt(R.string.button_do_not_show_again) { checked -> - Timber.d("Don't show again checked: %b", checked) - userCheckedDoNotShowAgain = checked - } - } - 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) { _, _ -> - MigrationService.start(baseContext) - 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()) - - if (shouldFetchMedia(this.sharedPrefs()) && !showUnsafeSyncDialog) { - Timber.i("Syncing before storage migration") - migrateStorageAfterMediaSyncCompleted = true - sync() - } else { - Timber.i("media sync disabled: displaying dialog") - AlertDialog.Builder(this).show { - setTitle(R.string.media_sync_required_title) - setIcon(R.drawable.ic_warning) - setMessage(R.string.media_sync_unavailable_message) - setPositiveButton(getString(R.string.scoped_storage_migrate)) { _, _ -> - Timber.i("Performing unsafe storage migration") - migrate() - } - setNegativeButton(getString(R.string.scoped_storage_postpone)) { _, _ -> - setMigrationWasLastPostponedAtToNow() - } - } - } - } - - // Scoped Storage migration - private fun setMigrationWasLastPostponedAtToNow() { - migrationWasLastPostponedAt = TimeManager.time.intTime() - } - - private fun timeToShowStorageMigrationDialog(): Boolean { - return !disabledScopedStorageReminder && - // A reminder was shown more than 4 days ago - migrationWasLastPostponedAt + SECONDS_PER_DAY * 4 <= TimeManager.time.intTime() - } - override fun onImportColpkg(colpkgPath: String?) { launchCatchingTask { // as the current collection is closed before importing a new collection, make sure the @@ -2922,9 +2486,6 @@ open class DeckPicker : override fun onMediaSyncCompleted(data: SyncCompletion) { Timber.i("Media sync completed. Success: %b", data.isSuccess) - if (migrateStorageAfterMediaSyncCompleted) { - migrate() - } } /** @@ -2981,8 +2542,6 @@ data class OptionsMenuState( /** If undo is available, a string describing the action. */ val undoLabel: String?, val syncIcon: SyncIconState, - val shouldShowStartMigrationButton: Boolean, - val mediaMigrationState: MediaMigrationState, val undoAvailable: Boolean ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt index 6f5e4bbbdc52..663b3348a483 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Import.kt @@ -21,15 +21,12 @@ import android.content.Intent import androidx.core.app.TaskStackBuilder import androidx.core.content.edit import androidx.lifecycle.Lifecycle -import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.ImportDialog import com.ichi2.anki.dialogs.ImportFileSelectionFragment import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ImportOptions import com.ichi2.anki.pages.CsvImporter import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.annotations.NeedsTest import com.ichi2.utils.ImportUtils import timber.log.Timber @@ -90,13 +87,6 @@ fun DeckPicker.showImportDialog() { } fun DeckPicker.showImportDialog(options: ImportOptions) { - if (ScopedStorageService.mediaMigrationIsInProgress(this)) { - showSnackbar( - R.string.functionality_disabled_during_storage_migration, - Snackbar.LENGTH_SHORT - ) - return - } showDialogFragment(ImportFileSelectionFragment.newInstance(options)) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index 11575a463c0e..88c8bd983262 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -19,7 +19,6 @@ package com.ichi2.anki import android.app.Activity.RESULT_OK import android.content.Context import android.content.SharedPreferences -import android.content.res.Resources import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog @@ -30,13 +29,10 @@ import anki.sync.syncAuth import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.dialogs.DialogHandlerMessage 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.async.AsyncOperation import com.ichi2.libanki.createBackup import com.ichi2.libanki.fullUploadOrDownload import com.ichi2.libanki.syncCollection @@ -144,8 +140,6 @@ fun isLoggedIn() = fun millisecondsSinceLastSync(preferences: SharedPreferences) = TimeManager.time.intTimeMS() - preferences.getLong("lastSyncTime", 0) -fun canSync(context: Context) = !ScopedStorageService.mediaMigrationIsInProgress(context) - fun DeckPicker.handleNewSync( conflict: ConflictResolution?, syncMedia: Boolean @@ -405,28 +399,6 @@ suspend fun monitorMediaSync( } } -/** - * Called from [DeckPicker.onMediaSyncCompleted] -> [DeckPicker.migrate] if the app is backgrounded - */ -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) - } -} - /** * Show a simple snackbar message or notification if the activity is not in foreground * @param messageResource String resource for message diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt index c707cf147d0d..3fedf3ee2352 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardMediaPlayer.kt @@ -57,7 +57,6 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber import java.io.Closeable -import java.io.File /** * Handles the two ways an Anki card defines sound: @@ -419,15 +418,6 @@ fun AbstractFlashcardViewer.createSoundErrorListener(): SoundErrorListener { return object : SoundErrorListener { private var handledError: HashSet = hashSetOf() - private fun AbstractFlashcardViewer.handleStorageMigrationError(file: File): Boolean { - val migrationService = migrationService ?: return false - if (handledError.contains(file.absolutePath)) { - return false - } - handledError.add(file.absolutePath) - return migrationService.migrateFileImmediately(file) - } - override fun onMediaPlayerError( mp: MediaPlayer?, which: Int, @@ -454,10 +444,6 @@ fun AbstractFlashcardViewer.createSoundErrorListener(): SoundErrorListener { // There is a multitude of transient issues with the MediaPlayer. (1, -1001) for example // Retrying fixes most of these if (file.exists()) return RETRY_AUDIO - // file doesn't exist - may be due to scoped storage - if (handleStorageMigrationError(file)) { - return RETRY_AUDIO - } // just doesn't exist - process the error AbstractFlashcardViewer.mediaErrorHandler.processMissingSound(file) { filename: String? -> displayCouldNotFindMediaSnackbar(filename) } return CONTINUE_AUDIO diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt index 192e7c275631..711efa67aec8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt @@ -25,7 +25,6 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CrashReportService import com.ichi2.anki.DeckPicker import com.ichi2.anki.R -import com.ichi2.anki.canSync import com.ichi2.anki.isLoggedIn import com.ichi2.anki.millisecondsSinceLastSync import com.ichi2.anki.preferences.sharedPrefs @@ -242,10 +241,6 @@ class BackupPromptDialog private constructor(private val windowContext: Context) // If we are on a 'full' build, the user can always restore access to their collection. // But we want them to sync regularly as a backup if (isLoggedIn()) { - // If we're unable to sync, there's no point in showing the dialog - if (!canSync(windowContext)) { - return false - } // Show dialog to sync if user hasn't synced in a while val preferences = windowContext.sharedPrefs() return millisecondsSinceLastSync(preferences) >= ONE_DAY_IN_MS * 7 diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index 985e9de66bbb..5ac633399672 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -55,7 +55,6 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.INCOMP import com.ichi2.anki.dialogs.ImportFileSelectionFragment.ImportOptions import com.ichi2.anki.isLoggedIn import com.ichi2.anki.launchCatchingTask -import com.ichi2.anki.servicelayer.ScopedStorageService import com.ichi2.anki.showImportDialog import com.ichi2.libanki.Consts import com.ichi2.libanki.utils.TimeManager @@ -146,10 +145,6 @@ class DatabaseErrorDialog : AsyncDialogFragment() { // retry options.add(res.getString(R.string.backup_retry_opening)) values.add(0) - } else if (!ScopedStorageService.mediaMigrationIsInProgress(requireContext())) { - // fix integrity - options.add(res.getString(R.string.check_db)) - values.add(1) } // repair db with sqlite if (sqliteInstalled) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt index 4cb99597f2f8..482795f81d55 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DialogHandler.kt @@ -23,7 +23,6 @@ import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CollectionLoadingErrorDialog import com.ichi2.anki.DeckPicker import com.ichi2.anki.IntentHandler -import com.ichi2.anki.MigrateStorageOnSyncSuccess import com.ichi2.anki.OneWaySyncDialog import com.ichi2.anki.analytics.UsageAnalytics import com.ichi2.utils.HandlerUtils.getDefaultLooper @@ -119,7 +118,6 @@ abstract class DialogHandlerMessage protected constructor(val which: WhichDialog WhichDialogHandler.MSG_SHOW_DATABASE_ERROR_DIALOG -> DatabaseErrorDialog.ShowDatabaseErrorDialog.fromMessage(message) WhichDialogHandler.MSG_SHOW_ONE_WAY_SYNC_DIALOG -> OneWaySyncDialog.fromMessage(message) WhichDialogHandler.MSG_DO_SYNC -> IntentHandler.Companion.DoSync() - WhichDialogHandler.MSG_MIGRATE_ON_SYNC_SUCCESS -> MigrateStorageOnSyncSuccess.MigrateOnSyncSuccessHandler() WhichDialogHandler.MSG_EXPORT_READY -> ExportReadyDialog.ExportReadyDialogMessage.fromMessage(message) } } @@ -136,7 +134,6 @@ abstract class DialogHandlerMessage protected constructor(val which: WhichDialog MSG_SHOW_DATABASE_ERROR_DIALOG(6), MSG_SHOW_ONE_WAY_SYNC_DIALOG(7), MSG_DO_SYNC(8), - MSG_MIGRATE_ON_SYNC_SUCCESS(9), MSG_EXPORT_READY(10) ; companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/MigrationProgressDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/MigrationProgressDialogFragment.kt deleted file mode 100644 index a30746c8348a..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/MigrationProgressDialogFragment.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - Copyright (c) 2023 Ashish Yadav - - 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.dialogs - -import android.app.Dialog -import android.os.Bundle -import android.text.format.Formatter.formatShortFileSize -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import com.google.android.material.progressindicator.CircularProgressIndicator -import com.ichi2.anki.AnkiActivity -import com.ichi2.anki.R -import com.ichi2.anki.services.MigrationService -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch - -/** - * A dialog showing the progress of migration of the collection - * from public storage to app-private storage. - * It attaches to the migration service, and, while showing, - * constantly updates the amount of transferred data, - * and displays messages in cases of success or failure. - * Dismissible. - */ -class MigrationProgressDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val layout = requireActivity().layoutInflater.inflate(R.layout.dialog_with_progress_indicator, null) - val progressBar = layout.findViewById(R.id.progress_indicator) - val textView = layout.findViewById(R.id.text) - - progressBar.max = Int.MAX_VALUE - - fun publishProgress(progress: MigrationService.Progress.MovingMediaFiles) { - when (progress) { - is MigrationService.Progress.MovingMediaFiles.CalculatingNumberOfBytesToMove -> { - progressBar.isIndeterminate = true - textView.text = getString(R.string.migration__calculating_transfer_size) - } - - is MigrationService.Progress.MovingMediaFiles.MovingFiles -> { - val movedSizeText = formatShortFileSize(requireContext(), progress.movedBytes) - val totalSizeText = formatShortFileSize(requireContext(), progress.totalBytes) - - progressBar.isIndeterminate = false - progressBar.progress = (progress.ratio * Int.MAX_VALUE).toInt() - textView.text = getString(R.string.migration__moved_x_of_y, movedSizeText, totalSizeText) - } - } - } - - lifecycleScope.launch { - MigrationService.flowOfProgress - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .filterNotNull() - .collect { progress -> - when (progress) { - // The dialog should not be accessible when copying essential files - is MigrationService.Progress.CopyingEssentialFiles -> {} - - is MigrationService.Progress.MovingMediaFiles -> publishProgress(progress) - - // MigrationSucceededDialogFragment or MigrationFailedDialogFragment - // is going to be shown instead. - is MigrationService.Progress.Done -> dismiss() - } - } - } - - return AlertDialog.Builder(requireActivity()) - .setView(layout) - .setPositiveButton(R.string.dialog_ok) { _, _ -> dismiss() } - .setNegativeButton(R.string.scoped_storage_learn_more) { _, _ -> - (requireActivity() as AnkiActivity).openUrl(R.string.link_scoped_storage_faq) - } - .create() - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigration.kt deleted file mode 100644 index 88a29ed71a87..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ScopedStorageMigration.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2021 David Allison - * - * 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.dialogs - -import androidx.appcompat.app.AlertDialog -import androidx.core.text.parseAsHtml -import com.ichi2.anki.R -import makeLinksClickable -import org.intellij.lang.annotations.Language - -/** - * Set [message] as the dialog message, followed by "Learn More", as a link to Scoped Storage faq. - * Then show the dialog. - * @return the dialog - */ -fun AlertDialog.Builder.addScopedStorageLearnMoreLinkAndShow(@Language("HTML") message: String): AlertDialog { - @Language("HTML") - val messageWithLink = """$message -
-
${context.getString(R.string.scoped_storage_learn_more)} - """.trimIndent().parseAsHtml() - setMessage(messageWithLink) - return makeLinksClickable().apply { show() } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt index 1303c12fe8e5..9f32f041dce7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt @@ -29,7 +29,6 @@ import com.ichi2.anki.MetaDB import com.ichi2.anki.R import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.provider.CardContentProvider -import com.ichi2.anki.servicelayer.ScopedStorageService import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.compat.CompatHelper import com.ichi2.utils.show @@ -46,7 +45,6 @@ class AdvancedSettingsFragment : SettingsFragment() { // Check that input is valid before committing change in the collection path requirePreference(CollectionHelper.PREF_COLLECTION_PATH).apply { - disableIfStorageMigrationInProgress() setOnPreferenceChangeListener { _, newValue: Any? -> val newPath = newValue as String try { @@ -137,20 +135,6 @@ class AdvancedSettingsFragment : SettingsFragment() { } } - private fun Preference.disableIfStorageMigrationInProgress() { - try { - if (ScopedStorageService.mediaMigrationIsInProgress(requireContext())) { - isEnabled = false - summaryProvider = null // needs to be disabled to set .summary - summary = getString(R.string.functionality_disabled_during_storage_migration) - } - } catch (e: Exception) { - // This screen is vital and must not crash. Trust the user knows what they're doing. - // This exists only as a precaution. - Timber.w(e) - } - } - companion object { fun getSubscreenIntent(context: Context): Intent { return getSubscreenIntent(context, AdvancedSettingsFragment::class) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/MediaService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/MediaService.kt index dcfdc364f053..f7e291b74368 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/MediaService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/MediaService.kt @@ -16,23 +16,13 @@ package com.ichi2.anki.servicelayer -import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CollectionManager import com.ichi2.anki.R -import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.withProgress import com.ichi2.libanki.MediaCheckResult -suspend fun AnkiActivity.checkMedia(): MediaCheckResult? { - if (ScopedStorageService.mediaMigrationIsInProgress(this)) { - showSnackbar( - R.string.functionality_disabled_during_storage_migration, - Snackbar.LENGTH_SHORT - ) - return null - } - +suspend fun AnkiActivity.checkMedia(): MediaCheckResult { return withProgress(R.string.check_media_message) { CollectionManager.withCol { media.check() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt index f9788e0c8351..7e3ea552c0a1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ScopedStorageService.kt @@ -18,21 +18,14 @@ package com.ichi2.anki.servicelayer import android.content.Context -import android.content.SharedPreferences import android.os.Build import android.os.Environment import androidx.annotation.VisibleForTesting import com.ichi2.anki.CollectionHelper -import com.ichi2.anki.CollectionManager import com.ichi2.anki.model.Directory import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.UserDataMigrationPreferences import com.ichi2.anki.ui.windows.managespace.isInsideDirectoriesRemovedWithTheApp -import com.ichi2.compat.CompatHelper -import com.ichi2.utils.FileUtil import com.ichi2.utils.FileUtil.getParentsAndSelfRecursive import com.ichi2.utils.FileUtil.isDescendantOf import com.ichi2.utils.Permissions @@ -69,150 +62,7 @@ fun DestFolderOverride.rootFolder(): File? { } } -fun DestFolderOverride.subFolder(): File? { - return when (this) { - is DestFolderOverride.Subfolder -> folder - else -> null - } -} - object ScopedStorageService { - /** - * Preference listing the [UnscopedSourceDirectory] where a scoped storage migration is occurring from - * - * This directory should exist if the preference is set - * - * If this preference is set and non-empty, then a [migration of user data][MigrateUserData] should be occurring - * @see mediaMigrationIsInProgress - * @see UserDataMigrationPreferences - */ - const val PREF_MIGRATION_SOURCE = "migrationSourcePath" - - /** - * Preference listing the [UnscopedSourceDirectory] where a scoped storage migration is migrating to. - * - * This directory should exist if the preference is set - * - * This preference exists to decouple scoped storage migration from the `deckPath` variable: there are a number - * of reasons that `deckPath` could change, and it's a long-term risk to couple the two operations - * - * If this preference is set and non-empty, then a [migration of user data][MigrateUserData] should be occurring - * @see mediaMigrationIsInProgress - * @see UserDataMigrationPreferences - */ - const val PREF_MIGRATION_DESTINATION = "migrationDestinationPath" - - /** - * The maximum allowed number of 'AnkiDroid' folders - * - * Exists as un unreachable bound through normal activity. - */ - private const val MAX_ANKIDROID_DIRECTORIES = 100 - - /** - * The buffer space required to migrate files (in addition to the size of the files that we move) - */ - private const val SAFETY_MARGIN_BYTES = 10 * 1024 * 1024 - - /** See [ValidatedMigrationSourceAndDestination] */ - fun prepareAndValidateSourceAndDestinationFolders( - context: Context, - // used for testing - sourceOverride: File? = null, - destOverride: DestFolderOverride = DestFolderOverride.None, - checkSourceDir: Boolean = true - ): ValidatedMigrationSourceAndDestination { - // this is checked by deckpicker already, but left here for unit tests - if (mediaMigrationIsInProgress(context)) { - throw IllegalStateException("Migration is already in progress") - } - - val sourceDirectory = sourceOverride ?: getSourceDirectory() - if (checkSourceDir) { - validateSourceDirectory(context, sourceDirectory) - } - - val destinationRoot = destOverride.rootFolder() ?: getBestDefaultRootDirectory(context, sourceDirectory) - val destinationDirectory = destOverride.subFolder() ?: determineBestNewProfileDirectory(destinationRoot) - CompatHelper.compat.createDirectories(destinationDirectory) - - validateDestinationDirectory(context, destinationDirectory) - ensureSpaceAvailable(sourceDirectory, destinationDirectory) - - Timber.i("will migrate %s -> %s", sourceDirectory, destinationDirectory) - - return ValidatedMigrationSourceAndDestination( - Directory.createInstance(sourceDirectory)!!, - Directory.createInstance(destinationDirectory)!! - ) - } - - private fun getSourceDirectory(): File { - val path = CollectionManager.collectionPathInValidFolder() - return File(path).parentFile!! - } - - private fun validateSourceDirectory(context: Context, dir: File) { - if (!isLegacyStorage(dir, context)) { - throw IllegalStateException("Source directory is already under scoped storage") - } - } - - private fun validateDestinationDirectory(context: Context, destFolder: File) { - if (CompatHelper.compat.hasFiles(destFolder)) { - throw IllegalStateException("Target directory was not empty: '$destFolder'") - } - - if (isLegacyStorage(destFolder, context)) { - throw IllegalStateException("Destination folder was not under scoped storage '$destFolder'") - } - } - - private fun ensureSpaceAvailable(sourceDirectory: File, destDirectory: File) { - // Ensure we have space. - // This must be after .mkdirs(): determineBytesAvailable works on non-empty directories, - MigrateEssentialFiles.UserActionRequiredException.OutOfSpaceException.throwIfInsufficient( - available = FileUtil.determineBytesAvailable(destDirectory.absolutePath), - required = MigrateEssentialFiles.PRIORITY_FILES.sumOf { it.spaceRequired(sourceDirectory.path) } + SAFETY_MARGIN_BYTES - ) - } - - /** append a folder name to the root destination. - If the root destination was /storage/emulated/0/Android/com.ichi2.anki/files - we add a subfolder name to allow for more than one AnkiDroid data directory to be migrated. - This is useful as: - * Multiple installations of AnkiDroid go to different folders - * It will allow us to add profiles without changing directories again - */ - private fun determineBestNewProfileDirectory(rootDestination: File): File { - return (1..MAX_ANKIDROID_DIRECTORIES).asSequence() - .map { File(rootDestination, "AnkiDroid$it") } - .first { !it.exists() } // skip directories which exist - } - - /** - * 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 - */ - fun mediaMigrationIsInProgress(context: Context): Boolean = - mediaMigrationIsInProgress(context.sharedPrefs()) - - /** - * 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. - * - * @see mediaMigrationIsInProgress[Context] - * @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 - */ - fun mediaMigrationIsInProgress(preferences: SharedPreferences) = - UserDataMigrationPreferences.createInstance(preferences).migrationInProgress - /** * Given a path, find in which app directory it is contained if any, otherwise return an arbitrary app directory * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectory.kt deleted file mode 100644 index c9e0c6ecc9ec..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectory.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.DirectoryNotEmptyException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.operationCompleted -import com.ichi2.compat.CompatHelper -import timber.log.Timber -import java.io.FileNotFoundException -import java.io.IOException - -data class DeleteEmptyDirectory(val directory: Directory) : Operation() { - override fun execute(context: MigrationContext): List { - val directoryContainsFiles = - try { - directory.hasFiles() - } catch (ex: FileNotFoundException) { - return noFile(ex) - } - if (directoryContainsFiles) { - context.reportError(this, DirectoryNotEmptyException(directory)) - return operationCompleted() - } - - try { - CompatHelper.compat.deleteFile(directory.directory) - Timber.d("deleted $directory") - } catch (ex: FileNotFoundException) { - Timber.d("$directory already deleted") - } - - return operationCompleted() - } - - private fun noFile(ex: IOException): List { - if (!directory.directory.exists()) { - // If the directory is already deleted, the goal of the operation is reached, - // hence we do not have to throw. - // However, we could have obtained this exception during the directory deletion attempt, because the directory may have been deleted between the creation of [directory] and the execution of the operation. - Timber.d("$directory already deleted") - return operationCompleted() - } - throw ex - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFiles.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFiles.kt deleted file mode 100644 index 07d747313a2b..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFiles.kt +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import android.annotation.SuppressLint -import android.content.Context -import androidx.annotation.VisibleForTesting -import androidx.core.content.edit -import com.ichi2.anki.CollectionHelper -import com.ichi2.anki.CrashReportService -import com.ichi2.anki.model.Directory -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_DESTINATION -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_SOURCE -import com.ichi2.anki.servicelayer.ValidatedMigrationSourceAndDestination -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles.Companion.PRIORITY_FILES -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles.UserActionRequiredException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.NumberOfBytes -import com.ichi2.annotations.NeedsTest -import com.ichi2.compat.CompatHelper -import timber.log.Timber -import java.io.File - -/** - * Algorithm class which represents copying the essential files (collection and media SQL-related - * files, and .nomedia/collection logs) to a location under scoped storage: [PRIORITY_FILES] - * This exists as a class to allow overriding operations for fault injection testing - * - * Our main concerns here are ensuring that there are no errors, and the graceful handling of issues. - * One primary concern is whether the failure case leaves files in the destination (scoped) directory. - * - * Many of our users are low on space, and leaving "difficult to delete" files in the app private - * directory is user-hostile. - * - * See: [migrateFiles] - * - * Preconditions (verified inside [MigrateEssentialFiles] and [migrateFiles] - exceptions thrown if not met): - * * Collection is not corrupt and can be opened - * * Collection basic check passes [UserActionRequiredException.CheckDatabaseException] - * * Collection can be closed and locked - * * User has space to move files [UserActionRequiredException.OutOfSpaceException] (the size of essential files + [ScopedStorageService.SAFETY_MARGIN_BYTES] - * * A migration is not currently taking place - */ -open class MigrateEssentialFiles -@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) -internal constructor( - private val context: Context, - private val folders: ValidatedMigrationSourceAndDestination -) { - private var oldPrefValues: Map? = null - - /** - * Copies (not moves) the [essential files][PRIORITY_FILES] to [destinationDirectory] - * - * Then opens a collection at the new location, and updates [CollectionHelper.PREF_COLLECTION_PATH] there. - * - * After, call updateCollectionPath(), and then: - * - * [PREF_MIGRATION_SOURCE] contains the unscopedSourceDirectory with the remaining items to move ([sourceDirectory]) - * [PREF_MIGRATION_DESTINATION] contains the scopedDestinationDirectory with the copied collection.anki2/media ([destinationDirectory]) - * [CollectionHelper.PREF_COLLECTION_PATH] now points to the new location of the collection in private storage - * [ScopedStorageService.UserDataMigrationPreferences.migrationInProgress] returns `true` - * - * @throws IllegalStateException Migration in progress - * @throws IllegalStateException [destinationDirectory] is not empty - * @throws UserActionRequiredException.MissingEssentialFileException if an essential file does not exist - * @throws UserActionRequiredException.CheckDatabaseException if 'Check Database' needs to be done first - * @throws IllegalStateException If a lock cannot be acquired on the collection - */ - fun migrateFiles() { - val (unscopedSourceDirectory, scopedDestinationDirectory) = folders - - try { - // Throws MissingEssentialFileException if the files we need to copy don't exist - throwIfEssentialFilesDoNotExistInDirectory(unscopedSourceDirectory) - - // Copy essential files to new location. Guaranteed to be empty - for (file in iterateEssentialFiles(unscopedSourceDirectory)) { - copyTopLevelFile(file, scopedDestinationDirectory) - } - - // Check that the files in the target location are identical. - throwIfEssentialFilesAreMutated(unscopedSourceDirectory, scopedDestinationDirectory) - } catch (e: Exception) { - try { - // MigrateEssentialFiles performs a COPY. Delete the data so we don't take up space. - folders.scopedDestinationDirectory.directory.deleteRecursively() - } catch (_: Exception) { - } - throw e - } - } - - @SuppressLint("NewApi") // contentEquals is API 26, we're guaranteed to be above this if performing a migration - @NeedsTest("untested, needs documentation") - private fun throwIfEssentialFilesAreMutated(sourceDirectory: Directory, destinationDirectory: Directory) { - // TODO: For Arthur to improve - for ((source, destination) in iterateEssentialFiles(sourceDirectory).zip(iterateEssentialFiles(destinationDirectory))) { - try { - throwIfContentUnequal(source, destination) - } catch (e: Exception) { - // 13807: .nomedia was reported as mutated, but we could not determine the cause - if (source.name == ".nomedia") { - CrashReportService.sendExceptionReport(e, ".nomedia was mutated") - continue - } - // any other file should be reported as an error and fail the migration - throw e - } - } - } - - /** - * Ensures that all files in [PRIORITY_FILES] and that are in the source exists in the destination. - * @throws UserActionRequiredException.MissingEssentialFileException if a file does not exist - */ - private fun throwIfEssentialFilesDoNotExistInDirectory(sourcePath: Directory) { - for (file in iterateEssentialFiles(sourcePath)) { - if (!file.exists()) { - throw UserActionRequiredException.MissingEssentialFileException(file) - } - } - } - - /** - * Copies [file] to [destinationDirectory], retaining the same filename - */ - private fun copyTopLevelFile(file: File, destinationDirectory: Directory) { - val destinationPath = File(destinationDirectory.directory, file.name).path - Timber.i("Migrating essential file: '${file.name}'") - Timber.d("Copying '$file' to '$destinationPath'") - CompatHelper.compat.copyFile(file.path, destinationPath) - } - - /** - * Updates preferences after a successful "essential files" migration. - * The collection is opened with this new preference. - * Any error in opening the collection are thrown, and the preference change is reverted. - */ - fun updateCollectionPath() { - val prefs = context.sharedPrefs() - - // keep the old values in case we need to restore them - oldPrefValues = listOf( - PREF_MIGRATION_SOURCE, - PREF_MIGRATION_DESTINATION, - CollectionHelper.PREF_COLLECTION_PATH - ) - .associateWith { prefs.getString(it, null) } - - prefs.edit { - // specify that a migration is in progress - putString(PREF_MIGRATION_SOURCE, folders.unscopedSourceDirectory.directory.absolutePath) - putString( - PREF_MIGRATION_DESTINATION, - folders.scopedDestinationDirectory.directory.absolutePath - ) - putString( - CollectionHelper.PREF_COLLECTION_PATH, - folders.scopedDestinationDirectory.directory.absolutePath - ) - } - } - - /** Can be called if collection fails to open after migration completes. */ - fun restoreOldCollectionPath() { - val prefs = context.sharedPrefs() - prefs.edit { - oldPrefValues?.forEach { - putString(it.key, it.value) - } - } - } - - /** - * A file, or group of files which must be migrated synchronously while the collection is closed - * This is either because the file is vital for when a collection is reopened in a new location - * Or because it is immediately created and may cause a conflict - */ - abstract class PriorityFile { - /** - * A list of essential files. - * The returned files are assumed to exists at the time when [getEssentialFiles] is called. - * It is the caller responsibility to ensure that those files are not created nor deleted - * between the time when `getFiles` is called and the time when the result is used. - */ - abstract fun getEssentialFiles(sourceDirectory: String): List - - fun spaceRequired(sourceDirectory: String): NumberOfBytes { - return getEssentialFiles(sourceDirectory).sumOf { it.length() } - } - - /** The list of filenames we would move (if they exist) */ - abstract val potentialFileNames: List - } - - /** - * A SQLite database, which contains both a database and a journal - * @see PriorityFile - */ - internal class SQLiteDBFiles(val fileName: String) : PriorityFile() { - override fun getEssentialFiles(sourceDirectory: String): List { - val list = mutableListOf(File(sourceDirectory, fileName)) - val journal = File(sourceDirectory, journalName) - if (journal.exists()) { - list.add(journal) - } - return list - } - - // guaranteed to be + "-journal": https://www.sqlite.org/tempfiles.html - private val journalName = "$fileName-journal" - - override val potentialFileNames get() = listOf(fileName, journalName) - } - - /** - * The file at [fileName] if it exists, no file otherwise. - * - * The existence test is delayed until the list of files is needed. - * This means that the list of files may vary with time depending on file creation or deletion - */ - class OptionalFile(val fileName: String) : PriorityFile() { - override fun getEssentialFiles(sourceDirectory: String): List { - val file = File(sourceDirectory, fileName) - return if (!file.exists()) { - emptyList() - } else { - listOf(file) - } - } - - override val potentialFileNames get() = listOf(fileName) - } - - /** - * An exception which requires user action to resolve - */ - abstract class UserActionRequiredException(message: String) : RuntimeException(message) { - constructor() : this("") - - /** - * The user must perform 'Check Database' - */ - class CheckDatabaseException : UserActionRequiredException() - - /** - * The user must determine why essential files don't exist - */ - class MissingEssentialFileException(val file: File) : UserActionRequiredException("missing essential file: ${file.name}") - - /** - * A user requires more free space on their device before starting a scoped storage migration - */ - class OutOfSpaceException(val available: Long, val required: Long) : UserActionRequiredException("More free space is required. Available: $available. Required: $required") { - companion object { - /** - * Throws if [required] > [available] - * @throws OutOfSpaceException - */ - fun throwIfInsufficient(available: Long, required: Long) { - if (required > available) { - throw OutOfSpaceException(available, required) - } - Timber.d("Appropriate space for operation. Needed %d bytes. Had %d", required, available) - } - } - } - } - - companion object { - /** - * Lists the files to be moved by [MigrateEssentialFiles] - * Priority files are files to be moved if they exist. - * Essential files are files which MUST be moved - */ - val PRIORITY_FILES = listOf( - SQLiteDBFiles("collection.anki2"), // Anki collection - // this is created on demand in the new backend - OptionalFile("collection.media.db"), - OptionalFile("collection.anki2-wal"), - OptionalFile("collection.media.db-wal"), - OptionalFile(".nomedia"), - OptionalFile("collection.log") // written immediately and conflicts - ) - - /** - * A collection of [File] objects to be moved by [MigrateEssentialFiles] - */ - fun iterateEssentialFiles(sourcePath: Directory) = - PRIORITY_FILES.flatMap { it.getEssentialFiles(sourcePath.directory.canonicalPath) } - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFile.kt deleted file mode 100644 index b48a56f8e1a2..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFile.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import androidx.annotation.CheckResult -import androidx.annotation.VisibleForTesting -import com.ichi2.anki.model.Directory -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.model.RelativeFilePath -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileConflictResolutionFailedException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileDirectoryConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.NumberOfBytes -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.operationCompleted -import com.ichi2.compat.CompatHelper -import java.io.File - -/** - * Moves a file from [sourceFile] to [proposedDestinationFile]. - * - * Ensures that the folder underneath [proposedDestinationFile] exists, and renames the destination file. - * if the file at [proposedDestinationFile] exists and has a distinct content, it will increment the filename, attempting to find a filename which is not in use. - * if the file at [proposedDestinationFile] exists and has the same content, [sourceFile] is deleted and move is assumed to be successful. - * - * This does not handle other exceptions, such as a file existing as a directory in the relative path. - * - * Throws [FileConflictResolutionFailedException] if [proposedDestinationFile] exists, as do all - * the other candidate destination files for conflict resolution. - * This is unlikely to occur, and is expected to represent an application logic error. - * - * @see MoveFile for the definition of move - */ -/* In AnkiDroid planned use, proposedDestinationFile is assumed to be topLevel/conflict/relativePathOfSourceFile */ -class MoveConflictedFile private constructor( - val sourceFile: DiskFile, - val proposedDestinationFile: File -) : Operation() { - - override fun execute(context: MigrationContext): List { - // create the "conflict" folder if it didn't exist, and the relative path to the file - // example: "AnkiDroid/conflict/collection.media/subfolder" - createDirectory(proposedDestinationFile.parentFile!!) - - // wrap the context so we can handle internal file conflict exceptions, and set the correct - // 'operation' if an error occurs. - val wrappedContext = ContextHandlingFileConflictException(context, this) - // loop from "filename.ext", then "filename (1).ext" to "filename (${MAX_RENAMES - 1}).ext" to ensure we transfer the file - for (potentialDestinationFile in queryCandidateFilenames(proposedDestinationFile)) { - return try { - moveFile(potentialDestinationFile, wrappedContext) - if (wrappedContext.handledFileConflictSinceLastReset) { - wrappedContext.reset() - continue // we had a conflict, try the next name - } - // the operation completed, with or without an error report. Don't try again - operationCompleted() - } catch (ex: Exception) { - // We had an exception not handled by Operation, - // or one that ContextHandlingFileConflictException decided to re-throw. - // Don't try a different name - wrappedContext.reportError(this, ex) - operationCompleted() - } - } - - throw FileConflictResolutionFailedException(sourceFile, proposedDestinationFile) - } - - @VisibleForTesting - internal fun moveFile(potentialDestinationFile: File, wrappedContext: ContextHandlingFileConflictException) { - MoveFile(sourceFile, potentialDestinationFile).execute(wrappedContext) - } - - private fun createDirectory(folder: File) = CompatHelper.compat.createDirectories(folder) - - companion object { - const val CONFLICT_DIRECTORY = "conflict" - - /** - * @param sourceFile The file to move from - * @param destinationTopLevel The top level directory to move to (non-relative path). "/storage/emulated/0/AnkiDroid/" - * @param sourceRelativePath The relative path of the file. Does not start with /conflict/. - * Is a suffix of [sourceFile]'s path: "/collection.media/image.jpg" - */ - fun createInstance( - sourceFile: DiskFile, - destinationTopLevel: Directory, - sourceRelativePath: RelativeFilePath - ): MoveConflictedFile { - // we add /conflict/ to the path inside this method. If this already occurred, something was wrong - if (sourceRelativePath.path.firstOrNull() == CONFLICT_DIRECTORY) { - throw IllegalStateException("can't move from a root path of 'conflict': $sourceRelativePath") - } - - val conflictedPath = sourceRelativePath.unsafePrependDirectory(CONFLICT_DIRECTORY) - - val destinationFile = conflictedPath.toFile(baseDir = destinationTopLevel) - - return MoveConflictedFile(sourceFile, destinationFile) - } - - @VisibleForTesting - @CheckResult - internal fun queryCandidateFilenames(templateFile: File) = sequence { - yield(templateFile) - - // examples from a file named: "helloWorld.tmp". the dot between name and extension isn't included - val filename = templateFile.nameWithoutExtension // 'helloWorld' - val extension = templateFile.extension // 'tmp' - for (i in 1 until MAX_DESTINATION_NAMES) { // 1..4 - val newFileName = "$filename ($i).$extension" // 'helloWorld (1).tmp' - yield(File(templateFile.parent, newFileName)) - } - } - - /** - * The max number of attempts to rename a file - * - * "filename.ext", then "filename (1).ext" ... "filename (4).ext" - * where 4 = MAX_DESTINATION_NAMES - 1 - */ - const val MAX_DESTINATION_NAMES = 5 - } - - /** - * Wrapper around [MigrateUserData.MigrationContext]. - * Ignores [FileConflictException], behaves as [MigrateUserData.MigrationContext] otherwise. - * Reports errors using the provided [operation] - */ - class ContextHandlingFileConflictException( - private val wrappedContext: MigrationContext, - private val operation: Operation - ) : MigrationContext() { - - /** Whether at least one [FileConflictException] was handled and ignored */ - var handledFileConflictSinceLastReset = false - - /** Resets the status of [handledFileConflictSinceLastReset] */ - fun reset() { - handledFileConflictSinceLastReset = false - } - - override fun reportError(throwingOperation: Operation, ex: Exception) { - if (ex is FileConflictException || ex is FileDirectoryConflictException) { - handledFileConflictSinceLastReset = true - return - } - - // report error using the operation passed into the ContextHandlingFileConflictException - // (MoveConflictedFile) - wrappedContext.reportError(operation, ex) - } - - override fun reportProgress(transferred: NumberOfBytes) = wrappedContext.reportProgress(transferred) - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContent.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContent.kt deleted file mode 100644 index ae8f58b17d71..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContent.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022 Arthur Milchior - * - * 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.servicelayer.scopedstorage - -import androidx.annotation.VisibleForTesting -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.operationCompleted -import com.ichi2.compat.CompatHelper -import com.ichi2.compat.FileStream -import java.io.File -import java.io.IOException - -/** - * This operation moves content of source to destination. This Operation is called one time for each file in the directory, and one last time. Unless an exception occurs. Use the [createInstance] to instantiate the object. It will convert the given Directory source to a FileStream. This conversion is a potentially long-running operation. - * @param [source]: an iterator over File content, [source] will be closed in [execute] once the source is empty or if accessing its content raise an exception. - * @param [destination]: a directory to copy the source content. - * This is different than [MoveFile], [MoveDirectory] and [MoveFileOrDirectory] where destination is the new path of the copied element. - * Because in this case, there is not a single destination path. - */ -class MoveDirectoryContent private constructor(val source: FileStream, val destination: File) : Operation() { - companion object { - /** - * Return a [MoveDirectoryContent], moving the content of [source] to [destination]. - * Its running time is potentially proportional to the number of files in [source]. - * @param [source] a directory that should be moved - * @param [destination] a directory, assumed to exists, to which [source] content will be transferred. - * @throws [NoSuchFileException] if the file do not exists (starting at API 26) - * @throws [java.nio.file.NotDirectoryException] if the file exists and is not a directory (starting at API 26) - * @throws [FileNotFoundException] if the file do not exists (up to API 25) - * @throws [IOException] if files can not be listed. On non existing or non-directory file up to API 25. This also occurred on an existing directory because of permission issue - * that we could not reproduce. See https://github.com/ankidroid/Anki-Android/issues/10358 - * @throws [SecurityException] – If a security manager exists and its SecurityManager.checkRead(String) method denies read access to the directory - */ - fun createInstance(source: Directory, destination: File): MoveDirectoryContent = - MoveDirectoryContent(CompatHelper.compat.contentOfDirectory(source.directory), destination) - } - - override fun execute(context: MigrationContext): List { - try { - val hasNext = source.hasNext() // can throw an IOException - if (!hasNext) { - source.close() - return operationCompleted() - } - } catch (e: IOException) { - source.close() - throw e - } - val nextFile = source.next() // Guaranteed not to throw since hasNext returned true. - val moveNextFile = toMoveOperation(nextFile) - val moveRemainingDirectoryContent = this - return listOf(moveNextFile, moveRemainingDirectoryContent) - } - - /** - * @returns An operation to move file or directory [sourceFile] to [destination] - */ - @VisibleForTesting - internal fun toMoveOperation(sourceFile: File): Operation { - return MoveFileOrDirectory(sourceFile, File(destination, sourceFile.name)) - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFile.kt deleted file mode 100644 index 94d1b473e6cd..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFile.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import androidx.annotation.VisibleForTesting -import com.ichi2.anki.model.Directory -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.DirectoryValidator -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.EquivalentFileException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileDirectoryConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MissingDirectoryException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.operationCompleted -import com.ichi2.compat.CompatHelper -import com.ichi2.utils.FileUtil -import timber.log.Timber -import java.io.File - -/** - * [Operation] which safely moves a file at path `sourceFile` to path `destinationFile`. - * - * Note: thrown exceptions are passed to the context via [MigrationContext.reportError] - * - * @throws FileConflictException If the source and destination exist and are different - * @throws EquivalentFileException sourceFile == destinationFile - * @throws MissingDirectoryException if source or destination's directory is missing - * @throws IllegalStateException File copy failed - * @throws java.io.IOException Unknown file operation failed - */ -internal data class MoveFile(val sourceFile: DiskFile, val destinationFile: File) : Operation() { - override fun execute(context: MigrationContext): List { - if (!sourceFile.file.exists()) { - sourceDoesNotExists(context) - } else if (destinationFile.exists()) { - destinationExists(context) - } else { - normalMove(context) - } - return operationCompleted() - } - - /** - * Deals with non existing source. - */ - private fun sourceDoesNotExists(context: MigrationContext) { - ensureParentDirectoriesExist() // check to confirm nothing went wrong (SD card removal) - Timber.d("no-op - source deleted: '$sourceFile'") - // since the file was deleted, we can't know the size. Report 0 file size - context.reportProgress(0) - } - - /** - * Assumes source and destination exists. Deals with this unexpected case in the following way: - * * if both files are the same, according to canonical path, throws a EquivalentFileException - * * if destination is a directory, report a a FileDirectoryConflictException - * * if destination is a copy of the source, delete the source, as it's copied, - * * if destination is a strict prefix of the source, delete the destination so that it can be copied entirely - * * if destination seems to be distinct from source, - * - * @throws EquivalentFileException sourceFile == destinationFile - * @throws MissingDirectoryException if source or destination's directory is missing - */ - private fun destinationExists(context: MigrationContext) { - if (sourceFile.file.canonicalPath == destinationFile.canonicalPath) { - // Deletion is destructive if both files are the same - throw EquivalentFileException(sourceFile.file, destinationFile) - } - - if (destinationFile.isDirectory) { - context.reportError( - this, - FileDirectoryConflictException( - sourceFile, - Directory.createInstanceUnsafe(destinationFile) - ) - ) - return - } - - when (FileUtil.isPrefix(destinationFile, sourceFile.file)) { - FileUtil.FilePrefix.EQUAL -> { - // Both files exist and are identical. Delete source + report size - context.execSafe(this) { - val fileSize = sourceFile.file.length() - deleteFile(sourceFile.file) - context.reportProgress(fileSize) - } - } - FileUtil.FilePrefix.STRICT_PREFIX -> { - val deletionSucceeded = destinationFile.delete() - Timber.w("(conflict) Deleted partial file in destination. Deletion result: %b", deletionSucceeded) - if (!deletionSucceeded) { - conflictingFileInDestination(context) - } else { - normalMove(context) - } - } - FileUtil.FilePrefix.STRICT_SUFFIX, - FileUtil.FilePrefix.NOT_PREFIX -> { - conflictingFileInDestination(context) - } - } - } - - // destination exists, and does NOT match content: throw an exception - // this is intended to be handled by moving the file to a "conflict" directory - private fun conflictingFileInDestination(context: MigrationContext) { - // if the source file doesn't exist, but the destination does, we assume that the move - // took place outside this "MoveFile" instance - possibly preempted by the - // user requesting the file - Timber.d("file already moved to $destinationFile") - if (sourceFile.file.exists()) { - context.reportError( - this, - FileConflictException( - sourceFile, - DiskFile.createInstance(destinationFile)!! - ) - ) - } - } - - /** - * Deals with copying existing source to a non existing destination - */ - private fun normalMove(context: MigrationContext) { - // attempt a quick rename - if (context.attemptRename) { - if (sourceFile.renameTo(destinationFile)) { - Timber.d("fast move successful from '$sourceFile' to '$destinationFile'") - context.reportProgress(destinationFile.length()) - return - } else { - context.attemptRename = false - } - } - - // copy the file, and delete the source. - // If the program crashes between "copy" and "delete", then the next time the operation is - // run, the duplicate source file will be deleted - copyFile( - source = sourceFile.file, - destination = destinationFile - ) - - if (!destinationFile.exists()) { - context.reportError( - this, - IllegalStateException("Failed to copy file to $destinationFile") - ) - return - } - - // We've moved the file, so can delete the source file - deleteFile(sourceFile.file) - Timber.d("move successful from '$sourceFile' to '$destinationFile'") - context.reportProgress(destinationFile.length()) - } - - /** - * @throws MissingDirectoryException if source or destination's directory is missing - */ - private fun ensureParentDirectoriesExist() { - val exceptionBuilder = DirectoryValidator() - exceptionBuilder.tryCreate("source - parent dir", sourceFile.file.parentFile!!) - exceptionBuilder.tryCreate("destination - parent dir", destinationFile.parentFile!!) - exceptionBuilder.throwIfNecessary() - } - - @VisibleForTesting - internal fun copyFile(source: File, destination: File) { - Timber.d("copying: $source to $destination") - CompatHelper.compat.copyFile(source.canonicalPath, destination.canonicalPath) - } - - @VisibleForTesting - internal fun deleteFile(file: File) { - Timber.d("deleting '$file'") - CompatHelper.compat.deleteFile(file) - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectory.kt deleted file mode 100644 index e9f2313f53e0..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectory.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.model.Directory -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MoveDirectory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.operationCompleted -import org.apache.commons.io.FileUtils.isSymlink -import timber.log.Timber -import java.io.File - -/** - * [MoveFileOrDirectory] checks whether a `File` object is actually a `File`, a `Directory` or something else. - * The last case should not occur in AnkiDroid. - * It then delegates to `MoveFile` or `MoveDirectory` the actual move. - * - * Checking whether a `File` object is a file/directory requires an I/O operation, which means that - * it should not be done in a loop, as this would block preemption. - * - * @param sourceFile is the file or directory to move - * @param destination the new path of the file or directory (not the directory containing it) - * - * @see [MoveDirectory] - * @see [MoveFile] - */ -data class MoveFileOrDirectory( - /** Source, known to exist */ - val sourceFile: File, - /** Destination: known to exist */ - val destination: File -) : Operation() { - - override fun execute(context: MigrationContext): List { - when { - sourceFile.isFile -> { - val fileToCreate = DiskFile.createInstanceUnsafe(sourceFile) - return listOf(MoveFile(fileToCreate, destination)) - } - sourceFile.isDirectory -> { - if (isSymlink(sourceFile)) { - Timber.d("skipping symlink: $sourceFile") - return operationCompleted() - } - val directory = Directory.createInstanceUnsafe(sourceFile) - return listOf(MoveDirectory(directory, destination)) - } - else -> { - if (!sourceFile.exists()) { - // probably already migrated - Timber.d("File no longer exists: $sourceFile") - } else { - context.reportError(this, IllegalStateException("File was neither file nor directory '${sourceFile.canonicalPath}'")) - } - } - } - return operationCompleted() - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserData.kt deleted file mode 100644 index d9e34a48ce33..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserData.kt +++ /dev/null @@ -1,657 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage.migrateuserdata - -import android.content.SharedPreferences -import androidx.annotation.VisibleForTesting -import com.ichi2.anki.R -import com.ichi2.anki.model.Directory -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.model.RelativeFilePath -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles -import com.ichi2.anki.servicelayer.scopedstorage.MoveConflictedFile -import com.ichi2.anki.servicelayer.scopedstorage.MoveFile -import com.ichi2.anki.servicelayer.scopedstorage.MoveFileOrDirectory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.SingleRetryDecorator -import com.ichi2.anki.utils.TranslatableAggregateException -import com.ichi2.anki.utils.getUserFriendlyErrorText -import com.ichi2.compat.CompatHelper -import timber.log.Timber -import java.io.File -import java.util.concurrent.CountDownLatch - -typealias NumberOfBytes = Long - -/** - * Function that is executed when one file is migrated, with the number of bytes moved. - * Called with 0 when the file is already present in destination (i.e. successful move with no byte copied) - * Not called for directories. - */ -typealias MigrationProgressListener = (NumberOfBytes) -> Unit - -/** - * Migrating user data (images, backups etc..) to scoped storage - * This needs to be performed in the background to allow users to use AnkiDroid. - * - * A file is not user data if it is moved by [MigrateEssentialFiles]: - * * (collection and media SQL-related files, and .nomedia/collection logs) - * - * If this were performed in the foreground, users would be encouraged to uninstall the app - * which means the app permanently loses access to the AnkiDroid directory. - * - * This also handles preemption, allowing media files to skip the queue - * (if they're required for review) - */ -open class MigrateUserData protected constructor(val source: Directory, val destination: Directory) { - companion object { - /** - * Creates an instance of [MigrateUserData] if valid, returns null if a migration is not in progress, or throws if data is invalid - * @return null if a migration is not taking place, otherwise a valid [MigrateUserData] instance - * - * @throws IllegalStateException if migration is not taking place, - * or if preferences are in an invalid state - * (should be logically impossible - currently unrecoverable) - * @throws MissingDirectoryException If either or both the source/destination do not exist - */ - fun createInstance(preferences: SharedPreferences): MigrateUserData { - val migrationPreferences = UserDataMigrationPreferences.createInstance(preferences) - if (!migrationPreferences.migrationInProgress) { - throw IllegalStateException("Migration is not in progress") - } - - return createInstance(migrationPreferences) - } - - /** - * Creates an instance of a [MigrateUserData] - * - * Assumes the paths come from preferences - * - * @throws MissingDirectoryException If either directory defined in [preferences] does not exist - */ - private fun createInstance(preferences: UserDataMigrationPreferences): MigrateUserData { - val directoryValidator = DirectoryValidator() - - val sourceDirectory = directoryValidator.tryCreate("source", preferences.sourceFile) - val destinationDirectory = directoryValidator.tryCreate("destination", preferences.destinationFile) - - if (sourceDirectory == null || destinationDirectory == null) { - throw directoryValidator.exceptionOnNullResult - } - - return MigrateUserData( - source = sourceDirectory, - destination = destinationDirectory - ) - } - } - - /** - * Exceptions that are expected to occur during migration, and that we can deal with. - */ - open class MigrationException(message: String) : RuntimeException(message) - - /** - * If a file exists in [destination] with different content than [source] - * - * If a file named `filename` exists in [destination] and in [source] with different content, move `source/filename` to `source/conflict/filename`. - */ - class FileConflictException(val source: DiskFile, val destination: DiskFile) : MigrationException("File $source can not be copied to $destination, destination exists and differs.") - - /** - * If [destination] is a directory. In this case, move `source/filename` to `source/conflict/filename`. - */ - class FileDirectoryConflictException(val source: DiskFile, val destination: Directory) : MigrationException("File $source can not be copied to $destination, as destination is a directory.") - - /** - * If one or more required directories were missing - */ - class MissingDirectoryException(val directories: List) : MigrationException("Directories $directories are missing.") { - init { - if (directories.isEmpty()) { - throw IllegalArgumentException("directories should not be empty") - } - } - - /** - * A file which should exist, but did not - * @param source The variable name/identifier of the file - * @param file A [File] reference to the missing file - */ - data class MissingFile(val source: String, val file: File) - } - - /** - * If during a file move, two files refer to the same path - * This implies that the file move should be cancelled - */ - class EquivalentFileException(val source: File, val destination: File) : MigrationException("Source and destination path are the same") - - /** - * If a directory could not be deleted as it still contained files. - */ - class DirectoryNotEmptyException(val directory: Directory) : MigrationException("directory was not empty: $directory") - - /** - * If the number of retries was exceeded when resolving a file conflict via moving it to the - * /conflict/ folder. - */ - class FileConflictResolutionFailedException(val sourceFile: DiskFile, attemptedDestination: File) : MigrationException("Failed to move $sourceFile to $attemptedDestination") - - /** - * Context for an [Operation], allowing a change of execution behavior and - * allowing progress and exception reporting logic when executing - * a large mutable queue of tasks - */ - abstract class MigrationContext { - abstract fun reportError(throwingOperation: Operation, ex: Exception) - - /** - * Called on each successful file migrated - * @param transferred The number of bytes of the transferred file - */ - abstract fun reportProgress(transferred: NumberOfBytes) - - /** - * Whether [File#renameTo] should be attempted for files. - * - * This is not attempted for directories: very unlikely to work as we're copying across - * mount points. - * Android has internal logic which recovers renames from /storage/emulated - * But this hasn't worked for me for folders - */ - var attemptRename: Boolean = true - - /** - * Performs an operation, reports errors and continues on failure - */ - open fun execSafe(operation: Operation, op: (Operation) -> Unit) { - try { - op(operation) - } catch (e: Exception) { - Timber.w(e, "Failed while executing %s", operation) - reportError(operation, e) - } - } - } - - /** - * Used to validate a number of [Directory] instances and produces a [MissingDirectoryException] with all - * missing directories. - * - * Usage: - * ```kotlin - * val directoryValidator = DirectoryValidator() - * - * val sourceDirectory = directoryValidator.tryCreate(source) - * val destinationDirectory = directoryValidator.tryCreate(destination) - * - * if (sourceDirectory == null || destinationDirectory == null) { - * throw directoryValidator.exceptionOnNullResult - * } - * - * // `sourceDirectory` and `destinationDirectory` may now be used - * ``` - * - * Alternately (just validation without using values): - * ```kotlin - * val directoryValidator = DirectoryValidator() - * - * directoryValidator.tryCreate(source) - * directoryValidator.tryCreate(destination) - * - * exceptionBuilder.throwIfNecessary() - * ``` - */ - class DirectoryValidator { - /** Only valid if [tryCreate] returned null */ - val exceptionOnNullResult: MissingDirectoryException - get() = MissingDirectoryException(failedFiles) - - /** - * A list of files which failed to be created - * Only valid if [tryCreate] returned null - */ - private val failedFiles = mutableListOf() - - /** - * Tries to create a [Directory] object. - * - * If this returns null, [exceptionOnNullResult] should be thrown by the caller. - * Example usages are provided in the [DirectoryValidator] documentation. - * - * @param [context] The "name" of the variable to test - * @param [file] A file which may not point to a valid directory - * @return A [Directory], or null if [file] did not point to an existing directory - */ - fun tryCreate(context: String, file: File): Directory? { - val ret = Directory.createInstance(file) - if (ret == null) { - failedFiles.add(MissingDirectoryException.MissingFile(context, file)) - } - return ret - } - - /** - * If any directories were not created, throw a [MissingDirectoryException] listing the files - * @throws MissingDirectoryException if any input files were invalid - */ - fun throwIfNecessary() { - if (failedFiles.any()) { - throw MissingDirectoryException(failedFiles) - } - } - } - - /** - * Represents an arbitrary operation that we may want to execute. - * - * This operation should be doable as a sequence of atomic steps. In a single-threaded context, - * it allows the thread and its resources to be preempted with minimal delay. - * - * For example, if an image is requested by the reviewer, I/O is guaranteed to rapidly get access to the image. - */ - abstract class Operation { - /** - * Starts to execute the current operation. Only do as little non-trivial work as possible to start the operation, such as listing a directory content or moving a single file. - * Returns the list of operations remaining to end this operation. - * - * E.g. for "move a directory", this method would simply compute the directory content and then returns the following list of operations: - * * creating the destination directory - * * moving each file and subdirectory individually - * * deleting the original directory. - */ - abstract fun execute(context: MigrationContext): List - - /** A list of operations to perform if the operation should be retried */ - open val retryOperations get() = emptyList() - } - - class AwaitableOperation(private val operation: Operation) : Operation() { - private val completion = CountDownLatch(1) - - override fun execute(context: MigrationContext): List { - try { - return operation.execute(context) - } finally { - this.completion.countDown() - } - } - fun await() = completion.await() - } - - /** - * A decorator for [Operation] which executes [standardOperation]. - * When retried, executes [retryOperation]. - * Ignores [retryOperations] defined in [standardOperation] - */ - class SingleRetryDecorator( - internal val standardOperation: Operation, - private val retryOperation: Operation - ) : Operation() { - override fun execute(context: MigrationContext) = standardOperation.execute(context) - override val retryOperations get() = listOf(retryOperation) - } - - /** - * An Executor allows execution of a list of tasks, provides progress reporting via a [MigrationContext] - * and allows tasks to be preempted (for example: copying an image used in the Reviewer - * should take priority over a background migration - * of a random file) - */ - open class Executor(private val operations: ArrayDeque) { - /** Whether [terminate] was called. Once this is called, a new instance should be used */ - var terminated: Boolean = false - private set - - /** - * A list of operations to be executed before [operations] - * [operations] should only be executed if this list is clear - */ - private val preempted: ArrayDeque = ArrayDeque() - - /** - * Executes operations from both [operations] and [preempted] - * Any operation is [preempted] takes priority - * Completes when: - * * [MigrationContext] determines too many failures have occurred or a critical failure has occurred (via `reportError`) - * * [operations] and [preempted] are empty - * * [terminated] is set via [terminate] - */ - fun execute(context: MigrationContext) { - while (operations.any() || preempted.any()) { - clearPreemptedQueue(context) - if (terminated) { - return - } - val operation = operations.removeFirstOrNull() ?: return - - context.execSafe(operation) { - val replacements = executeOperationInternal(it, context) - operations.addAll(0, replacements) - } - } - } - - @VisibleForTesting - internal open fun executeOperationInternal( - it: Operation, - context: MigrationContext - ) = it.execute(context) - - /** - * Executes all items in the preempted queue - * - * After this has completed either: [preempted] is empty, OR [terminated] is true - */ - private fun clearPreemptedQueue(context: MigrationContext) { - while (true) { - if (terminated) return - - // exit if we've got no more items - val nextItem = getNextPreemptedItem() ?: return - Timber.d("executing preempted operation: %s", nextItem) - context.execSafe(nextItem) { - val replacements = it.execute(context) - addPreempted(replacements) - } - } - } - - fun prepend(operation: Operation) = operations.addFirst(operation) - fun append(operation: Operation) = operations.add(operation) - fun appendAll(operations: List) = this.operations.addAll(operations) - - // region preemption (synchronized) - - private fun addPreempted(replacements: List) { - // insert all at the start of the queue - synchronized(preempted) { preempted.addAll(0, replacements) } - } - private fun getNextPreemptedItem() = synchronized(preempted) { - return@synchronized preempted.removeFirstOrNull() - } - fun preempt(operation: Operation) = synchronized(preempted) { preempted.add(operation) } - - // endregion - - /** Stops execution of [execute] */ - fun terminate() { - this.terminated = true - } - } - - /** - * Abstracts the decision of what to do when an exception occurs when migrating a file. - * Provides progress notifications. - * @param executor The executor that will do the migration. - * @param progressReportParam A function, called for each file that is migrated, with the number of bytes of the file. - */ - open class UserDataMigrationContext(private val executor: Executor, val source: Directory, val progressReportParam: MigrationProgressListener) : MigrationContext() { - val successfullyCompleted: Boolean get() = loggedExceptions.isEmpty() && !executor.terminated - - /** - * The reason that the the execution of the whole migration was terminated early - * - * @see failOperationWith - */ - var terminatedWith: Exception? = null - private set - - private val retriedDirectories = hashSetOf() - - val loggedExceptions = mutableListOf() - private var consecutiveExceptionsWithoutProgress = 0 - override fun reportError(throwingOperation: Operation, ex: Exception) { - when (ex) { - is FileConflictException -> { moveToConflictedFolder(ex.source) } - is FileDirectoryConflictException -> { moveToConflictedFolder(ex.source) } - is DirectoryNotEmptyException -> { - // If a directory isn't empty, some more files may have been added. Retry (after all others are completed) - if (throwingOperation.retryOperations.any() && retriedDirectories.add(ex.directory.directory)) { - executor.appendAll(throwingOperation.retryOperations) - } else { - logExceptionAndContinue(ex) - } - } - is MissingDirectoryException -> failOperationWith(ex) - // logical error: we tried to migrate to the same path - is EquivalentFileException -> failOperationWith(ex) - // if we couldn't move a file to /conflict/, log and continue. - // we do not expect this exception to occur - is FileConflictResolutionFailedException -> logExceptionAndContinue(ex) - else -> logExceptionAndContinue(ex) - } - } - - /** - * Keeps a circular buffer of the last 10 exceptions. - * the oldest exception is evicted if more than 10 are added - */ - private fun logExceptionAndContinue(ex: Exception) { - if (loggedExceptions.size >= 10) { - loggedExceptions.removeFirst() - } - loggedExceptions.add(ex) - consecutiveExceptionsWithoutProgress++ - if (consecutiveExceptionsWithoutProgress >= 10) { - val exception = loggedExceptions.singleOrNull() - ?: TranslatableAggregateException( - message = "Multiple consecutive errors without progress", - translatableMessage = { - getString( - R.string.error__etc__multiple_consecutive_errors_without_progress_most_recent, - getUserFriendlyErrorText(loggedExceptions.last()) - ) - }, - causes = loggedExceptions - ) - - failOperationWith(exception) - } - } - - /** - * On a conflicted file, move it from `` to `/conflict/`. - * Files in this folder will not be moved again - * - * We perform this action immediately - * - * @see MoveConflictedFile - */ - private fun moveToConflictedFolder(conflictedFile: DiskFile) { - val relativePath = RelativeFilePath.fromPaths(source, conflictedFile)!! - val operation: MoveConflictedFile = MoveConflictedFile.createInstance(conflictedFile, source, relativePath) - executor.prepend(operation) - } - - override fun reportProgress(transferred: NumberOfBytes) { - consecutiveExceptionsWithoutProgress = 0 - this.progressReportParam(transferred) - } - - /** A fatal exception has occurred which should stop all file processing */ - private fun failOperationWith(ex: Exception) { - executor.terminate() - terminatedWith = ex - } - - fun reset() { - loggedExceptions.clear() - consecutiveExceptionsWithoutProgress = 0 - } - } - - @VisibleForTesting - var executor = Executor(ArrayDeque()) - - @VisibleForTesting - var externalRetries = 0 - private set - - /** - * Migrates all files and folders to [destination], aside from [getEssentialFiles] - * - * @throws MissingDirectoryException - * @throws EquivalentFileException - * @throws AggregateException If multiple exceptions were thrown when executing - * @throws RuntimeException Various other failings if only a single exception was thrown - */ - fun migrateFiles(progressListener: MigrationProgressListener) { - val context = initializeContext(progressListener) - - // define the function here, so we can execute it on retry - fun moveRemainingFiles() { - executor.appendAll(getMigrateUserDataOperations()) - executor.execute(context) - } - - moveRemainingFiles() - - // try 2 times, then stop temporarily - while (!context.successfullyCompleted && externalRetries < 2) { - context.reset() - externalRetries++ - moveRemainingFiles() - } - - // if the operation was terminated (typically due to too many consecutive exceptions), throw that - // otherwise, there were a few exceptions which didn't stop execution, throw these. - if (!context.successfullyCompleted) { - context.terminatedWith?.let { throw it } - throw context.loggedExceptions.singleOrNull() - ?: TranslatableAggregateException(causes = context.loggedExceptions) - } - - // we are successfully migrated here - // TODO: fix "conflicts" - check to see if conflicts are due to partially copied files in the destination - } - - @VisibleForTesting - /** - * @return A User data migration context, executing the migration of [source] on [executor]. - * Calling [collectionTask::doProgress] on each migrated file, with the number of bytes migrated. - */ - internal open fun initializeContext(progress: (MigrationProgressListener)) = - UserDataMigrationContext(executor, source, progress) - - /** - * Returns migration operations for the top level items in /AnkiDroid/ - */ - @VisibleForTesting - internal open fun getMigrateUserDataOperations(): List = - getUserDataFiles() - .map { fileOrDir -> - MoveFileOrDirectory( - sourceFile = File(source.directory, fileOrDir.name), - destination = File(destination.directory, fileOrDir.name) - ) - }.sortedWith( - compareBy { - // Have user-generated files take priority over the media. - // the 'fonts' folder will impact UX - // 'card.html' is often regenerated and is likely to cause a conflict - // We want all the backups to be restorable ASAP) - when (it.sourceFile.name) { - "card.html" -> -3 - "fonts" -> -2 - "backups" -> -1 - else -> 0 - } - } - ) - .toList() - - /** Gets a sequence of content in [source] */ - private fun getDirectoryContent() = sequence { - CompatHelper.compat.contentOfDirectory(source.directory).use { - while (it.hasNext()) { - yield(it.next()) - } - } - } - - /** Returns a sequence of the Files or Directories in [source] which are to be migrated */ - private fun getUserDataFiles() = getDirectoryContent().filter { isUserData(it) } - - private fun isEssentialFileName(name: String): Boolean { - return MigrateEssentialFiles.PRIORITY_FILES.flatMap { it.potentialFileNames }.contains(name) - } - - /** Returns whether a file is "user data" and should be moved */ - private fun isUserData(file: File): Boolean { - if (file.isFile && isEssentialFileName(file.name)) { - return false - } - - // don't move the "conflict" directory - return file.name != MoveConflictedFile.CONFLICT_DIRECTORY - } - - /** - * Migrate a file to [expectedFileLocation] if it exists inside [source] - * @param expectedFileLocation A file which should exist inside [destination] - * */ - fun migrateFileImmediately(expectedFileLocation: File) { - // It is possible, but unlikely that a file at the location already exists - if (expectedFileLocation.exists()) { - Timber.d("nothing to migrate: file already exists") - return - } - - // convert to a relative path WRT the destination (our current collection) - val relativeDataPath = RelativeFilePath.fromPaths(destination.directory, expectedFileLocation) - ?: throw IllegalStateException("Could not create relative path between ${destination.directory} and $expectedFileLocation") - - // get a reference to the source file - val sourceFile = DiskFile.createInstance(relativeDataPath.toFile(source)) - if (sourceFile == null) { - Timber.w("couldn't migrate: source file not found or not a file. Maybe a bad card. Maybe already moved") - return - } - - val moveFile = MoveFile(sourceFile, expectedFileLocation) - AwaitableOperation(moveFile).also { operation -> - this.executor.preempt(operation) - operation.await() - } - - Timber.w("complete migration: %s $relativeDataPath $sourceFile", expectedFileLocation) - } -} - -/** - * Wraps an [Operation] with functionality to allow for retries - * - * Useful if you want to call a different operation when an operation is being retried. - * - * Example: call MoveDirectory again if DeleteEmptyDirectory fails - * - * @receiver The operation to be decorated with a retry action - * @param operationOnRetry The action to perform is [Operation.retryOperations] is called - */ -internal fun Operation.onRetryExecute(operationOnRetry: Operation): Operation { - val operationToBeDecorated = this - return SingleRetryDecorator( - standardOperation = operationToBeDecorated, - retryOperation = operationOnRetry - ) -} - -/** The operation was completed (not necessarily successfully) and no additional operations are required */ -internal fun operationCompleted() = emptyList() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MoveDirectory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MoveDirectory.kt deleted file mode 100644 index 95bea204b7be..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MoveDirectory.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage.migrateuserdata - -import androidx.annotation.VisibleForTesting -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.DeleteEmptyDirectory -import com.ichi2.anki.servicelayer.scopedstorage.MoveDirectoryContent -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.compat.CompatHelper -import timber.log.Timber -import java.io.File - -/** - * [Operation] which safely moves a directory at path `source` to path `destination`. - * `destination` is the new path of the directory. - * - * Note: thrown exceptions are passed to the context via [MigrateUserData.MigrationContext.reportError] - * - */ - -data class MoveDirectory(val source: Directory, val destination: File) : Operation() { - override fun execute(context: MigrationContext): List { - if (!createDirectory(context)) { - return operationCompleted() - } - - val moveContentOperations = MoveDirectoryContent.createInstance(source, destination) - // If DeleteEmptyDirectory fails, retrying is executing the MoveDirectory that spawned it - val deleteOperation = DeleteEmptyDirectory(source).onRetryExecute(this) - return listOf(moveContentOperations, deleteOperation) - } - - /** - * Create an empty directory at destination. - * Return whether it was successful. - */ - private fun createDirectory(context: MigrationContext): Boolean { - Timber.d("creating directory '$destination'") - createDirectory(destination) - - val destinationDirectory = Directory.createInstance(destination) - if (destinationDirectory == null) { - context.reportError(this, IllegalStateException("Directory instantiation failed: '$destination'")) - return false - } - return true - } - - /** Creates a directory if it doesn't already exist */ - @VisibleForTesting - internal fun createDirectory(directory: File) = CompatHelper.compat.createDirectories(directory) - - @VisibleForTesting - internal fun rename(source: Directory, destination: File) = source.renameTo(destination) -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/UserDataMigrationPreferences.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/UserDataMigrationPreferences.kt deleted file mode 100644 index 1fb5e86e136b..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/UserDataMigrationPreferences.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * Copyright (c) 2022 Arthur Milchior - * - * 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.servicelayer.scopedstorage.migrateuserdata - -import android.content.SharedPreferences -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_DESTINATION -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_SOURCE -import java.io.File - -/** - * Preferences relating to 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. - * - * @param source The path of the source directory. Check [migrationInProgress] before use. - * @param destination The path of the destination directory. Check [migrationInProgress] before use. - */ -class UserDataMigrationPreferences private constructor(val source: String, val destination: String) { - /** Whether a scoped storage migration is in progress */ - val migrationInProgress = source.isNotEmpty() - val sourceFile get() = File(source) - val destinationFile get() = File(destination) - - // Throws if migration can't occur as expected. - fun check() { - // ensure that both are set, or both are empty - if (source.isEmpty() != destination.isEmpty()) { - // throw if there's a mismatch + list the key -> value pairs - val message = - "'$PREF_MIGRATION_SOURCE': '$source'; " + - "'$PREF_MIGRATION_DESTINATION': '$destination'" - throw IllegalStateException("Expected either all or no migration directories set. $message") - } - } - - companion object { - /** - * @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 - */ - fun createInstance(preferences: SharedPreferences): UserDataMigrationPreferences { - fun getValue(key: String) = preferences.getString(key, "")!! - - return createInstance( - source = getValue(PREF_MIGRATION_SOURCE), - destination = getValue(PREF_MIGRATION_DESTINATION) - ) - } - - fun createInstance(source: String, destination: String) = UserDataMigrationPreferences(source, destination).also { it.check() } - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/MigrationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/MigrationService.kt deleted file mode 100644 index 8628534eea76..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/MigrationService.kt +++ /dev/null @@ -1,463 +0,0 @@ -/* - * Copyright (c) 2023 David Allison - * - * 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.services - -import android.app.Notification -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.PowerManager -import android.text.format.Formatter -import androidx.core.app.NotificationCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.Channel -import com.ichi2.anki.CollectionManager -import com.ichi2.anki.CrashReportService -import com.ichi2.anki.IntentHandler -import com.ichi2.anki.R -import com.ichi2.anki.preferences.sharedPrefs -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.prepareAndValidateSourceAndDestinationFolders -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles -import com.ichi2.anki.servicelayer.scopedstorage.MoveConflictedFile -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData -import com.ichi2.anki.utils.getUserFriendlyErrorText -import com.ichi2.anki.utils.withWakeLock -import com.ichi2.preferences.getOrSetLong -import com.ichi2.utils.FileUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import timber.log.Timber -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. -private const val PREF_MIGRATION_ERROR_TEXT = "migrationErrorText" - -// Shared preferences key for the initial total size of media files to be moved. -// It is used to correctly show progress if the app is killed and restarted. -private const val PREF_INITIAL_TOTAL_MEDIA_BYTES_TO_MOVE = "migrationServiceTotalBytes" - -/** - * A foreground service responsible for migrating the collection - * from a public directory to an app-private directory. - * - * Notes on behavior: - * - * * Data is moved in two stages, first essential database files are copied, - * and then the media files are moved. When the first step is started, - * the app *does not* update any persistent settings until it is complete. - * If at some point the first step fails, the newly created files are removed, - * however, if the app is killed, they may remain on disk. - * This does not affect the state of the app; upon restart it will behave as if nothing happened. - * - * * When moving media files, to show a progress bar, - * we first calculate the total size of the data to be transferred, - * and then, as the files are transferred by recursing into the directories, - * we add the size of each transferred file to a sum of transferred files. - * As the number of files and file sizes can change after the initial calculation, - * we can end up with the final ratio of transferred size to the estimate - * being less or greater to 1. This, however, is very unlikely, so we simply - * make sure than in the UI code transferred size never exceeds the estimate. - * - * * As the app can be killed at any time, to make sure that the service shows consistent - * progress after it is restarted, we save the initial size of data to be transferred. - * When resuming migration, we can calculate transferred size - * by subtracting the size of remaining data from the stored value. - * - * * We are not rate-limiting publication of the notifications in the code, - * as the files do not seem to be transferred so fast as to cause any problems. - * The system performs its own rate-limiting, dropping updates if they are published too quickly. - * An exception is is made for "completed progress notifications". See: - * https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/services/core/java/com/android/server/notification/NotificationManagerService.java - * https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/services/core/java/com/android/server/notification/RateEstimator.java - */ -class MigrationService : ServiceWithALifecycleScope(), ServiceWithASimpleBinder { - companion object { - private var serviceIsRunning = false - - fun start(context: Context) { - if (serviceIsRunning) return - serviceIsRunning = true - - context.sharedPrefs().edit { remove(PREF_MIGRATION_ERROR_TEXT) } - flowOfProgress.tryEmit(null) - - ContextCompat.startForegroundService( - context, - Intent(context, MigrationService::class.java) - ) - } - - val flowOfProgress: MutableStateFlow = MutableStateFlow(null) - } - - sealed interface Progress { - sealed interface Running : Progress - sealed interface Done : Progress - - data object CopyingEssentialFiles : Running - - sealed interface MovingMediaFiles : Running { - data object CalculatingNumberOfBytesToMove : MovingMediaFiles - - data class MovingFiles(val movedBytes: Long, val totalBytes: Long) : MovingMediaFiles { - val ratio get() = if (totalBytes == 0L) 1f else movedBytes.toFloat() / totalBytes - } - } - - data object Succeeded : Done - - data class Failed(val exception: Exception, val changesRolledBack: Boolean) : Done - } - - private val preferences get() = this.sharedPrefs() - - private lateinit var migrateUserDataTask: MigrateUserData - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Timber.w("onStartCommand(%s, ...)", intent) - - lifecycleScope.launch(Dispatchers.IO) { - withWakeLock( - levelAndFlags = PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ON_AFTER_RELEASE, - tag = "MigrationService" - ) { - if (getMediaMigrationState() is MediaMigrationState.NotOngoing.Needed) { - flowOfProgress.emit(Progress.CopyingEssentialFiles) - - try { - val folders = prepareAndValidateSourceAndDestinationFolders(baseContext) - CollectionManager.migrateEssentialFiles(baseContext, folders) - } catch (e: Exception) { - Timber.w(e, "Essential file migration failed") - CrashReportService.sendExceptionReport(e, "Essential file migration failed") - flowOfProgress.emit(Progress.Failed(exception = e, changesRolledBack = true)) - } - } - - if (getMediaMigrationState() is MediaMigrationState.Ongoing) { - flowOfProgress.emit(Progress.MovingMediaFiles.CalculatingNumberOfBytesToMove) - - try { - migrateUserDataTask = MigrateUserData.createInstance(preferences) - - val remainingBytesToMove = getRemainingMediaBytesToMove(migrateUserDataTask) - val totalBytesToMove = preferences - .getOrSetLong(PREF_INITIAL_TOTAL_MEDIA_BYTES_TO_MOVE) { remainingBytesToMove } - var movedBytes = max(totalBytesToMove - remainingBytesToMove, 0) - - migrateUserDataTask.migrateFiles(progressListener = { deltaMovedBytes -> - movedBytes += deltaMovedBytes - flowOfProgress.tryEmit( - Progress.MovingMediaFiles.MovingFiles( - movedBytes = movedBytes.coerceIn(0, totalBytesToMove), - totalBytes = totalBytesToMove - ) - ) - }) - - // TODO BEFORE-RELEASE Consolidate setting/removing migration-related preferences. - // The existence of these determine if the *media* migration is taking place. - // These are currently set in MigrateEssentialFiles.updatePreferences - // 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. - preferences.edit { - remove(PREF_MIGRATION_DESTINATION) - remove(PREF_MIGRATION_SOURCE) - remove(PREF_INITIAL_TOTAL_MEDIA_BYTES_TO_MOVE) - } - - flowOfProgress.emit(Progress.Succeeded) - } catch (e: Exception) { - Timber.w(e, "Media migration failed") - CrashReportService.sendExceptionReport(e, "Media migration failed") - - preferences.edit { - putString(PREF_MIGRATION_ERROR_TEXT, getUserFriendlyErrorText(e)) - } - - flowOfProgress.emit(Progress.Failed(exception = e, changesRolledBack = false)) - } - } - } - } - - lifecycleScope.launch { - flowOfProgress - .filterNotNull() - .collect { progress -> - startForeground(2, makeMigrationProgressNotification(progress)) - - if (progress is Progress.Done) { - ServiceCompat.stopForeground( - this@MigrationService, - ServiceCompat.STOP_FOREGROUND_DETACH - ) - - stopSelf() - - when (progress) { - is Progress.Succeeded -> - AnkiDroidApp.instance.activityAgnosticDialogs - .showOrScheduleStorageMigrationSucceededDialog() - - is Progress.Failed -> - AnkiDroidApp.instance.activityAgnosticDialogs - .showOrScheduleStorageMigrationFailedDialog( - exception = progress.exception, - changesRolledBack = progress.changesRolledBack - ) - } - } - } - } - - return START_STICKY - } - - private fun getRemainingMediaBytesToMove(task: MigrateUserData): Long { - val ignoredFiles = MigrateEssentialFiles.iterateEssentialFiles(task.source) + - File(task.source.directory, MoveConflictedFile.CONFLICT_DIRECTORY) - val ignoredSpace = ignoredFiles.sumOf { FileUtil.getSize(it) } - val folderSize = - FileUtil.DirectoryContentInformation.fromDirectory(task.source.directory).totalBytes - val remainingSpaceToMigrate = folderSize - ignoredSpace - Timber.d( - "folder size: %d, safe: %d, remaining: %d", - folderSize, - ignoredSpace, - remainingSpaceToMigrate - ) - return remainingSpaceToMigrate - } - - override fun onBind(intent: Intent) = SimpleBinder(this) - - override fun onDestroy() { - super.onDestroy() - serviceIsRunning = false - } - - /** - * A file was expected at the provided location, but wasn't found - * If it exists in the old location, attempt to migrate it. - * Block until migrated. - * - * @return Whether the migration was successful (or unnecessary) - */ - fun migrateFileImmediately(expectedFileLocation: File): Boolean { - try { - migrateUserDataTask.migrateFileImmediately(expectedFileLocation) - } catch (e: Exception) { - Timber.w(e, "Failed to migrate file") - } - return expectedFileLocation.exists() - } -} - -private fun Context.makeMigrationProgressNotification(progress: MigrationService.Progress): Notification { - val builder = NotificationCompat.Builder(this, Channel.SCOPED_STORAGE_MIGRATION.id) - .setSmallIcon(R.drawable.ic_star_notify) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setSilent(true) - - when (progress) { - is MigrationService.Progress.CopyingEssentialFiles -> { - builder.setOngoing(true) - builder.setProgress(0, 0, true) - builder.setContentTitle(getString(R.string.migration__migrating_database_files)) - builder.setContentText(getString(R.string.migration__copying)) - } - - is MigrationService.Progress.MovingMediaFiles.CalculatingNumberOfBytesToMove -> { - builder.setOngoing(true) - builder.setProgress(0, 0, true) - builder.setContentTitle(getString(R.string.migration__migrating_media)) - builder.setContentText(getString(R.string.migration__calculating_transfer_size)) - } - - is MigrationService.Progress.MovingMediaFiles.MovingFiles -> { - val movedSizeText = Formatter.formatShortFileSize(this, progress.movedBytes) - val totalSizeText = Formatter.formatShortFileSize(this, progress.totalBytes) - - builder.setOngoing(true) - builder.setProgress(Int.MAX_VALUE, (progress.ratio * Int.MAX_VALUE).toInt(), false) - builder.setContentTitle(getString(R.string.migration__migrating_media)) - builder.setContentText(getString(R.string.migration__moved_x_of_y, movedSizeText, totalSizeText)) - } - - is MigrationService.Progress.Succeeded -> { - builder.setProgress(100, 100, false) - builder.setContentTitle(getString(R.string.migration__migrating_media)) - builder.setContentText(getString(R.string.migration_successful_message)) - } - - // Note that this currently does not differentiate between failures - // with rolled-back changes and without them. - // - // A note on behavior of BigTextStyle. - // When the notification is collapsed, big text style is completely ignored, - // and the notification builder's title and text is shown, single-line each: - // - // Content title, bold - // Content text, ellipsized if long... - // - // When expanded, these are replaced by big content style's big content title - // and big text. If big content title is not present, notification's content title is used: - // - // Big content title or notification's content title, bold - // Big text, spanning several lines - // if it is sufficiently long - is MigrationService.Progress.Failed -> { - val errorText = getUserFriendlyErrorText(progress.exception) - - val copyDebugInfoIntent = IntentHandler - .copyStringToClipboardIntent(this, progress.exception.stackTraceToString()) - val copyDebugInfoPendingIntent = PendingIntentCompat.getActivity( - this, - 1, - copyDebugInfoIntent, - 0, - false - ) - - val helpUrl = getString(R.string.migration_failed_help_url) - val viewHelpUrlIntent = Intent(Intent.ACTION_VIEW, Uri.parse(helpUrl)) - val viewHelpUrlPendingIntent = PendingIntentCompat.getActivity( - this, - 0, - viewHelpUrlIntent, - 0, - false - ) - - builder.setContentTitle(getString(R.string.migration__failed__title)) - builder.setContentText(errorText) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(errorText)) - builder.addAction(R.drawable.ic_star_notify, getString(R.string.feedback_copy_debug), copyDebugInfoPendingIntent) - builder.addAction(0, getString(R.string.help), viewHelpUrlPendingIntent) - } - } - - return builder.build() -} - -/** - * A delegate for a property that yields: - * * 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, - * so expect this property to be `null` when reading right after `onStart()`. - */ -fun O.migrationServiceWhileStartedOrNull(): ReadOnlyProperty - where O : Context, O : LifecycleOwner { - var service: MigrationService? = null - - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - if (getMediaMigrationState() is MediaMigrationState.Ongoing.NotPaused) { - try { - withBoundTo { - service = it - suspendCancellableCoroutine {} - } - } finally { - service = null - } - } - } - } - - return ReadOnlyProperty { _, _ -> service } -} - -/**************************************************************************************************/ - -/** - * This represents the overarching state of media migration as determined by: - * * the build/flavor, - * * the API level, - * * some settings persisted in shared preferences. - * - * This is not determined by permissions or static variables. - */ -sealed interface MediaMigrationState { - sealed interface NotOngoing : MediaMigrationState { - sealed interface NotNeeded : NotOngoing { - data object CollectionIsInAppPrivateFolder : NotNeeded - data object CollectionIsInPublicFolderButWillRemainAccessible : NotNeeded - } - data object Needed : NotOngoing - } - - sealed interface Ongoing : MediaMigrationState { - data 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 = this.sharedPrefs() - - fun migrationIsOngoing() = ScopedStorageService.mediaMigrationIsInProgress(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 - } - } -} 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..cd2b3afa6106 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 @@ -18,102 +18,9 @@ package com.ichi2.anki.ui.dialogs import android.app.Activity import android.app.Application -import android.app.Dialog import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.core.text.parseAsHtml -import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager.getDefaultSharedPreferences -import com.ichi2.anki.R -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs.Companion.MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY -import com.ichi2.anki.utils.getUserFriendlyErrorText -import com.ichi2.utils.copyToClipboard -import makeLinksClickable - -// 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. -// Dismissing the notification should not also dismiss, or prevent from showing, this dialog, -// as notifications are too easily dismissed inadvertently. -// On the other hand, dismissing this dialog should probably dismiss the notification, -// as you have to press a button to dismiss the dialog. -class MigrationSucceededDialogFragment : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return AlertDialog.Builder(requireContext()) - .setTitle(R.string.migration_successful_message) - .setMessage(R.string.migration__succeeded__message) - .setPositiveButton(R.string.dialog_ok) { _, _ -> dismiss() } - .create() - .apply { setCanceledOnTouchOutside(false) } - } - - companion object { - fun show(activity: FragmentActivity) { - MigrationSucceededDialogFragment() - .show(activity.supportFragmentManager, "MigrationSucceededDialogFragment") - } - } -} - -// TODO BEFORE-RELEASE Add a "Retry" button, -// and also add instructions to fix the issue in case of easily fixable problems, -// such as running out of disk space. -class MigrationFailedDialogFragment : DialogFragment() { - @Suppress("MoveVariableDeclarationIntoWhen") // changesRolledBack - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val errorText = arguments?.getString(ERROR_TEXT_KEY) ?: "" - val stacktrace = arguments?.getString(STACKTRACE_KEY) ?: "" - val changesRolledBack = arguments?.getBoolean(CHANGES_ROLLED_BACK_KEY) ?: false - - val messageTemplateId = when (changesRolledBack) { - true -> R.string.migration__failed__changes_rolled_back__message - false -> R.string.migration__failed__changes_not_rolled_back__message - } - val helpUrl = getString(R.string.link_migration_failed_dialog_learn_more_en) - val message = getString(messageTemplateId, errorText, helpUrl).parseAsHtml() - - return AlertDialog.Builder(requireContext()) - .setTitle(R.string.migration__failed__title) - .setMessage(message) - .setPositiveButton(R.string.dialog_ok) { _, _ -> dismiss() } - .setNegativeButton(R.string.feedback_copy_debug) { _, _ -> - requireContext().copyToClipboard( - text = stacktrace, - failureMessageId = R.string.about_ankidroid_error_copy_debug_info - ) - } - .create() - .apply { - makeLinksClickable() - setCanceledOnTouchOutside(false) - } - } - - companion object { - private const val ERROR_TEXT_KEY = "error text" - private const val STACKTRACE_KEY = "stacktrace" - private const val CHANGES_ROLLED_BACK_KEY = "changes rolled back" - - const val TAG = "MigrationFailedDialogFragment" - - fun show(activity: FragmentActivity, errorText: CharSequence, stacktrace: String, changesRolledBack: Boolean) { - MigrationFailedDialogFragment() - .apply { - arguments = bundleOf( - ERROR_TEXT_KEY to errorText, - STACKTRACE_KEY to stacktrace, - CHANGES_ROLLED_BACK_KEY to changesRolledBack - ) - } - .show(activity.supportFragmentManager, TAG) - } - } -} - /* ********************************************************************************************** */ /** @@ -121,32 +28,6 @@ class MigrationFailedDialogFragment : DialogFragment() { * or, if the app is in background, scheduling it to be shown the next time any activity is started. */ class ActivityAgnosticDialogs private constructor(private val application: Application) { - fun showOrScheduleStorageMigrationSucceededDialog() { - if (currentlyStartedFragmentActivity != null) { - MigrationSucceededDialogFragment.show(currentlyStartedFragmentActivity!!) - } else { - preferences.edit { putBoolean(MIGRATION_SUCCEEDED_DIALOG_PENDING_KEY, true) } - } - } - - fun showOrScheduleStorageMigrationFailedDialog(exception: Exception, changesRolledBack: Boolean) { - val currentlyStartedFragmentActivity = this.currentlyStartedFragmentActivity - val context = currentlyStartedFragmentActivity ?: application - val errorText = context.getUserFriendlyErrorText(exception) - val stacktrace = exception.stackTraceToString() - - if (currentlyStartedFragmentActivity != null) { - MigrationFailedDialogFragment - .show(currentlyStartedFragmentActivity, errorText, stacktrace, changesRolledBack) - } else { - preferences.edit { - putString(MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY, errorText) - putString(MIGRATION_FAILED_DIALOG_STACKTRACE_KEY, stacktrace) - putBoolean(MIGRATION_FAILED_DIALOG_CHANGES_ROLLED_BACK_KEY, changesRolledBack) - } - } - } - private val preferences = getDefaultSharedPreferences(application) private val startedActivityStack = mutableListOf() @@ -160,48 +41,14 @@ class ActivityAgnosticDialogs private constructor(private val application: Appli override fun onActivityStarted(activity: Activity) { startedActivityStack.add(activity) } override fun onActivityStopped(activity: Activity) { startedActivityStack.remove(activity) } }) - - application.registerActivityLifecycleCallbacks(object : DefaultActivityLifecycleCallbacks { - override fun onActivityStarted(activity: Activity) { - if (activity !is FragmentActivity) return - - if (preferences.getBoolean(MIGRATION_SUCCEEDED_DIALOG_PENDING_KEY, false)) { - MigrationSucceededDialogFragment.show(activity) - preferences.edit { remove(MIGRATION_SUCCEEDED_DIALOG_PENDING_KEY) } - } - - val errorText = preferences.getString(MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY, null) - val stacktrace = preferences.getString(MIGRATION_FAILED_DIALOG_STACKTRACE_KEY, null) - val changesRolledBack = preferences.getBoolean(MIGRATION_FAILED_DIALOG_CHANGES_ROLLED_BACK_KEY, false) - if (errorText != null && stacktrace != null) { - MigrationFailedDialogFragment.show(activity, errorText, stacktrace, changesRolledBack) - preferences.edit { - remove(MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY) - remove(MIGRATION_FAILED_DIALOG_STACKTRACE_KEY) - remove(MIGRATION_FAILED_DIALOG_CHANGES_ROLLED_BACK_KEY) - } - } - } - }) } companion object { - private const val MIGRATION_SUCCEEDED_DIALOG_PENDING_KEY = "migration succeeded dialog pending" - - const val MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY = "migration failed dialog error text" - private const val MIGRATION_FAILED_DIALOG_STACKTRACE_KEY = "migration failed dialog stacktrace" - private const val MIGRATION_FAILED_DIALOG_CHANGES_ROLLED_BACK_KEY = "migration failed dialog changes rolled back" - fun register(application: Application) = ActivityAgnosticDialogs(application) .apply { registerCallbacks() } } } -fun storageMigrationFailedDialogIsShownOrPending(activity: AppCompatActivity) = - activity.supportFragmentManager.findFragmentByTag(MigrationFailedDialogFragment.TAG) != null || - activity.sharedPrefs() - .getString(MIGRATION_FAILED_DIALOG_ERROR_TEXT_KEY, null) != null - interface DefaultActivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) {} diff --git a/AnkiDroid/src/main/res/drawable/ic_migrate.xml b/AnkiDroid/src/main/res/drawable/ic_migrate.xml deleted file mode 100644 index 688fc494b2ae..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_migrate.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/AnkiDroid/src/main/res/layout/migration_progress_menu_layout.xml b/AnkiDroid/src/main/res/layout/migration_progress_menu_layout.xml deleted file mode 100644 index 9a47b95d46d3..000000000000 --- a/AnkiDroid/src/main/res/layout/migration_progress_menu_layout.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/menu/deck_picker.xml b/AnkiDroid/src/main/res/menu/deck_picker.xml index f9819b441513..ce7e05de8b4d 100644 --- a/AnkiDroid/src/main/res/menu/deck_picker.xml +++ b/AnkiDroid/src/main/res/menu/deck_picker.xml @@ -10,21 +10,6 @@ ankidroid:actionViewClass="androidx.appcompat.widget.SearchView" ankidroid:showAsAction="always|collapseActionView"/> - - - Device storage not mounted Sync - Migrate to scoped storage Do you want to cancel the sync? Continue sync Sync cancelled @@ -194,80 +193,6 @@ “Multiple consecutive errors without progress, most recent: CPU hit by a cosmic ray particle”." >Multiple consecutive errors without progress, most recent: %s
- - Migrating database files - Copying… - - Migrating media - Calculating transfer size… - Moved %1$s of %2$s - - Migration successful - - Your data has been migrated successfully. - AnkiDroid is fully functional again, and you can sync. - \n - \nNote that your collection is now located in a folder that is removed when uninstalling the app. - Please make sure that your data is safe by regularly syncing or exporting backups. - - - Migration failed -
- The changes were reverted. -

- Please try re-running the migration. - To resolve the problem, you may need to free up some disk space, - or reconnect the removable storage that holds your collection. -

- Learn more and get help - ]]>
-
- The changes were not reverted. - While no data should’ve been lost, - the media files might be split between the old and the new folder. -

- Please try re-running the migration. - To resolve the problem, you may need to free up some disk space, - or reconnect the removable storage that holds your collection. -

- Learn more and get help - ]]>
- -
- Learn more and get help - ]]>
- Resume migration - Inaccessible collection 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: Android has removed AnkiDroid\'s %1$s permission due to app inactivity.\n\nYour data is safe and can be restored. It is located at\n%2$s\n\nSelect an option below to restore: diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 8650fc5410f8..ef94458dea95 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -211,11 +211,6 @@ Search decks - Show migration progress - Fatal Error AnkiDroid relies on the System WebView which is unavailable. This can happen if the system is installing updates. Please try again in a few minutes.\n\n%s @@ -349,16 +344,6 @@ Truncate the height of each row of the Browser to show only first 3 lines of content Browser Options - - This functionality is unavailable during a storage migration - - - Tap to start storage migration - Media Sync Required Media sync is disabled in the settings. Please sync and backup any media which hasn\'t been synced before continuing diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAndroidTest.kt deleted file mode 100644 index 22fe23429b3f..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAndroidTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer - -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper -import com.ichi2.testutils.EmptyApplication -import com.ichi2.testutils.createTransientDirectory -import io.mockk.every -import io.mockk.mockkObject -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import java.io.File - -@RunWith(AndroidJUnit4::class) -@Config(application = EmptyApplication::class) -class ScopedStorageAndroidTest { - - @Test - fun best_default_root_first() { - // define two directories - runTestWithTwoExternalDirectories { externalDirectories -> - val templatePath = externalDirectories[0].createTransientDirectory("AD") - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("should return parent directory", best.canonicalPath, equalTo(externalDirectories[0].canonicalPath)) - } - } - - @Test - fun best_default_root_second() { - runTestWithTwoExternalDirectories { externalDirectories -> - val templatePath = externalDirectories[1].createTransientDirectory("AD") - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("should return parent directory", best.canonicalPath, equalTo(externalDirectories[1].canonicalPath)) - } - } - - @Test - fun best_default_root_returns_first_if_no_match() { - runTestWithTwoExternalDirectories { externalDirectories -> - val templatePath = createTransientDirectory("unrelated") - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("should return first path", best.canonicalPath, equalTo(externalDirectories[0].canonicalPath)) - } - } - - @Test - fun in_nested_paths_closest_is_returned() { - val sharedRootDirectory = createTransientDirectory("first") // ./first - val secondRootDirectory = sharedRootDirectory.createTransientDirectory("second") // ./first.second - val thirdRootDirectory = secondRootDirectory.createTransientDirectory("third") // ./first.second.third - val sharedDirectories = arrayOf(sharedRootDirectory, secondRootDirectory, thirdRootDirectory) - - val templatePath = secondRootDirectory.createTransientDirectory("template") // ./first.second.template - - runTestWithTwoExternalDirectories(sharedDirectories) { - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("should return second path as closest to the root", best.canonicalPath, equalTo(secondRootDirectory.canonicalPath)) - } - runTestWithTwoExternalDirectories(sharedDirectories.reversedArray()) { - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("should return second path as closest to the root", best.canonicalPath, equalTo(secondRootDirectory.canonicalPath)) - } - } - - @Test - fun sibling_of_second_external() { - val phoneRootDirectory = createTransientDirectory("phone") - val sdRootDirectory = createTransientDirectory("sd") - val phoneExternal = phoneRootDirectory.createTransientDirectory("external") - val sdExternal = sdRootDirectory.createTransientDirectory("external") - val sdAnkiDroid = sdRootDirectory.createTransientDirectory("ankidroid") - runTestWithTwoExternalDirectories(arrayOf(phoneExternal, sdExternal)) { - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), sdAnkiDroid) - assertThat("ambiguous, so should return first path", best.canonicalPath, equalTo(sdExternal.canonicalPath)) - } - } - - @Test - fun if_root_is_shared_return_first_path() { - val sharedRootDirectory = createTransientDirectory("root") - val depthOneRootDirectory = sharedRootDirectory.createTransientDirectory("first") - val depthTwoRootDirectory = sharedRootDirectory.createTransientDirectory("second").createTransientDirectory("second") - val depthThreeRootDirectory = sharedRootDirectory.createTransientDirectory("third").createTransientDirectory("third").createTransientDirectory("third") - val templatePath = sharedRootDirectory.createTransientDirectory("final") - - runTestWithTwoExternalDirectories(arrayOf(depthTwoRootDirectory, depthThreeRootDirectory, depthOneRootDirectory)) { - val best = ScopedStorageService.getBestDefaultRootDirectory(ApplicationProvider.getApplicationContext(), templatePath) - assertThat("ambiguous, so should return first path", best.canonicalPath, equalTo(depthTwoRootDirectory.canonicalPath)) - } - } - - /** - * run the test [test], with the hypothesis that there are two distinct app specific external directories. - * Those two directories are given as argument to the test. - */ - private fun runTestWithTwoExternalDirectories(test: (Array) -> Unit) { - val twoDirs = arrayOf(createTransientDirectory(), createTransientDirectory()) - runTestWithTwoExternalDirectories(twoDirs, test) - } - - /** - * run the test [test], with the hypothesis that [externalDirectories] are the external directories of the system. - */ - private fun runTestWithTwoExternalDirectories(externalDirectories: Array, test: (Array) -> Unit) { - mockkObject(CollectionHelper) { - every { CollectionHelper.getAppSpecificExternalDirectories(any()) } returns externalDirectories.toList() - test(externalDirectories) - } - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAnkiDroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAnkiDroidTest.kt deleted file mode 100644 index 865a9ebf44ab..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageAnkiDroidTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionHelper -import com.ichi2.anki.CollectionManager -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles -import com.ichi2.anki.servicelayer.scopedstorage.migrateEssentialFilesForTest -import com.ichi2.anki.servicelayer.scopedstorage.setLegacyStorage -import com.ichi2.libanki.Collection -import com.ichi2.testutils.ShadowStatFs -import com.ichi2.testutils.assertFalse -import com.ichi2.testutils.createTransientDirectory -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File -import kotlin.test.assertFailsWith - -@RunWith(AndroidJUnit4::class) -class ScopedStorageAnkiDroidTest : RobolectricTest() { - - override fun useInMemoryDatabase(): Boolean = false - - private fun getMigrationSourcePath() = File(col.path).parent!! - - @Test - fun migrate_essential_files_successful() = runTest { - val colPath = setupCol().path - ShadowStatFs.markAsNonEmpty(getBestRootDirectory()) - val migratedFrom = File(colPath).parentFile!! - - val migratedTo = migrateEssentialFilesForTest(targetContext, getMigrationSourcePath()) - - // close collection again so -wal doesn't end up in the list - CollectionManager.ensureClosed() - - val from = migratedFrom.listFiles()!!.associateBy { it.name }.toMutableMap() - val to = migratedTo.listFiles()!!.associateBy { it.name }.toMutableMap() - - assertThat("target folder name should be set", migratedTo.name, equalTo("AnkiDroid1")) - assertThat("target should be under scoped storage", ScopedStorageService.isLegacyStorage(migratedTo.absoluteFile, targetContext), equalTo(false)) - assertThat("bare files should be moved", to.keys, equalTo(from.keys)) - } - - @Test - fun migrate_essential_files_second_directory() = runTest { - setupCol() - val root = getBestRootDirectory() - root.createTransientDirectory("AnkiDroid1") - - val destinationFile = migrateEssentialFilesForTest(targetContext, getMigrationSourcePath(), destOverride = DestFolderOverride.Root(root)) - assertThat(destinationFile.name, equalTo("AnkiDroid2")) - } - - @Test - fun migrate_essential_files_fails_on_no_available_directory() = runTest { - setupCol() - val root = getBestRootDirectory() - for (i in 1..100) { - root.createTransientDirectory("AnkiDroid$i") - } - - // if "AnkiDroid100" can't be created - assertFailsWith { migrateEssentialFilesForTest(targetContext, getMigrationSourcePath(), destOverride = DestFolderOverride.Root(root)) } - } - - @Test - fun migrate_essential_files_deletes_created_directory_on_failure() = runTest { - setupCol() - - val colPath = File(col.path) - CollectionManager.ensureClosed() - colPath.delete() - - val folder = getBestRootDirectory() - - assertFailsWith { - migrateEssentialFilesForTest( - targetContext, - colPath.parent!!, - destOverride = DestFolderOverride.Subfolder(folder) - ) - } - - assertFalse("folder should not exist", folder.exists()) - } - - /** - * Accessing the collection ensure the creation of the collection. - */ - private fun setupCol(): Collection { - setLegacyStorage() - ShadowStatFs.markAsNonEmpty(getBestRootDirectory()) - return col - } - - private fun getBestRootDirectory(): File { - val collectionPath = - targetContext.sharedPrefs().getString(CollectionHelper.PREF_COLLECTION_PATH, null)!! - - // Get the scoped storage directory to migrate to. This is based on the location - // of the current collection path - return ScopedStorageService.getBestDefaultRootDirectory(targetContext, File(collectionPath)) - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageServiceTest.kt deleted file mode 100644 index b8176a5b3e5f..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/ScopedStorageServiceTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer - -import android.content.SharedPreferences -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_DESTINATION -import com.ichi2.anki.servicelayer.ScopedStorageService.PREF_MIGRATION_SOURCE -import com.ichi2.anki.servicelayer.ScopedStorageService.mediaMigrationIsInProgress -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.junit.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import kotlin.test.assertFailsWith - -class ScopedStorageServiceTest { - @Test - fun no_migration_by_default() { - val preferences = getScopedStorageMigrationPreferences(setSource = false, setDestination = false) - - assertThat("migration is not in progress if neither preference set", mediaMigrationIsInProgress(preferences), equalTo(false)) - } - - @Test - fun error_if_only_source_set() { - val preferences = getScopedStorageMigrationPreferences(setSource = true, setDestination = false) - - val exception = assertFailsWith { mediaMigrationIsInProgress(preferences) } - assertThat( - exception.message, - equalTo( - "Expected either all or no migration directories set. " + - "'migrationSourcePath': 'sample_source_path'; " + - "'migrationDestinationPath': ''" - ) - ) - } - - @Test - fun error_if_only_destination_set() { - val preferences = getScopedStorageMigrationPreferences(setSource = false, setDestination = true) - - val exception = assertFailsWith { mediaMigrationIsInProgress(preferences) } - assertThat( - exception.message, - equalTo( - "Expected either all or no migration directories set. " + - "'migrationSourcePath': ''; " + - "'migrationDestinationPath': 'sample_dest_path'" - ) - ) - } - - @Test - fun migration_if_both_set() { - val preferences = getScopedStorageMigrationPreferences(setSource = true, setDestination = true) - - assertThat("migration is in progress if both preferences set", mediaMigrationIsInProgress(preferences), equalTo(true)) - } - - private fun getScopedStorageMigrationPreferences(setSource: Boolean, setDestination: Boolean): SharedPreferences { - return mock { - on { getString(PREF_MIGRATION_SOURCE, "") } doReturn if (setSource) "sample_source_path" else "" - on { getString(PREF_MIGRATION_DESTINATION, "") } doReturn if (setDestination) "sample_dest_path" else "" - } - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectoryTest.kt deleted file mode 100644 index 04e8cb0c21b9..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/DeleteEmptyDirectoryTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.DirectoryNotEmptyException -import com.ichi2.compat.Test21And26 -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.hasSize -import org.hamcrest.Matchers.instanceOf -import org.junit.Test -import java.io.File -import kotlin.io.path.createTempDirectory -import kotlin.io.path.pathString - -/** - * Tests for [DeleteEmptyDirectory] - */ -class DeleteEmptyDirectoryTest : Test21And26(), OperationTest { - - override val executionContext = MockMigrationContext() - - @Test - fun succeeds_if_directory_is_empty() { - val toDelete = createEmptyDirectory() - - val next = DeleteEmptyDirectory(toDelete).execute(executionContext) - - assertThat("no exceptions", executionContext.exceptions, hasSize(0)) - assertThat("no more operations", next, hasSize(0)) - } - - @Test - fun fails_if_directory_is_not_empty() { - val toDelete = createEmptyDirectory() - File(toDelete.directory, "aa.txt").createNewFile() - - executionContext.logExceptions = true - val next = DeleteEmptyDirectory(toDelete).execute(executionContext) - - assertThat("exception expected", executionContext.exceptions, hasSize(1)) - assertThat(executionContext.exceptions.single(), instanceOf(DirectoryNotEmptyException::class.java)) - assertThat("no more operations", next, hasSize(0)) - } - - @Test - fun succeeds_if_directory_does_not_exist() { - val directory = createTempDirectory() - val dir = File(directory.pathString) - val toDelete = Directory.createInstance(dir)!! - dir.delete() - - val next = DeleteEmptyDirectory(toDelete).execute(executionContext) - - assertThat("no exceptions", executionContext.exceptions, hasSize(0)) - assertThat("no more operations", next, hasSize(0)) - } - - /** - * Reproduces https://github.com/ankidroid/Anki-Android/issues/10358 - * Where for some reason, `listFiles` returned null on an existing directory and - * newDirectoryStream returned `AccessDeniedException`. - */ - @Test - fun reproduce_10358() { - val permissionDenied = createPermissionDenied() - permissionDenied.assertThrowsWhenPermissionDenied { DeleteEmptyDirectory(permissionDenied.directory).execute(executionContext) } - } - - private fun createEmptyDirectory() = - Directory.createInstance(createTempDirectory().pathString)!! -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ExecutorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ExecutorTest.kt deleted file mode 100644 index f1a34eebb9b8..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ExecutorTest.kt +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Executor -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.testutils.common.Flaky -import com.ichi2.testutils.common.OS -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.hasSize -import org.junit.Test -import org.mockito.kotlin.mock -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit - -// TODO: Remove [MockExecutor] and now use a 'real' executor in tests - -/** Test for [Executor] */ -class ExecutorTest { - - /** the system under test: no initial operations */ - private val underTest = Executor(ArrayDeque()) - - /** execution context: allows access to the order of execution */ - private val executionContext = MockMigrationContext() - - /** - * pass in two elements to the [Executor]: they should be executed in the same order. - */ - @Test - fun `Regular operations are executed in order of addition`() { - val opOne = mock(name = "opOne") - val opTwo = mock(name = "opTwo") - - underTest.appendAll(listOf(opOne, opTwo)) - - underTest.execute(executionContext) - - assertThat("first operation should be executed first", executionContext.executed[0], equalTo(opOne)) - assertThat("second operation should be executed second", executionContext.executed[1], equalTo(opTwo)) - } - - @Test - fun `Execution succeeds with only preempted tasks`() { - val opOne = mock(name = "opOne") - val opTwo = mock(name = "opTwo") - - underTest.preempt(opOne) - underTest.preempt(opTwo) - - underTest.execute(executionContext) - - assertThat("first operation should be executed first", executionContext.executed[0], equalTo(opOne)) - assertThat("second operation should be executed second", executionContext.executed[1], equalTo(opTwo)) - } - - /** - * pass in one to the [Executor], then [prepend][Executor.prepend] one: the prepended should be executed first - */ - @Test - fun `Prepend adds an operation to the start of the list`() { - val opOne = mock(name = "opOne") - val opTwo = mock(name = "opTwo") - - underTest.append(opOne) - underTest.prepend(opTwo) - - underTest.execute(executionContext) - - assertThat("prepended operation should be executed first", executionContext.executed[0], equalTo(opTwo)) - assertThat("regular operation should be executed after prepended operation", executionContext.executed[1], equalTo(opOne)) - } - - /** - * Pass in two elements. While the first element is being executed, preempt an element - * The preempted element should be added before the second 'initial' element. - */ - @Test - @Flaky(os = OS.WINDOWS, "Index 2 out of bounds for length 2") - fun `A preempted element is executed before a regular element`() { - val opOne = BlockedOperation() - val opTwo = mock(name = "opTwo") - - val preemptedOp = mock(name = "preemptedOp") - - underTest.appendAll(listOf(opOne, opTwo)) - - // start executing (blocked on op 1) - executeInDifferentThreadThenWaitForCompletion { - opOne.isExecuting.acquireInTwoSeconds() - underTest.preempt(preemptedOp) - opOne.isBlocked.release() - } - - assertThat("Initial operation should be executed first", executionContext.executed[0], equalTo(opOne)) - assertThat("Preemption should take priority over next over normal operation", executionContext.executed[1], equalTo(preemptedOp)) - assertThat("All operations should be executed", executionContext.executed[2], equalTo(opTwo)) - } - - /** add two preempted operations: terminate after the first and ensure that only one is executed */ - @Test - fun `Termination does not continue executing preempted tasks`() { - val blockingOp = BlockedOperation() - val opTwo = mock(name = "opTwo") - - underTest.preempt(blockingOp) - underTest.preempt(opTwo) - - executeInDifferentThreadThenWaitForCompletion { - blockingOp.isExecuting.acquireInTwoSeconds() - underTest.terminate() - blockingOp.isBlocked.release() - } - - assertThat(executionContext.executed[0], equalTo(blockingOp)) - assertThat( - "a preempted operation is not run if terminate() is called", - executionContext.executed, - hasSize(1) - ) - } - - /** add two normal operations: terminate after the first and ensure that only one is executed */ - @Test - fun `Termination does not continue executing regular tasks`() { - val blockingOp = BlockedOperation() - val opTwo = mock(name = "opTwo") - - underTest.appendAll(listOf(blockingOp, opTwo)) - - executeInDifferentThreadThenWaitForCompletion { - blockingOp.isExecuting.acquireInTwoSeconds() - underTest.terminate() - blockingOp.isBlocked.release() - } - - assertThat(executionContext.executed[0], equalTo(blockingOp)) - assertThat( - "a regular operation is not run if terminate() is called", - executionContext.executed, - hasSize(1) - ) - } - - /** - * Executes the executor in one thread, and executes the provided lambda in the main thread - * Timeout: one second, either the operation is completed, or an exception is thrown - */ - private fun executeInDifferentThreadThenWaitForCompletion(f: (() -> Unit)) { - Thread { underTest.execute(executionContext) } - .apply { - start() - f() - join(ONE_SECOND) - } - } - - /** - * An operation which spins until [isBlocked] is set to false - */ - class BlockedOperation : Operation() { - // Semaphore that can be acquired once the operation is not blocked anymore - val isBlocked = Semaphore(1).apply { acquire() } - - // Semaphore that can be acquired after operation start executing - var isExecuting = Semaphore(1).apply { acquire() } - override fun execute(context: MigrationContext): List { - isExecuting.release() - isBlocked.acquireInTwoSeconds() - return emptyList() - } - } - - companion object { - private const val ONE_SECOND = 1000 * 1L - } -} - -private fun Semaphore.acquireInTwoSeconds() { this.tryAcquire(2, TimeUnit.SECONDS) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesIntegrationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesIntegrationTest.kt deleted file mode 100644 index 8af083cfc8a8..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesIntegrationTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.servicelayer.DestFolderOverride -import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.annotations.NeedsTest -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.shadows.ShadowStatFs -import java.io.File -import java.io.FileOutputStream -import kotlin.io.path.Path -import kotlin.io.path.pathString -import kotlin.test.assertFailsWith - -/** - * Test for [MigrateEssentialFiles] - */ -@RunWith(AndroidJUnit4::class) -@NeedsTest("fails when you can't create the destination directory") -class MigrateEssentialFilesIntegrationTest : RobolectricTest() { - /** - * A directory in scoped storage. - * Non existing in the file system, but with available space - */ - private lateinit var destinationPath: File - - override fun useInMemoryDatabase(): Boolean = false - - @Before - override fun setUp() { - super.setUp() - - setLegacyStorage() - - // we need to access 'col' before we start - col.basicCheck() - destinationPath = File(Path(targetContext.getExternalFilesDir(null)!!.canonicalPath, "AnkiDroid-1").pathString) - - // arbitrary large values - ShadowStatFs.registerStats(destinationPath, 100, 20, 10000) - } - - @After - override fun tearDown() { - super.tearDown() - ShadowStatFs.reset() - } - - @Test - fun migrate_essential_files_success() = runTest { - assertMigrationNotInProgress() - - val oldDeckPath = getPreferences().getString("deckPath", "") - - migrateEssentialFilesForTest(targetContext, File(col.path).parent!!, DestFolderOverride.Subfolder(destinationPath)) - - // assert the collection is open, working, and has been moved to the outPath - assertThat(col.basicCheck(), equalTo(true)) - assertThat(col.path, equalTo(File(destinationPath, "collection.anki2").canonicalPath)) - - assertMigrationInProgress() - - // assert that the preferences are updated - val prefs = getPreferences() - assertThat("The deck path is updated", prefs.getString("deckPath", ""), equalTo(destinationPath.canonicalPath)) - assertThat("The migration source is the original deck path", prefs.getString(ScopedStorageService.PREF_MIGRATION_SOURCE, ""), equalTo(oldDeckPath)) - assertThat("The migration destination is the deck path", prefs.getString(ScopedStorageService.PREF_MIGRATION_DESTINATION, ""), equalTo(destinationPath.canonicalPath)) - } - - @Test - fun exception_if_not_enough_free_space_migrate_essential_files() { - ShadowStatFs.reset() - - val ex = assertFailsWith { - ScopedStorageService.prepareAndValidateSourceAndDestinationFolders(targetContext) - } - - assertThat(ex.message, containsString("More free space is required")) - } - - @Test - fun exception_if_source_already_scoped() { - val ex = assertFailsWith { - ScopedStorageService.prepareAndValidateSourceAndDestinationFolders(targetContext, sourceOverride = destinationPath) - } - - assertThat(ex.message, containsString("Source directory is already under scoped storage")) - } - - @Test - fun no_exception_if_directory_is_empty_directory_migrate_essential_files() = runTest { - assertThat("destination should not exist ($destinationPath)", destinationPath.exists(), equalTo(false)) - - migrateEssentialFilesForTest( - targetContext, - File(col.path).parent!!, - DestFolderOverride.Subfolder(destinationPath) - ) - } - - @Test - fun fails_if_destination_is_not_empty() { - destinationPath.mkdirs() - assertThat("destination should exist ($destinationPath)", destinationPath.exists(), equalTo(true)) - - FileOutputStream(File(destinationPath, "hello.txt")).use { - it.write(1) - } - - val ex = assertFailsWith { - ScopedStorageService.prepareAndValidateSourceAndDestinationFolders(targetContext, destOverride = DestFolderOverride.Subfolder(destinationPath)) - } - - assertThat(ex.message, containsString("Target directory was not empty")) - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesTest.kt deleted file mode 100644 index 94c2a6a8fca3..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MigrateEssentialFilesTest.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import android.content.Context -import android.database.sqlite.SQLiteDatabaseCorruptException -import androidx.core.content.edit -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionManager -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.servicelayer.DestFolderOverride -import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.anki.servicelayer.ScopedStorageService.prepareAndValidateSourceAndDestinationFolders -import com.ichi2.anki.servicelayer.scopedstorage.MigrateEssentialFiles.UserActionRequiredException.MissingEssentialFileException -import com.ichi2.compat.CompatHelper -import com.ichi2.testutils.CollectionDBCorruption -import com.ichi2.testutils.createTransientDirectory -import net.ankiweb.rsdroid.BackendException -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.io.FileMatchers -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.shadows.ShadowStatFs -import java.io.File -import kotlin.io.path.Path -import kotlin.io.path.pathString -import kotlin.test.assertFailsWith - -const val DECK_PATH = "deckPath" - -/** - * Test for [MigrateEssentialFiles] - */ -@RunWith(AndroidJUnit4::class) -class MigrateEssentialFilesTest : RobolectricTest() { - - override fun useInMemoryDatabase(): Boolean = false - private lateinit var defaultCollectionSourcePath: String - - /** Whether to check the collection to ensure it's still openable */ - private var checkCollectionAfter = true - - @Before - override fun setUp() { - // had interference between two tests - CollectionManager.setColForTests(null) - super.setUp() - defaultCollectionSourcePath = getMigrationSourcePath() - // arbitrary large values - ShadowStatFs.registerStats(getMigrationDestinationPath(targetContext), 100, 20, 10000) - } - - @After - override fun tearDown() { - super.tearDown() - ShadowStatFs.reset() - if (checkCollectionAfter) { - assertThat("col is still valid", col.basicCheck()) - } - } - - @Test - fun successful_migration() = runTest { - assertMigrationNotInProgress() - - addNoteUsingBasicModel("Hello", "World") - - val collectionSourcePath = getMigrationSourcePath() - - val oldDeckPath = getPreferences().getString(DECK_PATH, "") - - val outPath = migrateEssentialFilesForTest(targetContext, collectionSourcePath) - - // assert the collection is open, working, and has been moved to the outPath - assertThat(col.basicCheck(), equalTo(true)) - assertThat(col.path, equalTo(File(outPath, "collection.anki2").canonicalPath)) - - assertMigrationInProgress() - - // assert that the preferences are updated - val prefs = getPreferences() - assertThat("The deck path should be updated", prefs.getString(DECK_PATH, ""), equalTo(outPath.canonicalPath)) - assertThat("The migration source should be the original deck path", prefs.getString(ScopedStorageService.PREF_MIGRATION_SOURCE, ""), equalTo(oldDeckPath)) - assertThat("The migration destination should be the deck path", prefs.getString(ScopedStorageService.PREF_MIGRATION_DESTINATION, ""), equalTo(outPath.canonicalPath)) - - assertThat(".nomedia should be copied", File(outPath.canonicalPath, ".nomedia"), FileMatchers.anExistingFile()) - - assertThat("The added card should still exists", col.cardCount(), equalTo(1)) - } - - @Test - fun exception_thrown_if_migration_is_started_while_in_process() { - getPreferences().edit { - putString(ScopedStorageService.PREF_MIGRATION_SOURCE, defaultCollectionSourcePath) - putString(ScopedStorageService.PREF_MIGRATION_DESTINATION, createTransientDirectory().path) - } - assertMigrationInProgress() - - val ex = assertFailsWith { - prepareAndValidateSourceAndDestinationFolders(targetContext) - } - - assertThat(ex.message, containsString("Migration is already in progress")) - } - - @Test - fun exception_thrown_if_destination_is_not_empty() { - val source = getMigrationSourcePath() - // This is not handled upstream as it's a logic error - the directory passed in should be created - val nonEmptyDestination = getMigrationDestinationPath(targetContext).also { - File(it, "tmp.txt").createNewFile() - } - - val exception = assertFailsWith { - prepareAndValidateSourceAndDestinationFolders(targetContext, sourceOverride = File(source), destOverride = DestFolderOverride.Subfolder(nonEmptyDestination), checkSourceDir = false) - } - assertThat(exception.message, containsString("not empty")) - } - - @Test - fun exception_thrown_if_database_corrupt() = runTest { - checkCollectionAfter = false - val collectionAnki2Path = CollectionDBCorruption.closeAndCorrupt() - - val collectionSourcePath = File(collectionAnki2Path).parent!! - - assertFailsWith { migrateEssentialFilesForTest(targetContext, collectionSourcePath) } - - assertMigrationNotInProgress() - } - - @Test - fun prefs_are_restored_if_reopening_fails() = runTest { - // after preferences are set, we make one final check with these new preferences - // if this check fails, we want to revert the changes to preferences that we made - val collectionSourcePath = getMigrationSourcePath() - - val prefKeys = listOf(ScopedStorageService.PREF_MIGRATION_SOURCE, ScopedStorageService.PREF_MIGRATION_DESTINATION, DECK_PATH) - val oldPrefValues = prefKeys - .associateWith { getPreferences().getString(it, null) } - - CollectionManager.emulateOpenFailure = true - assertFailsWith { - migrateEssentialFilesForTest(targetContext, collectionSourcePath) - } - CollectionManager.emulateOpenFailure = false - - oldPrefValues.forEach { - assertThat("Pref ${it.key} should be unchanged", getPreferences().getString(it.key, null), equalTo(it.value)) - } - - assertMigrationNotInProgress() - } - - @Test - fun fails_if_missing_essential_file() = runTest { - col.close() // required for Windows, can't delete if locked. - - CompatHelper.compat.deleteFile(File(defaultCollectionSourcePath, "collection.anki2")) - - val ex = assertFailsWith { - migrateEssentialFilesForTest(targetContext, defaultCollectionSourcePath) - } - - assertThat(ex.file.name, equalTo("collection.anki2")) - } - - private fun getMigrationSourcePath() = File(col.path).parent!! -} - -/** - * Executes the collection migration algorithm, moving from the local test directory /AnkiDroid, to /migration - * This is only the initial stage which does not delete data - */ -suspend fun migrateEssentialFilesForTest( - context: Context, - ankiDroidFolder: String, - destOverride: DestFolderOverride = DestFolderOverride.None, - checkSourceDir: Boolean = false -): File { - val destOverrideUpdated = when (destOverride) { - is DestFolderOverride.None -> DestFolderOverride.Root(getMigrationDestinationPath(context)) - else -> destOverride - } - val sourceFolder = File(ankiDroidFolder) - val folders = prepareAndValidateSourceAndDestinationFolders( - context, - sourceOverride = sourceFolder, - destOverride = destOverrideUpdated, - checkSourceDir = checkSourceDir - ) - CollectionManager.migrateEssentialFiles(context, folders) - return folders.scopedDestinationDirectory.directory -} - -private fun getMigrationDestinationPath(context: Context): File { - return File(Path(context.getExternalFilesDir(null)!!.canonicalPath, "AnkiDroid1").pathString).also { - it.mkdirs() - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockExecutor.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockExecutor.kt deleted file mode 100644 index 784477990e58..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockExecutor.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation - -/** - * Functionality: - * - * * Execution of recursive tasks (folder copying etc..) - * - * This is a mock as it's not tested, but will eventually be moved to a real class with a slightly different API - * - * @param operations A collection of operations to be executed, to allow direct access in tests - * @param contextSupplier A function providing context, used so [execute] only requires operations - */ -class MockExecutor( - val operations: ArrayDeque = ArrayDeque(), - val contextSupplier: (() -> MigrationContext) -) { - /** - * Executes one, or a number of [operations][Operation]. - * Each operation in [operations] is executed after all previous operations - * (and the operation they span) are completed. - */ - fun execute(vararg operations: Operation) { - this.operations.addAll(operations) - val context = contextSupplier() - while (this.operations.any()) { - context.execSafe(this.operations.removeFirst()) { - val replacements = it.execute(context) - this.operations.addAll(0, replacements) - } - } - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContext.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContext.kt deleted file mode 100644 index b517818cf189..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContext.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.NumberOfBytes - -open class MockMigrationContext : MigrationContext() { - /** set [logExceptions] to populate this property */ - val errors = mutableListOf() - val exceptions get() = errors.map { it.exception } - var logExceptions: Boolean = false - val progress = mutableListOf() - - /** A list of tasks which were passed into [execSafe] */ - val executed = mutableListOf() - - override fun reportError(throwingOperation: Operation, ex: Exception) { - if (!logExceptions) { - throw ex - } - errors.add(ReportedError(throwingOperation, ex)) - } - - override fun reportProgress(transferred: NumberOfBytes) { - progress.add(transferred) - } - - data class ReportedError(val operation: Operation, val exception: Exception) - - override fun execSafe( - operation: Operation, - op: (Operation) -> Unit - ) { - this.executed.add(operation) - super.execSafe(operation, op) - } -} - -/** - * A [MockMigrationContext] which will call [Operation.retryOperations] once on - * a failed operation, if any `retryOperations` are available. - * - * If a second failure occurs, or if no `retryOperations` are available, it will throw - */ -class RetryMigrationContext(val retry: (List) -> Unit) : MockMigrationContext() { - var retryCount = 0 - - override fun reportError(throwingOperation: Operation, ex: Exception) { - val opsForRetry = throwingOperation.retryOperations - if (!opsForRetry.any()) { - throw ex - } - retryCount++ - if (retryCount > 1) { - throw ex - } - retry(opsForRetry) - } - - override fun reportProgress(transferred: NumberOfBytes) { - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContextTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContextTest.kt deleted file mode 100644 index 4954ffac5f1c..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MockMigrationContextTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.testutils.TestException -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.junit.Test -import kotlin.test.assertFailsWith - -class MockMigrationContextTest { - @Test - fun retry_migration_context_test_retry() { - // if the lambda assigned to "retry" is called - var retryCalled = 0 - val context = RetryMigrationContext { retryCalled++ } - val throwIfUsed = OperationWhichThrowsIfUsed() - - fun reportError() = context.reportError(throwIfUsed, TestException("test ex")) - - reportError() // retryCount is 0 during test. It increments to 1 - assertFailsWith { reportError() } - - assertThat("retry should be called", retryCalled, equalTo(1)) - } - - class OperationWhichThrowsIfUsed : Operation() { - override fun execute(context: MigrationContext): List = - throw TestException("should not be called") - - override val retryOperations: List - get() = listOf(OperationWhichThrowsIfUsed()) - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFileTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFileTest.kt deleted file mode 100644 index 57374fa0e4f1..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveConflictedFileTest.kt +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * Copyright (c) 2022 Arthur Milchior - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.model.Directory -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.model.RelativeFilePath -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileConflictResolutionFailedException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.compat.Test21And26 -import com.ichi2.testutils.TestException -import com.ichi2.testutils.addTempFile -import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.createTransientFile -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.CoreMatchers.endsWith -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.instanceOf -import org.hamcrest.CoreMatchers.not -import org.hamcrest.CoreMatchers.startsWith -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.hasSize -import org.hamcrest.io.FileMatchers -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doThrow -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever -import java.io.File -import java.io.IOException -import kotlin.test.assertFailsWith - -class MoveConflictedFileTest : Test21And26(), OperationTest { - - override val executionContext: MockMigrationContext by lazy { - MockMigrationContext() - } - - /** - * Comprehensive test of the function to query candidate filenames given a "template" file - * - * @see [MoveConflictedFile.queryCandidateFilenames] - */ - @Test - fun test_queryCandidateFilenames() { - val testFile = createTransientFile() - - val sequence = MoveConflictedFile.queryCandidateFilenames(testFile).toList() - - assertThat("5 attempts should be made to find a valid file", sequence.size, equalTo(EXPECTED_ATTEMPTS)) - - // Test and production uses different method to extract the base name for extra test safety - val filename = testFile.name.substringBefore(".") - - assertThat("first element has no brackets", sequence.first().name, not(containsString("("))) - assertThat("second element has brackets", sequence[1].name, endsWith(" (1).tmp")) - assertThat("last element has brackets", sequence.last().name, equalTo("$filename (${EXPECTED_ATTEMPTS - 1}).tmp")) - - val final = sequence.last() - - assertThat("final file is in same directory as original file", final.parent!!, equalTo(testFile.parent)) - } - - @Test - fun creation_fails_if_path_starts_with_conflict() { - // the method adds /conflict/, so don't do this outside the function call - val params = InputParameters("conflict", sourceFileName = "tmp.txt") - - val illegalStateException = assertFailsWith { params.createOperation() } - - assertThat(illegalStateException.message, startsWith("can't move from a root path of 'conflict': ")) - } - - @Test - fun creation_prepends_conflict_to_path() { - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - val operation = params.createOperation() - - assertThat("provided 'sourceFile' parameter is unchanged", operation.sourceFile.file, equalTo(params.sourceFile)) - - // this is "path", but with a "conflict" subfolder. - assertThat("'conflict' is prepended to the path", operation.proposedDestinationFile, equalTo(params.intendedDestinationFilePath)) - } - - @Test - fun failing_to_create_directory_fails() { - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - // this will fail to create a directory, as we have a file named conflict. - File(params.destinationTopLevel, "conflict").apply { - createNewFile() - deleteOnExit() - } - - assertFailsWith { params.createOperation().execute() } - - assertThat("should be no progress", executionContext.progress, hasSize(0)) - } - - @Test - fun valid() { - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - assertThat("source file exists", params.sourceFile, FileMatchers.anExistingFile()) - val moveConflictedFile = params.createOperation() - moveConflictedFile.execute() - - assertThat("source file should be removed", params.sourceFile, not(FileMatchers.anExistingFile())) - assertThat("destination should exist", moveConflictedFile.proposedDestinationFile, FileMatchers.anExistingFile()) - - assertThat("1 instance of progress", executionContext.progress, hasSize(1)) - assertThat("1 instance of progress: 0 bytes", executionContext.progress.single(), equalTo(params.contentLength)) - } - - @Test - fun single_rename() { - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - params.intendedDestinationFilePath.apply { - parentFile!!.mkdirs() - createNewFile() - writeText("hello") // we can't have the contents be identical - } - - val moveConflictedFile = params.createOperation() - moveConflictedFile.execute() - - val expectedFile = File(moveConflictedFile.proposedDestinationFile.parentFile, "tmp (1).txt") - - assertThat("source file should be removed", params.sourceFile, not(FileMatchers.anExistingFile())) - assertThat("destination should exist with (1) in the name", expectedFile, FileMatchers.anExistingFile()) - - assertThat("1 instance of progress", executionContext.progress, hasSize(1)) - assertThat("1 instance of progress: 0 bytes", executionContext.progress.single(), equalTo(params.contentLength)) - - assertThat("1 instance of progress", executionContext.progress, hasSize(1)) - assertThat("1 instance of progress: 0 bytes", executionContext.progress.single(), equalTo(params.contentLength)) - } - - @Test - fun maxed_out_rename_fails() { - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - // use up all the paths - for (path in MoveConflictedFile.queryCandidateFilenames(params.intendedDestinationFilePath)) { - path.apply { - parentFile!!.mkdirs() - createNewFile() - writeText(path.nameWithoutExtension) // we can't have the contents be identical - } - } - - assertFailsWith { params.createOperation().execute() } - - assertThat("should be no progress", executionContext.progress, hasSize(0)) - } - - @Test - fun operation_failed_via_report() { - // arrange - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - var op = params.createOperation() - op = spy(op) { - doAnswer> { answer -> - val context = answer.arguments[1] as MigrationContext - context.reportError(this.mock, TestException("testing")) - emptyList() - }.whenever(it).moveFile(any(), any()) - } - - executionContext.logExceptions = true - - // act - op.execute() - - // assert - assertThat("source file should not be moved", params.sourceFile, FileMatchers.anExistingFile()) - assertThat("an error should be logged", executionContext.errors, hasSize(1)) - val error = executionContext.errors.single() - - assertThat("operation should be the wrapping operation", error.operation, instanceOf(MoveConflictedFile::class.java)) - assertThat("Exception should be thrown", error.exception, instanceOf(TestException::class.java)) - - assertThat("should be no progress", executionContext.progress, hasSize(0)) - } - - @Test - fun operation_failed_via_exception() { - // arrange - val params = InputParameters("collection.media", sourceFileName = "tmp.txt") - - var op = params.createOperation() - op = spy(op) { - doThrow(TestException("operation_failed_via_exception")).whenever(it).moveFile(any(), any()) - } - - executionContext.logExceptions = true - - // act - op.execute() - - // assert - assertThat("source file should not be moved", params.sourceFile, FileMatchers.anExistingFile()) - assertThat("an error should be logged", executionContext.errors, hasSize(1)) - val error = executionContext.errors.single() - - assertThat("operation should be the wrapping operation", error.operation, instanceOf(MoveConflictedFile::class.java)) - assertThat("Exception should be thrown", error.exception, instanceOf(TestException::class.java)) - - assertThat("should be no progress", executionContext.progress, hasSize(0)) - } - - /** - * Encapsulates the variables required to create a [MoveConflictedFile] instance for testing - * - * Generates a temporary directory for the source and destination, and sets up the file defined by - * [directoryComponents]/[sourceFileName] - * - * [createOperation] returns the operation to move this source file to [destinationTopLevel] - * - * @param directoryComponents components to the folder holding the source file ["collection.media"] - * @param sourceFileName The name of the source file: "file.ext" - * @param content The content of the source file - */ - private class InputParameters( - private vararg val directoryComponents: String, - val sourceFileName: String, - val content: String = "source content" - ) { - val sourceTopLevel: File by lazy { createTransientDirectory() } - val sourceFile: File by lazy { - var directory: File = sourceTopLevel - for (component in directoryComponents) { - directory = File(directory, component) - } - directory.mkdirs() - directory.addTempFile(sourceFileName, content) - } - val destinationTopLevel by lazy { createTransientDirectory() } - val destinationTopLevelDirectory get() = Directory.createInstance(destinationTopLevel)!! - val contentLength = content.length.toLong() - - /** A [RelativeFilePath] created from [directoryComponents] and [sourceFileName] */ - private val relativePath - get() = RelativeFilePath.Companion.fromPaths(sourceTopLevel, sourceFile)!! - - /** - * The intended destination of the file (assumed it did not have to be renamed due to conflict in the "conflict"'s subdirectory) - * - * File [destinationTopLevel]/"conflict"/[directoryComponents]/[sourceFileName] - */ - val intendedDestinationFilePath: File get() = - relativePath.unsafePrependDirectory("conflict").toFile(baseDir = destinationTopLevelDirectory) - - /** - * Operation to move conflicted file [sourceFileName] from [sourceTopLevel] to [destinationTopLevel]/"conflict"/[directoryComponents]/[sourceFileName]. - */ - fun createOperation(): MoveConflictedFile { - return MoveConflictedFile.createInstance( - sourceFile = DiskFile.createInstance(this.sourceFile)!!, - destinationTopLevel = destinationTopLevelDirectory, - sourceRelativePath = relativePath - ) - } - } - - companion object { - /** - * Equal to [MoveConflictedFile.MAX_DESTINATION_NAMES] - * Copied to tests to ensure unexpected changes cause test changes - */ - const val EXPECTED_ATTEMPTS = 5 - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContentTest.kt deleted file mode 100644 index 82afa5471dc3..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryContentTest.kt +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import android.annotation.SuppressLint -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.compat.Test21And26 -import com.ichi2.testutils.TestException -import com.ichi2.testutils.addTempFile -import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.createTransientFile -import com.ichi2.testutils.withTempFile -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.hasSize -import org.hamcrest.Matchers.instanceOf -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever -import java.io.File -import java.io.FileNotFoundException -import java.io.IOException -import java.nio.file.NotDirectoryException -import kotlin.test.assertFailsWith - -/** - * Test for [MoveDirectoryContent] - */ -class MoveDirectoryContentTest : Test21And26(), OperationTest { - - override val executionContext = MockMigrationContext() - - @Test - fun test_one_operation() { - val source = createTransientDirectory() - .withTempFile("foo.txt") - val destinationDirectory = createTransientDirectory() - - val moveOperation = moveDirectoryContent(source, destinationDirectory) - val result1 = moveOperation.execute() - assertThat("First result should have two operations", result1, hasSize(2)) - assertThat("First result's first operation should be MoveFileOrDirectory", result1[0], instanceOf(MoveFileOrDirectory::class.java)) - val moveFoo = result1[0] as MoveFileOrDirectory - assertThat("First result's second operation should have source foo.txt", moveFoo.sourceFile.name, equalTo("foo.txt")) - assertThat("First result's second operation should be move_operation", result1[1], equalTo(moveOperation)) - - val result2 = moveOperation.execute() - assertThat("Second result should have no operation", result2, hasSize(0)) - } - - @Test - fun test_success_integration_test_recursive() { - val source = createTransientDirectory().withTempFile("tmp.txt") - val moreFiles = source.createTransientDirectory("more files").withTempFile("tmp-2.txt") - val destinationDirectory = createTransientDirectory() - - executeAll(moveDirectoryContent(source, destinationDirectory)) - - assertThat("source directory should exist", source.exists(), equalTo(true)) - assertThat("destination directory should exist", destinationDirectory.exists(), equalTo(true)) - assertThat("tmp.txt should be deleted at source", File(source, "tmp.txt").exists(), equalTo(false)) - assertThat("tmp.txt should be copied", File(destinationDirectory, "tmp.txt").exists(), equalTo(true)) - - val subdirectory = File(destinationDirectory, "more files") - assertThat("'more file' should be deleted at source", moreFiles.exists(), equalTo(false)) - assertThat("subdir was copied", subdirectory.exists(), equalTo(true)) - assertThat("tmp-2.txt file was deleted at source", File(moreFiles, "tmp-2.txt").exists(), equalTo(false)) - assertThat("tmp-2.txt file was copied", File(subdirectory, "tmp-2.txt").exists(), equalTo(true)) - } - - @Test - fun a_move_failure_is_not_fatal() { - val source = createTransientDirectory() - .withTempFile("foo.txt") - .withTempFile("bar.txt") - .withTempFile("baz.txt") - - assertThat("foo should exists", File(source, "foo.txt").exists(), equalTo(true)) - val destinationDirectory = createTransientDirectory() - - // Use variables as we don't know which file will be returned in the middle from listFiles() - executionContext.logExceptions = true - val spyMoveDirectoryContent = OperationTest.SpyMoveDirectoryContent(moveDirectoryContent(source, destinationDirectory)) - val moveDirectoryContent = spyMoveDirectoryContent.spy - executeAll(moveDirectoryContent) - assertThat("Should have done three moves", spyMoveDirectoryContent.movesProcessed, equalTo(3)) - - assertThat(executionContext.exceptions, hasSize(1)) - executionContext.exceptions[0].run { - assertThat(this, instanceOf(TestException::class.java)) - } - - assertThat("source directory should not be deleted", source.exists(), equalTo(true)) - assertThat("fail (${spyMoveDirectoryContent.failedFile!!.absolutePath}) was not copied", spyMoveDirectoryContent.failedFile!!.exists(), equalTo(true)) - assertThat("file before failure was copied", spyMoveDirectoryContent.beforeFile!!.exists(), equalTo(false)) - assertThat("file after failure was copied", spyMoveDirectoryContent.afterFile!!.exists(), equalTo(false)) - } - - @Test - fun adding_file_during_move_is_not_fatal() { - adding_during_move_helper { - return@adding_during_move_helper it.addTempFile("new_file.txt", "new file") - } - } - - @Test - fun adding_directory_during_move_is_not_fatal() { - adding_during_move_helper { - val new_directory = File(it, "subdirectory") - assertThat("Subdirectory is created", new_directory.mkdir()) - new_directory.deleteOnExit() - return@adding_during_move_helper new_directory - } - } - - /** - * Test moving a directory with two files. [toDoBetweenTwoFilesMove] is executed before moving the second file and return a new file/directory it generated in source directly (not in a subdirectory). - * This new file/directory must be present in source or destination. - * - */ - fun adding_during_move_helper(toDoBetweenTwoFilesMove: (source: File) -> File) { - val source = createTransientDirectory() - .withTempFile("foo.txt") - .withTempFile("bar.txt") - - val destinationDirectory = generateDestinationDirectoryRef() - var new_file_name: String? = null - - executionContext.attemptRename = false - executionContext.logExceptions = true - var movesProcessed = 0 - val operation = spy(MoveDirectoryContent.createInstance(Directory.createInstance(source)!!, destinationDirectory)) { - doAnswer { op -> - val operation = op.callRealMethod() as Operation - if (movesProcessed++ == 1) { - return@doAnswer object : Operation() { - // Create a file in `source` and then execute the original operation. - // It ensures a file is added after some files where already copied. - override fun execute(context: MigrationContext): List { - new_file_name = toDoBetweenTwoFilesMove(source).name - return operation.execute() - } - } - } - return@doAnswer operation - }.whenever(it).toMoveOperation(any()) - } - executeAll(operation) - - assertThat( - "new_file should be present in source or directory", - File(source, new_file_name!!).exists() || File(destinationDirectory, new_file_name!!).exists() - ) - } - - /** - * Checking that `MoveDirectoryContent` don't delete the source directory. - * Deleting the source directory is the responsibility of `MoveDirectory` - */ - @Test - fun empty_directory_is_not_deleted() { - val source = createTransientDirectory() - val destinationDirectory = generateDestinationDirectoryRef() - - executeAll(moveDirectoryContent(source, destinationDirectory)) - - assertThat("source directory should not be deleted", source.exists(), equalTo(true)) - } - - @Test - fun factory_on_missing_directory_throw() { - val source = createTransientDirectory() - val sourceDirectory = Directory.createInstance(source)!! - val destinationDirectory = generateDestinationDirectoryRef() - source.delete() - assertFailsWith { moveDirectoryContent(sourceDirectory, destinationDirectory) } - } - - @SuppressLint("NewApi") // NotDirectoryException - @Test - fun factory_on_file_throw() { - val source_file = createTransientFile() - val dir = Directory.createInstanceUnsafe(source_file) - val destinationDirectory = generateDestinationDirectoryRef() - val ex = assertFailsWith { moveDirectoryContent(dir, destinationDirectory) } - if (isV26) { - assertThat("Starting at API 26, this should be a NotDirectoryException", ex, instanceOf(NotDirectoryException::class.java)) - } - } - - /** - * Reproduces https://github.com/ankidroid/Anki-Android/issues/10358 - * Where for some reason, `listFiles` returned null on an existing directory and - * newDirectoryStream returned `AccessDeniedException`. - */ - @Test - fun reproduce_10358() { - val permissionDenied = createPermissionDenied() - permissionDenied.assertThrowsWhenPermissionDenied { MoveDirectoryContent.createInstance(permissionDenied.directory, createTransientFile()) } - } - - private fun moveDirectoryContent(source: Directory, destinationDirectory: File): MoveDirectoryContent { - return MoveDirectoryContent.createInstance(source, destinationDirectory) - } - - private fun moveDirectoryContent(source: File, destinationDirectory: File): MoveDirectoryContent { - return moveDirectoryContent(Directory.createInstance(source)!!, destinationDirectory) - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryTest.kt deleted file mode 100644 index a2b32627d36a..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveDirectoryTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MoveDirectory -import com.ichi2.compat.Test21And26 -import com.ichi2.testutils.TestException -import com.ichi2.testutils.addTempFile -import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.exists -import com.ichi2.testutils.withTempFile -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.instanceOf -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.containsString -import org.hamcrest.Matchers.hasSize -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever -import java.io.File - -/** - * Test for [MoveDirectory] - */ -class MoveDirectoryTest : Test21And26(), OperationTest { - override lateinit var executionContext: MockMigrationContext - private val executor = MockExecutor { executionContext } - - @Before - fun setUp() { - executionContext = MockMigrationContext() - } - - @Test - fun test_success_integration_test_recursive() { - val source = createTransientDirectory().withTempFile("tmp.txt") - source.createTransientDirectory("more files").withTempFile("tmp-2.txt") - val destinationFile = generateDestinationDirectoryRef() - executionContext.attemptRename = false - - executeAll(moveDirectory(source, destinationFile)) - - assertThat("source directory should not exist", source.exists(), equalTo(false)) - assertThat("destination directory should exist", destinationFile.exists(), equalTo(true)) - assertThat("file should be copied", File(destinationFile, "tmp.txt").exists(), equalTo(true)) - - val subdirectory = File(destinationFile, "more files") - assertThat("subdir was copied", subdirectory.exists(), equalTo(true)) - assertThat("subdir file was copied", File(subdirectory, "tmp-2.txt").exists(), equalTo(true)) - } - - @Test - fun a_move_failure_is_not_fatal() { - val source = createTransientDirectory() - .withTempFile("foo.txt") - .withTempFile("bar.txt") - .withTempFile("baz.txt") - - val destinationDirectory = generateDestinationDirectoryRef() - - executionContext.attemptRename = false - executionContext.logExceptions = true - val moveDirectory = moveDirectory(source, destinationDirectory) - val subOperations = moveDirectory.execute() - val moveDirectoryContent = subOperations[0] as MoveDirectoryContent - val deleteDirectory = subOperations[1] - val spyMoveDirectoryContent = OperationTest.SpyMoveDirectoryContent(moveDirectoryContent) - val moveDirectoryContentSpied = spyMoveDirectoryContent.spy - executeAll(moveDirectoryContentSpied, deleteDirectory) - - assertThat(executionContext.exceptions, hasSize(2)) - executionContext.exceptions[0].run { - assertThat(this, instanceOf(TestException::class.java)) - } - executionContext.exceptions[1].run { - assertThat(this.message, containsString("directory was not empty")) - } - - assertThat("source directory should not be deleted", source.exists(), equalTo(true)) - assertThat("fail was not copied", spyMoveDirectoryContent.failedFile!!.exists(), equalTo(true)) - assertThat("file before failure was copied", spyMoveDirectoryContent.beforeFile!!.exists(), equalTo(false)) - assertThat("file after failure was copied", spyMoveDirectoryContent.afterFile!!.exists(), equalTo(false)) - } - - @Test - fun adding_file_during_move_is_not_fatal() { - val operation = adding_during_move_helper { - return@adding_during_move_helper it.addTempFile("new_file.txt", "new file") - } - - assertThat("source should not be deleted on retry", operation.source.exists(), equalTo(true)) - assertThat("additional file was not moved", File(operation.destination, "new_file.txt").exists(), equalTo(false)) - } - - @Test - fun adding_directory_during_move_is_not_fatal() { - val operation = adding_during_move_helper { - val new_directory = File(it, "subdirectory") - assertThat("Subdirectory is created", new_directory.mkdir()) - new_directory.deleteOnExit() - return@adding_during_move_helper new_directory - } - - assertThat("source should not be deleted on retry", operation.source.exists(), equalTo(true)) - assertThat("additional directory was not moved", File(operation.destination, "subdirectory").exists(), equalTo(false)) - } - - @Test - fun succeeds_on_retry_after_adding_file_during_process() { - executionContext = RetryMigrationContext { l -> executor.operations.addAll(0, l) } - - val operation = adding_during_move_helper { - return@adding_during_move_helper it.addTempFile("new_file.txt", "new file") - } - - assertThat("source should be deleted on retry", operation.source.exists(), equalTo(false)) - assertThat("additional file was moved", File(operation.destination, "new_file.txt").exists(), equalTo(true)) - } - - @Test - fun succeeds_on_retry_after_adding_directory_during_process() { - executionContext = RetryMigrationContext { l -> executor.operations.addAll(0, l) } - - val operation = adding_during_move_helper { - val newDirectory = File(it, "subdirectory") - assertThat("Subdirectory is created", newDirectory.mkdir()) - newDirectory.deleteOnExit() - return@adding_during_move_helper newDirectory - } - - assertThat("source should be deleted on retry", operation.source.exists(), equalTo(false)) - assertThat("additional directory was moved", File(operation.destination, "subdirectory").exists(), equalTo(true)) - } - - /** - * Test moving a directory with two files. [toDoBetweenTwoFilesMove] is executed before moving the second file and return a new file/directory it generated in source directly (not in a subdirectory). - * This new file/directory must be present in source or destination. - * - * @return The [MoveDirectory] which was executed - */ - fun adding_during_move_helper(toDoBetweenTwoFilesMove: (source: File) -> File): MoveDirectory { - val source = createTransientDirectory() - .withTempFile("foo.txt") - .withTempFile("bar.txt") - - val destinationDirectory = generateDestinationDirectoryRef() - var new_file_name: String? = null - - executionContext.attemptRename = false - executionContext.logExceptions = true - var movesProcessed = 0 - val moveDirectory = moveDirectory(source, destinationDirectory) - val subOperations = moveDirectory.execute() - val moveDirectoryContent = subOperations[0] as MoveDirectoryContent - val deleteDirectory = subOperations[1] - val moveDirectoryContentSpied = spy(moveDirectoryContent) { - doAnswer { op -> - val operation = op.callRealMethod() as Operation - if (movesProcessed++ == 1) { - return@doAnswer object : Operation() { - // Create a file in `source` and then execute the original operation. - // It ensures a file is added after some files where already copied. - override fun execute(context: MigrationContext): List { - new_file_name = toDoBetweenTwoFilesMove(source).name - return operation.execute() - } - } - } - return@doAnswer operation - }.whenever(it).toMoveOperation(any()) - } - - executor.execute(moveDirectoryContentSpied, deleteDirectory) - - assertThat( - "new_file should be present in source or directory", - File(source, new_file_name!!).exists() || File(destinationDirectory, new_file_name!!).exists() - ) - return moveDirectory - } - - @Test - fun empty_directory_is_deleted() { - val source = createTransientDirectory() - val destinationFile = generateDestinationDirectoryRef() - - executionContext.attemptRename = false - - executeAll(moveDirectory(source, destinationFile)) - - assertThat("source was deleted", source.exists(), equalTo(false)) - } - - @Test - fun empty_directory_is_deleted_rename() { - val source = createTransientDirectory() - val destinationFile = generateDestinationDirectoryRef() - - executionContext.attemptRename = true - - executeAll(moveDirectory(source, destinationFile)) - - assertThat("source was deleted", source.exists(), equalTo(false)) - } - - /** - * Reproduces https://github.com/ankidroid/Anki-Android/issues/10358 - * Where for some reason, `listFiles` returned null on an existing directory and - * newDirectoryStream returned `AccessDeniedException`. - */ - @Test - fun reproduce_10358() { - val sourceWithPermissionDenied = createPermissionDenied() - val destination = createTransientDirectory() - sourceWithPermissionDenied.assertThrowsWhenPermissionDenied { executeAll(moveDirectory(sourceWithPermissionDenied.directory.directory, destination)) } - } - - fun moveDirectory(source: File, destination: File): MoveDirectory { - return MoveDirectory(Directory.createInstance(source)!!, destination) - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectoryTest.kt deleted file mode 100644 index 66f46b85e167..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileOrDirectoryTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MoveDirectory -import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.createTransientFile -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.instanceOf -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.hasSize -import org.junit.Test -import java.io.File - -/** Tests for [MoveFileOrDirectory] */ -class MoveFileOrDirectoryTest : OperationTest { - - override val executionContext = MockMigrationContext() - private val destinationDir = createTransientDirectory() - - @Test - fun applied_to_file_returns_file() { - val file = createTransientFile() - val nextOperations = moveFromAndExecute(file) - assertThat("Only one operation should be next", nextOperations, hasSize(1)) - val nextOperation = nextOperations[0] - assertThat("A file as input should return a file operation", nextOperation, instanceOf(MoveFile::class.java)) - val moveFile = nextOperation as MoveFile - assertThat("Move file source should be file", moveFile.sourceFile.file, equalTo(file)) - assertThat("Destination file source should be file", moveFile.destinationFile, equalTo(File(destinationDir, file.name))) - } - - @Test - fun applied_to_directory_returns_directory() { - val directory = createTransientDirectory() - val nextOperations = moveFromAndExecute(directory) - assertThat("Only one operation should be next", nextOperations, hasSize(1)) - val nextOperation = nextOperations[0] - assertThat("A file as input should return a file operation", nextOperation, instanceOf(MoveDirectory::class.java)) - val moveDirectory = nextOperation as MoveDirectory - assertThat("Move file source should be file", moveDirectory.source.directory, equalTo(directory)) - assertThat("Destination file source should be file", moveDirectory.destination, equalTo(File(destinationDir, directory.name))) - } - - @Test - fun applied_to_deleted_file_returns_nothing() { - val file = createTransientFile() - file.delete() - val nextOperations = moveFromAndExecute(file) - assertThat("No operations should be next as file is deleted", nextOperations, hasSize(0)) - } - - private fun moveFromAndExecute(file: File) = MoveFileOrDirectory(file, File(destinationDir, file.name)).execute() -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileTest.kt deleted file mode 100644 index 27457f654398..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/MoveFileTest.kt +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.EquivalentFileException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.FileDirectoryConflictException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MissingDirectoryException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MissingDirectoryException.MissingFile -import com.ichi2.testutils.FileUtil -import com.ichi2.testutils.TestException -import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.length -import org.hamcrest.CoreMatchers.containsString -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.CoreMatchers.instanceOf -import org.hamcrest.CoreMatchers.not -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.hamcrest.Matchers.hasSize -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever -import org.robolectric.ParameterizedRobolectricTestRunner -import org.robolectric.ParameterizedRobolectricTestRunner.Parameters -import timber.log.Timber -import java.io.File -import kotlin.test.assertFailsWith - -@RunWith(ParameterizedRobolectricTestRunner::class) -class MoveFileTest(private val attemptRename: Boolean) : RobolectricTest(), OperationTest { - companion object { - @Suppress("unused") - @Parameters(name = "attemptRename = {0}") - @JvmStatic // required for initParameters - fun initParameters(): Collection> { - return listOf(arrayOf(true), arrayOf(false)) - } - } - - override val executionContext: MockMigrationContext = MockMigrationContext() - - @Test - fun move_file_is_success() { - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - val size = source.length() - - MoveFile(source, destinationFile) - .execute() - - assertThat("source file should no longer exist", source.file.exists(), equalTo(false)) - assertThat("destination file should exist", destinationFile.exists(), equalTo(true)) - - assertThat("content should be copied", getContent(destinationFile), equalTo("hello-world")) - assertProgressReported(size) - } - - @Test - fun move_file_twice_throws_no_exception() { - // For example: if an operation was preempted by a file transfer for the Reviewer - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - val size = source.length() - - MoveFile(source, destinationFile).execute() - MoveFile(source, destinationFile).execute() - - assertThat("source file should no longer exist", source.file.exists(), equalTo(false)) - assertThat("destination file should exist", destinationFile.exists(), equalTo(true)) - - assertThat("content should be copied", getContent(destinationFile), equalTo("hello-world")) - assertThat(executionContext.progress, equalTo(listOf(size, 0L))) - } - - @Test - fun if_copy_does_not_move_then_no_issues() { - if (attemptRename) return // not relevant - // if the move doesn't work, do not delete the source file - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - - executionContext.logExceptions = true - spy(MoveFile(source, destinationFile)) { - Mockito.doAnswer { Timber.w("testing: do nothing on copy") }.whenever(it).copyFile(any(), any()) - } - .execute() - - assertThat("copy should have failed, destination should not exist", destinationFile.exists(), equalTo(false)) - assertThat("source file should still exist", source.file.exists(), equalTo(true)) - - val exception = getSingleThrownException() - assertThat(exception.message, containsString("Failed to copy file to")) - } - - @Test - fun copy_exception_does_not_cause_issues() { - if (attemptRename) return // not relevant - // if the move doesn't work, do not delete the source file - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - - // this is correct - exception is not in a logged context - executionContext.logExceptions = true - val exception = assertFailsWith { - spy(MoveFile(source, destinationFile)) { - Mockito.doThrow(TestException("test-copyFile()")).whenever(it).copyFile(any(), any()) - } - .execute() - } - assertThat("copy should have failed, destination should not exist", destinationFile.exists(), equalTo(false)) - assertThat("source file should still exist", source.file.exists(), equalTo(true)) - assertThat("source content is unchanged", getContent(source.file), equalTo("hello-world")) - assertThat(exception.message, containsString("test-copyFile()")) - } - - @Test - fun no_op_if_both_files_deleted_but_directory_exists() { - // if the move doesn't work, do not delete the source file - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - // we make a `DiskFile` which doesn't exist - class is in a bad state - source.file.delete() - assertThat("destination should not exist", destinationFile.exists(), equalTo(false)) - assertThat("source file should not exist", source.file.exists(), equalTo(false)) - - MoveFile(source, destinationFile) - .execute() - - assertThat("no exceptions should have been reported", executionContext.exceptions, Matchers.emptyCollectionOf(Exception::class.java)) - assertThat("empty progress should have been reported", executionContext.progress.single(), equalTo(0L)) - } - - @Test - fun source_is_deleted_if_both_files_are_the_same() { - // This can happen if a power off occurs between copying the file, and cleaning up the source - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - source.file.copyTo(destinationFile, overwrite = false) - - val size = source.length() - - assertThat("destination should exist", destinationFile.exists(), equalTo(true)) - assertThat("source file should exist", source.file.exists(), equalTo(true)) - - MoveFile(source, destinationFile) - .execute() - - assertThat("source file should be deleted", source.file.exists(), equalTo(false)) - assertThat("destination file should not be deleted", destinationFile.exists(), equalTo(true)) - assertThat("progress was reported", executionContext.progress.single(), equalTo(size)) - } - - @Test - fun if_both_files_same_and_delete_throws_exception() { - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - source.file.copyTo(destinationFile, overwrite = false) - - executionContext.logExceptions = true - spy(MoveFile(source, destinationFile)) { - Mockito.doThrow(TestException("test-deleteFile()")).whenever(it).deleteFile(any()) - } - .execute() - - assertThat("source should still exist", source.file.exists(), equalTo(true)) - assertThat("destination should still exist", destinationFile.exists(), equalTo(true)) - - val ex = executionContext.exceptions.single() - assertThat(ex.message, containsString("test-deleteFile()")) - assertThat("no progress - file was not deleted", executionContext.progress, hasSize(0)) - } - - @Test - fun error_if_copied_file_already_exists_and_is_different() { - // This can happen if someone adds a file in the new directory before the old directory - // is copied - val source = addUntrackedMediaFile("hello-oo", listOf("hello.txt")) - val destinationFile = addUntrackedMediaFile("world", listOf("world.txt")).file - - executionContext.logExceptions = true - MoveFile(source, destinationFile) - .execute() - - val conflictException = getSingleExceptionOfType() - assertThat("source is correct", conflictException.source.file.canonicalPath, equalTo(source.file.canonicalPath)) - assertThat("destination is correct", conflictException.destination.file.canonicalPath, equalTo(destinationFile.canonicalPath)) - - assertThat("source content is unchanged", getContent(source.file), equalTo("hello-oo")) - assertThat("destination content is unchanged", getContent(destinationFile), equalTo("world")) - } - - @Test - fun succeeds_if_copied_file_already_exists_but_is_zero_length() { - // part of #13170: This can occur if the phone is turned off before the copy completes - val source = addUntrackedMediaFile("hello-oo", listOf("hello.txt")) - val destinationFile = addUntrackedMediaFile("", listOf("world.txt")).file - - val sizeToTransfer = source.length() - assertThat("The file to transfer should exist", sizeToTransfer, not(equalTo(0))) - assertThat("destination file should be zero length", destinationFile.length(), equalTo(0)) - - MoveFile(source, destinationFile) - .execute() - - assertThat("source file should be deleted", source.file.exists(), equalTo(false)) - assertThat("destination file should not be deleted", destinationFile.exists(), equalTo(true)) - assertThat("progress was reported", executionContext.progress.single(), equalTo(sizeToTransfer)) - assertThat("file content is transferred", getContent(destinationFile), equalTo("hello-oo")) - } - - @Test - fun error_if_source_and_destination_are_same() { - val source = addUntrackedMediaFile("hello-oo", listOf("hello.txt")) - val destinationFile = source.file - - val ex = assertFailsWith { - MoveFile(source, destinationFile) - .execute() - } - - assertThat("source still exists", source.file.exists()) - assertThat(ex.message, containsString("Source and destination path are the same")) - } - - @Test - fun error_if_both_files_do_not_exist_but_no_directory() { - val sourceDirectoryToDelete = createTransientDirectory("toDelete") - val destinationDirectoryToDelete = createTransientDirectory("toDelete") - val sourceNotExist = DiskFile.createInstanceUnsafe(File(sourceDirectoryToDelete, "deletedDirectory-in.txt")) - val destinationFileNotExist = File(destinationDirectoryToDelete, "deletedDirectory-out.txt") - assertThat( - "deletion should work", - sourceDirectoryToDelete.delete() && destinationDirectoryToDelete.delete(), - equalTo(true) - ) - - val exception = assertFailsWith { - MoveFile(sourceNotExist, destinationFileNotExist) - .execute() - } - - assertThat("2 missing directories expected", exception.directories, hasSize(2)) - assertThat("source was logged", exception.directories[0], equalTo(MissingFile("source - parent dir", sourceDirectoryToDelete))) - assertThat("destination was logged", exception.directories[1], equalTo(MissingFile("destination - parent dir", destinationDirectoryToDelete))) - } - - @Test - fun duplicate_file_is_cleaned_up_on_rerun() { - if (attemptRename) return // not relevant - // if a crash/"delete" fails, we want to ensure the duplicate file is cleaned up - val source = addUntrackedMediaFile("hello-world", listOf("hello.txt")) - val destinationFile = File(ankiDroidDirectory(), "hello.txt") - val size = source.length() - - executionContext.logExceptions = true - assertFailsWith { - spy(MoveFile(source, destinationFile)) { - Mockito.doThrow(TestException("test-deleteFile()")).whenever(it).deleteFile(any()) - } - .execute() - } - - Timber.d("delete should have failed") - assertThat("source exists", source.file.exists(), equalTo(true)) - assertThat("destination exists", destinationFile.exists(), equalTo(true)) - - MoveFile(source, destinationFile) - .execute() - - // delete now works, BUT the file was already copied - - assertThat("source file should no longer exist", source.file.exists(), equalTo(false)) - assertThat("destination file should exist", destinationFile.exists(), equalTo(true)) - - assertThat("content should be copied", getContent(destinationFile), equalTo("hello-world")) - assertProgressReported(size) - } - - @Test - fun move_file_to_dir_fail() { - val source = addUntrackedMediaFile("hello", listOf("hello.txt")) - val destination = createTransientDirectory() - executionContext.logExceptions = true - MoveFile(source, destination).execute() - - assertThat("An exception should be logged", executionContext.exceptions, hasSize(1)) - val exception = executionContext.exceptions[0] - assertThat("An exception should be of the correct type", exception, instanceOf(FileDirectoryConflictException::class.java)) - - assertThat("source file should still exist", source.file.exists(), equalTo(true)) - assertThat("destination file should exist", destination.exists(), equalTo(true)) - assertThat("content should not have changed", getContent(source.file), equalTo("hello")) - } - - /** Asserts that 1 element of progress of the provided size was reported */ - private fun assertProgressReported(expectedSize: Long) { - val progress = executionContext.progress - assertThat("only one progress report expected", progress.size, equalTo(1)) - assertThat("unexpected progress", progress.single(), equalTo(expectedSize)) - } - - private fun MoveFile.execute() { - executionContext.attemptRename = attemptRename - val result = this.execute(executionContext) - assertThat("No operation left after a move file", result, hasSize(0)) - } - - private fun getContent(destinationFile: File) = FileUtil.readSingleLine(destinationFile) - - private fun getSingleThrownException(): Exception { - val exceptions = executionContext.exceptions - assertThat("a single exception should be thrown", exceptions, hasSize(1)) - return exceptions.single() - } - - private inline fun getSingleExceptionOfType(): T { - val exception = getSingleThrownException() - assertThat(exception, instanceOf(FileConflictException::class.java)) - return exception as T - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/OperationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/OperationTest.kt deleted file mode 100644 index 5e3148c16f4c..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/OperationTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * Copyright (c) 2022 Arthur Milchior - * - * 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.servicelayer.scopedstorage - -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.compat.CompatHelper -import com.ichi2.testutils.TestException -import com.ichi2.testutils.createTransientDirectory -import org.mockito.invocation.InvocationOnMock -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.spy -import org.mockito.kotlin.whenever -import timber.log.Timber -import java.io.File - -interface OperationTest { - val executionContext: MockMigrationContext - - /** Helper function: executes an [Operation] and all sub-operations */ - fun executeAll(vararg ops: Operation) = - MockExecutor(ArrayDeque(ops.toList())) { executionContext } - .execute() - - /** - * Executes an [Operation] without executing the sub-operations - * @return the sub-operations returned from the execution of the operation - */ - - fun Operation.execute(): List = this.execute(executionContext) - - /** Creates an empty TMP directory to place the output files in */ - fun generateDestinationDirectoryRef(): File { - val createDirectory = createTransientDirectory() - Timber.d("test: deleting $createDirectory") - CompatHelper.compat.deleteFile(createDirectory) - return createDirectory - } - - /** - * Allow to get a MoveDirectoryContent that works for at most three files and fail on the second one. - * It keeps track of which files is the failed one and which are before and after; since directory can list its file in - * any order, it ensure that we know which file failed. It also allows to test that move still occurs after a failure. - */ - class SpyMoveDirectoryContent(private val moveDirectoryContent: MoveDirectoryContent) { - /** - * The first file moved, before the failed file. Null if no moved occurred. - */ - var beforeFile: File? = null - private set - - /** - * The second file, it moves fails. Null if no moved occurred. - */ - var failedFile: File? = null // ensure the second file fails - private set - - /** - * The last file moved, after the failed file. Null if no moved occurred. - */ - var afterFile: File? = null - private set - var movesProcessed = 0 - private set - - fun toMoveOperation(op: InvocationOnMock): Operation { - val sourceFile = op.arguments[0] as File - when (movesProcessed++) { - 0 -> beforeFile = sourceFile - 1 -> { - failedFile = sourceFile - return FailMove() - } - 2 -> afterFile = sourceFile - else -> throw IllegalStateException("only 3 files expected") - } - return op.callRealMethod() as Operation - } - - /** - * The [MoveDirectoryContent] that performs the action mentioned in the class description. - */ - val spy: MoveDirectoryContent - get() = spy(moveDirectoryContent) { moveDirectoryContent -> - doAnswer { toMoveOperation(it) }.whenever(moveDirectoryContent).toMoveOperation(any()) - } - } -} - -/** A move operation which fails */ -class FailMove : Operation() { - override fun execute(context: MigrationContext): List { - throw TestException("should fail but not crash") - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ScopedStorageMigrationIntegrationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ScopedStorageMigrationIntegrationTest.kt deleted file mode 100644 index d4e3a0c42339..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/ScopedStorageMigrationIntegrationTest.kt +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.CollectionManager -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.model.Directory -import com.ichi2.anki.servicelayer.DestFolderOverride -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.DirectoryNotEmptyException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Executor -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MigrationContext -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Operation -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.SingleRetryDecorator -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrationProgressListener -import com.ichi2.anki.utils.AggregateException -import com.ichi2.testutils.ShadowStatFs -import com.ichi2.testutils.TestException -import com.ichi2.testutils.addTempDirectory -import com.ichi2.testutils.addTempFile -import com.ichi2.testutils.createTransientDirectory -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.anyOf -import org.hamcrest.Matchers.endsWith -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.instanceOf -import org.hamcrest.Matchers.not -import org.hamcrest.io.FileMatchers.anExistingDirectory -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import timber.log.Timber -import java.io.File -import kotlin.io.path.Path -import kotlin.io.path.pathString -import kotlin.test.assertFailsWith -import kotlin.test.fail - -// PERF: Some of these do not need a collection -/** Test for [MigrateUserData.migrateFiles] */ -@RunWith(AndroidJUnit4::class) -class ScopedStorageMigrationIntegrationTest : RobolectricTest() { - - private lateinit var underTest: MigrateUserDataTester - private val validDestination = File(Path(targetContext.getExternalFilesDir(null)!!.canonicalPath, "AnkiDroid-1").pathString) - - override fun useInMemoryDatabase() = false - - @After - override fun tearDown() { - try { - super.tearDown() - } finally { - ShadowStatFs.reset() - } - } - - @Test - fun `Valid migration`() = runTest { - setLegacyStorage() - - underTest = MigrateUserDataTester.create() - - // use all the real components on a real collection. - val inputDirectory = File(col.path).parentFile!! - File(inputDirectory, "collection.media").addTempFile("image.jpg", "foo") - - ShadowStatFs.markAsNonEmpty(validDestination) - ShadowStatFs.markAsNonEmpty(inputDirectory) - - // migrate the essential files - migrateEssentialFilesForTest(targetContext, inputDirectory.path, DestFolderOverride.Subfolder(validDestination)) - - underTest = MigrateUserDataTester.create(inputDirectory, validDestination) - val result = underTest.execTask() - - assertThat("execution of user data should succeed", result, equalTo(true)) - - // close collection again so -wal doesn't end up in the list - CollectionManager.ensureClosed() - - // 2 files remain: [collection.anki2, .nomedia] - underTest.integrationAssertOnlyIntendedFilesRemain() - assertThat(underTest.migratedFilesCount, equalTo(underTest.filesToMigrateCount)) - - assertThat( - "a number of files should remain to allow the user to restore their collection", - fileCount(inputDirectory), - equalTo(MigrateUserDataTester.INTEGRATION_INTENDED_REMAINING_FILE_COUNT) - ) - } - - @Test - fun `Migration without space fails`() = runTest { - setLegacyStorage() - // use all the real components on a real collection. - val inputDirectory = File(col.path).parentFile!! - File(inputDirectory, "collection.media").addTempFile("image.jpg", "foo") - File(inputDirectory, "collection.media").addTempFile("image2.jpg", "bar") - - ShadowStatFs.markAsNonEmpty(validDestination) - ShadowStatFs.markAsNonEmpty(inputDirectory) - - // migrate the essential files - migrateEssentialFilesForTest(targetContext, inputDirectory.path, DestFolderOverride.Root(validDestination)) - - underTest = MigrateUserDataTester.create(inputDirectory, validDestination) - underTest.executor = object : Executor(ArrayDeque()) { - override fun executeOperationInternal(it: Operation, context: MigrationContext): List { - if (it is MoveFile) { - context.reportError(it, TestException("no space left on disk")) - return emptyList() - } - return super.executeOperationInternal(it, context) - } - } - - val aggregatedException = assertFailsWith { underTest.execTask() } - - val testExceptions = aggregatedException.causes.filter { it !is DirectoryNotEmptyException } - - assertThat("two failed files means two exceptions", testExceptions.size, equalTo(2)) - - assertThat(testExceptions[0], instanceOf(TestException::class.java)) - assertThat(testExceptions[1], instanceOf(TestException::class.java)) - } - - @Test - fun `Empty migration passes`() { - underTest = MigrateUserDataTester.create(createTransientDirectory(), createTransientDirectory()) - - val result = underTest.execTask() - - assertThat("migrating empty folder should succeed", result, equalTo(true)) - } - - /** - * Introduce a conflicted file - * * it's moved to /conflict/ - * * the process succeeds - */ - @Test - fun `Migration with conflict is moved`() { - underTest = MigrateUserDataTester.create() - underTest.destination.directory.addTempFile("maybeConflicted.log", "bar") - - val result = underTest.execTask() - - assertThat("all files should be in the destination", underTest.migratedFilesCount, equalTo(underTest.filesToMigrateCount)) - assertThat("one file is conflicted", underTest.conflictedFilesCount, equalTo(1)) - assertThat("expect to have conflict/maybeConflicted.log in source (file & folder)", underTest.sourceFilesCount, equalTo(2)) - assertThat(underTest.conflictedFilePaths.single(), anyOf(endsWith("/conflict/maybeConflicted.log"), endsWith("\\conflict\\maybeConflicted.log"))) - - assertThat("even with a conflict, the operation should succeed", result, equalTo(true)) - } - - @Test - fun `Migration with file added is internally retried`() { - underTest = MigrateUserDataTester.create() - val executorWithNonEmpty = object : Executor(ArrayDeque()) { - var called = false - override fun executeOperationInternal(it: Operation, context: MigrationContext): List { - Timber.i("%s", it::class.java.name) - val inner = innerOperation(it) - if (!called && inner is DeleteEmptyDirectory && inner.directory.directory.name == "collection.media") { - called = true - context.reportError( - it, - DirectoryNotEmptyException(inner.directory) - ) - return emptyList() - } - return super.executeOperationInternal(it, context) - } - - fun innerOperation(op: Operation): Operation = (op as? SingleRetryDecorator)?.standardOperation ?: op - } - underTest.executor = executorWithNonEmpty - - underTest.execTask() - - assertThat("test exception should be raised", executorWithNonEmpty.called, equalTo(true)) - - assertThat( - "collection media should be deleted on retry if empty", - File(underTest.source.directory, "collection.media"), - not(anExistingDirectory()) - ) - - assertThat("no external retries should be made", underTest.externalRetries, equalTo(0)) - } - - @Test - fun `Migration with temporary problem is externally retried`() { - underTest = MigrateUserDataTester.create() - // Define an 'out of space' error, and the 'retry' will solve this - val executorWithNonEmpty = object : Executor(ArrayDeque()) { - val shouldFail get() = underTest.externalRetries == 0 - override fun executeOperationInternal(it: Operation, context: MigrationContext): List { - if (shouldFail) { - context.reportError(it, TestException("testing")) - return emptyList() - } - return super.executeOperationInternal(it, context) - } - } - underTest.executor = executorWithNonEmpty - - val result = underTest.execTask() - - assertThat("operation should succeed", result, equalTo(true)) - assertThat("an external retry occurred", underTest.externalRetries, equalTo(1)) - } - - private fun MigrateUserDataTester.execTask(): Boolean { - this.migrateFiles(mock()) - - // TODO BEFORE-RELEASE This method always returns true, as before this change - // it returned the result of `migrateFiles`, which was also always true. - // Figure out why and apply the necessary changes. - return true - } -} - -/** - * @param filesToMigrateCount The number of files which should be migrated - */ -private class MigrateUserDataTester -private constructor(source: Directory, destination: Directory, val filesToMigrateCount: Int) : - MigrateUserData(source, destination) { - - override fun initializeContext(progress: MigrationProgressListener): UserDataMigrationContext { - return super.initializeContext(progress).apply { - attemptRename = false - } - } - - fun integrationAssertOnlyIntendedFilesRemain() { - if (sourceFilesCount == INTEGRATION_INTENDED_REMAINING_FILE_COUNT) { - return - } - fail("expected directory with $INTEGRATION_INTENDED_REMAINING_FILE_COUNT files, got: " + source.directory.listFiles()!!.map { it.name }) - } - - private val conflictDirectory = File(source.directory, "conflict") - - /** The number of files in [destination] */ - val migratedFilesCount: Int get() = fileCount(destination.directory) - - /** The number of files in [source] */ - val sourceFilesCount: Int get() = fileCount(source.directory) - - /** The number of files in the "conflict" directory */ - val conflictedFilesCount: Int get() { - if (!conflictDirectory.exists()) { - return 0 - } - return fileCount(conflictDirectory) - } - - /** - * Lists the files in the TOP LEVEL directory of /conflict/ - * Throws if [conflictDirectory] does not exist - */ - val conflictedFilePaths: List get() { - check(conflictDirectory.exists()) { "$conflictDirectory should exist" } - return conflictDirectory.listFiles()!!.map { it.path } - } - - companion object { - // media DB created on demand, and no -journal file in new backend - // collection.log no longer exists - const val INTEGRATION_INTENDED_REMAINING_FILE_COUNT: Int = 2 - - /** - * A MigrateUserDataTest from inputSource to inputDestination (or transient directories if not provided) - * - * If [inputSource] is null, it is created and with the following contents: - * * ./foo.txt`, `./bar.txt` - * * `maybeConflicted.log` - * *`./collection.media/` - * * `.collection.media/image.jpg` - * - * i.e. 5 files files or directories that are not part of AnkiDroid's essential files. - */ - fun create(inputSource: File? = null, inputDestination: File? = null): MigrateUserDataTester { - val destination = inputDestination ?: createTransientDirectory("destination") - - val source = inputSource ?: createTransientDirectory("source").apply { - addTempFile("foo.txt", "foo") - addTempFile("bar.txt", "bar") - addTempFile("maybeConflicted.log", "maybeConflicted") - val media = addTempDirectory("collection.media") - media.directory.addTempFile("image.jpg", "image") - } - - return MigrateUserDataTester( - source = Directory.createInstance(source)!!, - destination = Directory.createInstance(destination)!!, - filesToMigrateCount = fileCount(source) - ).also { - assertThat("Conflict directory should not exist before the migration starts", it.conflictedFilesCount, equalTo(0)) - } - } - } -} - -/** - * Return the number of files and directories in [directory] or in one of its subdirectories; not counting [directory] itself. - * - * Assumes no symbolic links. - */ -private fun fileCount(directory: File): Int { - check(directory.exists()) { "$directory must exist" } - check(directory.isDirectory) { "$directory must be a directory" } - - val files = directory.listFiles() - return files!!.sumOf { - if (it.isFile) { - return@sumOf 1 - } else { - return@sumOf fileCount(it) + 1 - } - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/Utils.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/Utils.kt deleted file mode 100644 index 285001c77618..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/Utils.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage - -import androidx.annotation.CheckResult -import androidx.core.content.edit -import com.ichi2.anki.CollectionHelper -import com.ichi2.anki.RobolectricTest -import com.ichi2.anki.model.DiskFile -import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.libanki.Media -import org.acra.util.IOUtils -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import java.io.File - -/** Adds a media file to collection.media which [Media] is not aware of */ -@CheckResult -internal fun addUntrackedMediaFile(media: Media, content: String, path: List): DiskFile { - val file = convertPathToMediaFile(media, path) - File(file.parent!!).mkdirs() - IOUtils.writeStringToFile(file, content) - return DiskFile.createInstance(file)!! -} - -private fun convertPathToMediaFile(media: Media, path: List): File { - val mutablePath = ArrayDeque(path) - var file = File(media.dir) - while (mutablePath.any()) { - file = File(file, mutablePath.removeFirst()) - } - return file -} - -/** A [File] reference to the AnkiDroid directory of the current collection */ -internal fun RobolectricTest.ankiDroidDirectory() = File(col.path).parentFile!! - -internal fun RobolectricTest.setLegacyStorage() { - getPreferences().edit { putString(CollectionHelper.PREF_COLLECTION_PATH, CollectionHelper.legacyAnkiDroidDirectory) } -} - -/** Adds a file to collection.media which [Media] is not aware of */ -@CheckResult -internal fun RobolectricTest.addUntrackedMediaFile(content: String, path: List): DiskFile = - addUntrackedMediaFile(col.media, content, path) - -fun RobolectricTest.assertMigrationInProgress() { - assertThat("the migration should be in progress", ScopedStorageService.mediaMigrationIsInProgress(this.targetContext), equalTo(true)) -} - -fun RobolectricTest.assertMigrationNotInProgress() { - assertThat("the migration should not be in progress", ScopedStorageService.mediaMigrationIsInProgress(this.targetContext), equalTo(false)) -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserDataJvmTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserDataJvmTest.kt deleted file mode 100644 index 122281bf5027..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicelayer/scopedstorage/migrateuserdata/MigrateUserDataJvmTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) 2022 David Allison - * - * 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.servicelayer.scopedstorage.migrateuserdata - -import android.content.SharedPreferences -import com.ichi2.anki.servicelayer.ScopedStorageService -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.Companion.createInstance -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserData.MissingDirectoryException -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserDataJvmTest.SourceType.MISSING_DIR -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserDataJvmTest.SourceType.NOT_SET -import com.ichi2.anki.servicelayer.scopedstorage.migrateuserdata.MigrateUserDataJvmTest.SourceType.VALID_DIR -import com.ichi2.testutils.createTransientDirectory -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.junit.BeforeClass -import org.junit.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import kotlin.test.assertFailsWith - -/** - * A test for [MigrateUserData] which does not require Robolectric - */ -class MigrateUserDataJvmTest { - - companion object { - private lateinit var sourceDir: String - private lateinit var destDir: String - private lateinit var missingDir: String - - @BeforeClass - @JvmStatic // required for @BeforeClass - fun initClass() { - sourceDir = createTransientDirectory().canonicalPath - destDir = createTransientDirectory().canonicalPath - missingDir = createTransientDirectory().also { it.delete() }.canonicalPath - } - } - - @Test - fun valid_instance_if_directories_exist() { - val preferences = getScopedStorageMigrationPreferences(source = VALID_DIR, destination = VALID_DIR) - val data = createInstance(preferences) - - assertThat(data.source.directory.canonicalPath, equalTo(sourceDir)) - assertThat(data.destination.directory.canonicalPath, equalTo(destDir)) - } - - @Test - fun no_instance_if_not_migrating() { - val preferences = getScopedStorageMigrationPreferences(source = NOT_SET, destination = NOT_SET) - val exception = assertFailsWith { createInstance(preferences) } - - assertThat(exception.message, equalTo("Migration is not in progress")) - } - - @Test - fun error_if_settings_are_bad() { - val preferences = getScopedStorageMigrationPreferences(source = NOT_SET, destination = VALID_DIR) - val exception = assertFailsWith { createInstance(preferences) } - - assertThat(exception.message, equalTo("Expected either all or no migration directories set. 'migrationSourcePath': ''; 'migrationDestinationPath': '$destDir'")) - } - - @Test - fun error_if_source_does_not_exist() { - val preferences = getScopedStorageMigrationPreferences(source = MISSING_DIR, destination = VALID_DIR) - val exception = assertFailsWith { createInstance(preferences) } - assertThat(exception.directories.single().file.canonicalPath, equalTo(missingDir)) - } - - @Test - fun error_if_destination_does_not_exist() { - val preferences = getScopedStorageMigrationPreferences(source = VALID_DIR, destination = MISSING_DIR) - val exception = assertFailsWith { createInstance(preferences) } - assertThat(exception.directories.single().file.canonicalPath, equalTo(missingDir)) - } - - private fun getScopedStorageMigrationPreferences(source: SourceType, destination: SourceType): SharedPreferences { - return mock { - on { getString(ScopedStorageService.PREF_MIGRATION_SOURCE, "") } doReturn - when (source) { - VALID_DIR -> sourceDir - MISSING_DIR -> missingDir - NOT_SET -> "" - } - on { getString(ScopedStorageService.PREF_MIGRATION_DESTINATION, "") } doReturn - when (destination) { - VALID_DIR -> destDir - MISSING_DIR -> missingDir - NOT_SET -> "" - } - } - } - - enum class SourceType { - NOT_SET, - MISSING_DIR, - VALID_DIR - } -}