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/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index 6d4dd7c82b11..eca1bb1667e3 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -483,10 +483,6 @@
-
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
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..daff06099322 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
@@ -295,19 +290,8 @@
Make field %s sticky
-
- Storage migration
- Learn More
- Postpone
- Migrate
-
Search returned no results
- AnkiDroid needs to migrate its storage to comply with Android 10 security policy.\n\nThis will improve media sync performance.<br><br>During the migration you will be unable to sync and some settings will be disabled. A media sync will occur before the migration starts to keep your data safe.<br><br>It is safe to use or close AnkiDroid during this process.
- Starting storage migration. You may resume using AnkiDroid shortly.
- You may resume using AnkiDroid.
-\nStorage migration will continue in the background.
-
%s is not a valid JavaScript addon package
Could not create directory %s
@@ -349,16 +333,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/main/res/values/constants.xml b/AnkiDroid/src/main/res/values/constants.xml
index 483d8263c0b0..894819f37db9 100644
--- a/AnkiDroid/src/main/res/values/constants.xml
+++ b/AnkiDroid/src/main/res/values/constants.xml
@@ -153,9 +153,7 @@
https://github.com/ankidroid/Anki-Android/wiki/Storage-Migration-FAQ
https://github.com/ankidroid/Anki-Android/wiki/Full-Storage-Access
https://docs.ankidroid.org/#_custom_sync_server
- https://docs.ankidroid.org/storage-migration-error.html
https://docs.ankiweb.net/browsing.html#cards
- https://docs.ankidroid.org/storage-migration-error.html
- 0
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
- }
-}