Skip to content

Commit

Permalink
Remove DOWNGRADE_REQUIRED and slightly simplify startup error handling
Browse files Browse the repository at this point in the history
- The backend automatically downgrades when required, and possible. No
backup is required, as the downgrade happens in a single transaction,
and the downgrade code has proven itself over time.
- Store the type of failure in getColSafe(), so it can be checked later.
  • Loading branch information
dae committed Jun 18, 2022
1 parent 4ca5608 commit 0daa7a5
Show file tree
Hide file tree
Showing 7 changed files with 57 additions and 173 deletions.
18 changes: 0 additions & 18 deletions AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package com.ichi2.anki

import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import com.ichi2.anki.exception.OutOfSpaceException
import com.ichi2.compat.CompatHelper
import com.ichi2.libanki.Collection
import com.ichi2.libanki.Utils
Expand All @@ -38,23 +37,6 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

open class BackupManager {
@Throws(OutOfSpaceException::class)
fun performDowngradeBackupInForeground(path: String): Boolean {
val colFile = File(path)
if (!hasFreeDiscSpace(colFile)) {
Timber.w("Could not backup: no free disc space")
throw OutOfSpaceException()
}
val backupFile = getBackupFile(colFile, "ankiDroidv16.colpkg")
return try {
performBackup(colFile, backupFile)
} catch (e: Exception) {
Timber.w(e)
CrashReportService.sendExceptionReport(e, "performBackupInForeground")
false
}
}

/**
* Attempts to create a backup in a background thread. Returns `true` if the process is started.
*
Expand Down
59 changes: 31 additions & 28 deletions AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
import timber.log.Timber;

import static com.ichi2.libanki.Consts.SCHEMA_VERSION;
import static com.ichi2.libanki.Consts.SCHEMA_DOWNGRADE_SUPPORTED_VERSION;

/**
* Singleton which opens, stores, and closes the reference to the Collection.
Expand Down Expand Up @@ -76,6 +75,14 @@ public class CollectionHelper {
*/
private boolean mCollectionLocked;

/**
* If the last call to getColSafe() failed, this stores the error type. This only exists
* to enable better error reporting during startup; in the future it would be better if
* callers check the exception themselves via a helper routine, instead of relying on a null
* return.
*/
private static @Nullable CollectionOpenFailure mLastOpenFailure;

@Nullable
public static Long getCollectionSize(Context context) {
try {
Expand All @@ -100,6 +107,14 @@ public synchronized boolean isCollectionLocked() {
}


/**
* If the last call to getColSafe() failed, this contains the error type.
*/
@Nullable
public static CollectionOpenFailure getLastOpenFailure() {
return mLastOpenFailure;
}

/**
* Lazy initialization holder class idiom. High performance and thread safe way to create singleton.
*/
Expand Down Expand Up @@ -193,12 +208,19 @@ public synchronized Time getTimeSafe(Context context) {
* @return
*/
public synchronized Collection getColSafe(Context context) {
mLastOpenFailure = null;
try {
return getCol(context);
} catch (BackendException.BackendDbException.BackendDbLockedException e) {
mLastOpenFailure = CollectionOpenFailure.LOCKED;
Timber.w(e);
return null;
} catch (BackendException.BackendDbException.BackendDbFileTooNewException e) {
mLastOpenFailure = CollectionOpenFailure.FILE_TOO_NEW;
Timber.w(e);
return null;
} catch (Exception e) {
mLastOpenFailure = CollectionOpenFailure.CORRUPT;
Timber.w(e);
CrashReportService.sendExceptionReport(e, "CollectionHelper.getColSafe");
return null;
Expand Down Expand Up @@ -542,42 +564,23 @@ public static void loadCollectionComplete(Collection col) {
col.getModels();
}

public static DatabaseVersion isFutureAnkiDroidVersion(Context context) throws UnknownDatabaseVersionException {
int databaseVersion = getDatabaseVersion(context);

if (databaseVersion > SCHEMA_VERSION && databaseVersion != SCHEMA_DOWNGRADE_SUPPORTED_VERSION) {
return DatabaseVersion.FUTURE_NOT_DOWNGRADABLE;
} else if (databaseVersion == SCHEMA_DOWNGRADE_SUPPORTED_VERSION) {
return DatabaseVersion.FUTURE_DOWNGRADABLE;
} else {
return DatabaseVersion.USABLE;
}
}


public static int getDatabaseVersion(Context context) throws UnknownDatabaseVersionException {
try {
Collection col = getInstance().mCollection;
return col.queryVer();
} catch (Exception e) {
Timber.w(e, "Failed to query version");
// fallback to a pure DB implementation
int version = Storage.getDatabaseVersion(getCollectionPath(context));
if (version <= SCHEMA_DOWNGRADE_SUPPORTED_VERSION) {
// If the Rust call failed but the schema is in range, this indicates corruption.
throw new UnknownDatabaseVersionException(new Exception("probably corrupt"));
}
return version;
}
// backend can't open a schema version outside range, so fall back to a pure DB implementation
return Storage.getDatabaseVersion(getCollectionPath(context));
}

public enum DatabaseVersion {
USABLE,
FUTURE_DOWNGRADABLE,
FUTURE_NOT_DOWNGRADABLE,
UNKNOWN
}

public enum CollectionOpenFailure {
FILE_TOO_NEW,
CORRUPT,
LOCKED
}

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
public void setColForTests(Collection col) {
this.mCollection = col;
Expand Down
2 changes: 0 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,6 @@ open class DeckPicker : NavigationDrawerActivity(), StudyOptionsListener, SyncEr
Timber.i("Displaying database locked error")
showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_DB_LOCKED)
}
DATABASE_DOWNGRADE_REQUIRED -> // This has a callback to continue with handleStartup
InitialActivity.downgradeBackend(this)
WEBVIEW_FAILED -> MaterialDialog.Builder(this)
.title(R.string.ankidroid_init_failed_webview_title)
.content(getString(R.string.ankidroid_init_failed_webview, AnkiDroidApp.getWebViewErrorMessage()))
Expand Down
103 changes: 8 additions & 95 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,10 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.*
import androidx.annotation.CheckResult
import com.ichi2.anki.CollectionHelper.DatabaseVersion
import com.ichi2.anki.exception.OutOfSpaceException
import com.ichi2.anki.servicelayer.PreferenceUpgradeService
import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate
import com.ichi2.utils.VersionUtils.pkgVersionName
import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
import net.ankiweb.rsdroid.RustBackendFailedException
import timber.log.Timber
import java.lang.ref.WeakReference

/** Utilities for launching the first activity (currently the DeckPicker) */
object InitialActivity {
Expand All @@ -52,53 +47,17 @@ object InitialActivity {
return StartupFailure.DIRECTORY_NOT_ACCESSIBLE
}

return when (isFutureAnkiDroidVersion(context)) {
DatabaseVersion.FUTURE_NOT_DOWNGRADABLE -> StartupFailure.FUTURE_ANKIDROID_VERSION
DatabaseVersion.FUTURE_DOWNGRADABLE -> StartupFailure.DATABASE_DOWNGRADE_REQUIRED
DatabaseVersion.UNKNOWN, DatabaseVersion.USABLE -> try {
CollectionHelper.getInstance().getCol(context)
StartupFailure.DB_ERROR
} catch (e: BackendDbLockedException) {
StartupFailure.DATABASE_LOCKED
} catch (ignored: Exception) {
StartupFailure.DB_ERROR
return when (CollectionHelper.getLastOpenFailure()) {
CollectionHelper.CollectionOpenFailure.FILE_TOO_NEW -> StartupFailure.FUTURE_ANKIDROID_VERSION
CollectionHelper.CollectionOpenFailure.CORRUPT -> StartupFailure.DB_ERROR
CollectionHelper.CollectionOpenFailure.LOCKED -> StartupFailure.DATABASE_LOCKED
null -> {
// if getColSafe returned null, this should never happen
null
}
}
}

private fun isFutureAnkiDroidVersion(context: Context): DatabaseVersion {
return try {
CollectionHelper.isFutureAnkiDroidVersion(context)
} catch (e: Exception) {
Timber.w(e, "Could not determine if future AnkiDroid version - assuming not")
DatabaseVersion.UNKNOWN
}
}

/**
* Downgrades the database at the currently selected collection path from V16 to V11 in a background task
*/
// #7108: AsyncTask
@Suppress("deprecation")
@JvmStatic
fun downgradeBackend(deckPicker: DeckPicker) {
// Note: This method does not require a backend pointer or an open collection
Timber.i("Downgrading backend")
PerformDowngradeTask(WeakReference(deckPicker)).execute()
}

@Throws(OutOfSpaceException::class, RustBackendFailedException::class)
internal fun downgradeCollection(deckPicker: DeckPicker?, backupManager: BackupManager) {
requireNotNull(deckPicker) { "deckPicker was null" }
val collectionPath = CollectionHelper.getCollectionPath(deckPicker)
require(backupManager.performDowngradeBackupInForeground(collectionPath)) { "backup failed" }
Timber.d("Downgrading database to V11: '%s'", collectionPath)
// TODO: this routine is no longer useful?
val backend = AnkiDroidApp.currentBackendFactory().getBackend()
backend.openCollection(collectionPath)
backend.closeCollection(true)
}

/** @return Whether any preferences were upgraded
*/
@JvmStatic
Expand Down Expand Up @@ -149,54 +108,8 @@ object InitialActivity {
return preferences.getString("lastVersion", "") == pkgVersionName
}

// I disapprove, but it's best to keep consistency with the rest of the app
// #7108: AsyncTask
@Suppress("deprecation")
private class PerformDowngradeTask(private val deckPicker: WeakReference<DeckPicker>) : AsyncTask<Void?, Void?, Void?>() {
private var mException: Exception? = null
override fun doInBackground(vararg p0: Void?): Void? {
// It would be great if we could catch the OutOfSpaceException here
try {
val deckPicker = deckPicker.get()
downgradeCollection(deckPicker, deckPicker!!.backupManager!!)
} catch (e: Exception) {
Timber.w(e)
mException = e
}
return null
}

override fun onPreExecute() {
super.onPreExecute()
val d = deckPicker.get()
d?.showProgressBar()
}

override fun onPostExecute(result: Void?) {
super.onPostExecute(result)
val d = deckPicker.get() ?: return
d.hideProgressBar()
if (mException != null) {
if (mException is OutOfSpaceException) {
d.displayDowngradeFailedNoSpace()
} else {
d.displayDatabaseFailure()
}
return
}
Timber.i("Database downgrade successful - starting up")
// no exception - continue
d.handleStartup()
// This call should probably be in handleStartup - but it's also called there onRefresh
// TODO: PERF: to fix the above, add test to ensure that this is only called once on each startup path
d.refreshState()
}
}

enum class StartupFailure {
SD_CARD_NOT_MOUNTED, DIRECTORY_NOT_ACCESSIBLE, FUTURE_ANKIDROID_VERSION,

/** A downgrade of the AnkiDroid database is required (and possible) */
DATABASE_DOWNGRADE_REQUIRED, DB_ERROR, DATABASE_LOCKED, WEBVIEW_FAILED
DB_ERROR, DATABASE_LOCKED, WEBVIEW_FAILED
}
}
1 change: 0 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/libanki/Consts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ object Consts {
var SCHEMA_VERSION = 11

/** The database schema version that we can downgrade from */
const val SCHEMA_DOWNGRADE_SUPPORTED_VERSION = 18
const val SYNC_MAX_BYTES = (2.5 * 1024 * 1024).toInt()
const val SYNC_MAX_FILES = 25
const val SYNC_BASE = "https://sync%s.ankiweb.net/"
Expand Down
47 changes: 18 additions & 29 deletions AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.ichi2.libanki.DB;
import com.ichi2.libanki.DeckConfig;
import com.ichi2.libanki.Storage;
import com.ichi2.libanki.exception.UnknownDatabaseVersionException;
import com.ichi2.libanki.sched.AbstractSched;
import com.ichi2.testutils.AnkiActivityUtils;
import com.ichi2.testutils.BackendEmulatingOpenConflict;
Expand Down Expand Up @@ -446,18 +447,20 @@ public void corruptVersion16CollectionShowsDatabaseError() {
}

@Test
public void notEnoughSpaceToBackupBeforeDowngradeShowsError() {
Class<DeckPickerNoSpaceForBackup> clazz = DeckPickerNoSpaceForBackup.class;
try (MockedStatic<InitialActivity> initialActivityMock = mockStatic(InitialActivity.class, Mockito.CALLS_REAL_METHODS)) {
initialActivityMock
.when(() -> InitialActivity.getStartupFailureType(any()))
.thenAnswer((Answer<InitialActivity.StartupFailure>) invocation -> InitialActivity.StartupFailure.DATABASE_DOWNGRADE_REQUIRED);
public void futureSchemaShowsError() {
try {
setupColV250();

InitialActivityWithConflictTest.setupForValid(getTargetContext());

DeckPickerNoSpaceForBackup deckPicker = super.startActivityNormallyOpenCollectionWithIntent(clazz, new Intent());
DeckPickerEx deckPicker = super.startActivityNormallyOpenCollectionWithIntent(DeckPickerEx.class, new Intent());
waitForAsyncTasksToComplete();

assertThat("A downgrade failed dialog should be shown", deckPicker.mDisplayedDowngradeFailed, is(true));
assertThat("Collection should not be open", !CollectionHelper.getInstance().colIsOpen());
assertThat("An error dialog should be displayed", deckPicker.mDatabaseErrorDialog, is(DatabaseErrorDialog.INCOMPATIBLE_DB_VERSION));
assertThat(CollectionHelper.getDatabaseVersion(getTargetContext()), is(250));
} catch (UnknownDatabaseVersionException e) {
assertThat("no exception should be thrown", false, is(true));
} finally {
InitialActivityWithConflictTest.setupForDefault();
}
Expand Down Expand Up @@ -514,9 +517,15 @@ protected void setupColV16() {
useCollection(CollectionType.SCHEMA_V_16);
}

protected void setupColV250() {
Storage.setUseInMemory(false);
DB.setSqliteOpenHelperFactory(new FrameworkSQLiteOpenHelperFactory());
useCollection(CollectionType.SCHEMA_V_250);
}

public enum CollectionType {
SCHEMA_V_16("schema16.anki2", "ThisIsSchema16");
SCHEMA_V_16("schema16.anki2", "ThisIsSchema16"),
SCHEMA_V_250("schema250.anki2", "ThisIsSchema250");

private final String mAssetFile;
private final String mDeckName;
Expand All @@ -537,26 +546,6 @@ public boolean isCollection(Collection col) {
}
}

private static class DeckPickerNoSpaceForBackup extends DeckPickerEx {

private boolean mDisplayedDowngradeFailed;


@Override
public BackupManager getBackupManager() {
BackupManager bm = spy(new BackupManager());
doReturn(false).when(bm).hasFreeDiscSpace(any());
return bm;
}


@Override
public void displayDowngradeFailedNoSpace() {
this.mDisplayedDowngradeFailed = true;
super.displayDowngradeFailedNoSpace();
}
}

private static class DeckPickerEx extends DeckPicker {
private int mDatabaseErrorDialog;
private boolean mDisplayedAnalyticsOptIn;
Expand Down
Binary file added AnkiDroid/src/test/resources/schema250.anki2
Binary file not shown.

0 comments on commit 0daa7a5

Please sign in to comment.