From 02947086d6bd13a0383647d1c02683cf59b09b0f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:19:57 +0100 Subject: [PATCH 1/3] fix: explain 'Anki already open' errors The backend string: 'Anki already open, or media currently syncing' doesn't explain this on Android - the other app likely launched on boot, (and upstream is much less likely to have parallel apps). In AnkiDroid, it is not possible for 'media syncing' to be the cause of this error. CollectionManager now rethrows `BackendDbLockedException` as `CollectionLockedException`, with better user-facing guidance. Also: * a locked collection no longer files a crash report * the login screen shows the guidance in a dialog rather than truncating it in a snackbar Fixes 21051 GPT-6 resolved the rebase conflict and updated the locked-collection message references to CommonString. GPT-6 also updated locked-collection exception assertions and registered the error dialog test activity for cleanup. Assisted-by: Claude Fable 5 Assisted-by: GPT-6 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 4 ++ .../java/com/ichi2/anki/CoroutineHelpers.kt | 5 +- .../java/com/ichi2/anki/InitialActivity.kt | 3 +- .../ichi2/anki/dialogs/DatabaseErrorDialog.kt | 2 +- AnkiDroid/src/main/res/values/03-dialogs.xml | 2 +- .../com/ichi2/anki/CollectionLeaseTest.kt | 4 +- .../ichi2/anki/CollectionManagerImportTest.kt | 4 +- .../anki/CollectionManagerStackTraceTest.kt | 5 +- .../ichi2/anki/CollectionStorageModeTest.kt | 6 +-- .../anki/CoroutineHelpersRobolectricTest.kt | 49 +++++++++++++++++++ .../java/com/ichi2/anki/RobolectricTest.kt | 2 +- .../anki/tags/ManageTagsViewModelTest.kt | 8 +-- .../BackendEmulatingOpenConflictTest.kt | 4 +- .../com/ichi2/anki/CollectionManager.kt | 26 ++++++---- .../exception/CollectionLockedException.kt | 37 ++++++++++++++ 15 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index d87518e7a39d..968ab1122d4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.contextmenu.AnkiCardContextMenu import com.ichi2.anki.contextmenu.CardBrowserContextMenu +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.logging.FragmentLifecycleLogger @@ -181,6 +182,9 @@ open class AnkiDroidApp : initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } Animations.setPreferencesProvider { context -> PrefsRepository(context) } + CollectionLockedException.messageProvider = { + getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) + } val logType = LogType.value when (logType) { LogType.DEBUG -> Timber.plant(DebugTree()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 058b76403b1d..def212ffbe8c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -33,6 +33,7 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.pages.toIntent @@ -163,7 +164,9 @@ suspend fun FragmentActivity.runCatching( Timber.w(exc, errorMessage) if (!isFinishing) redirectToMainEntryPoint() } - is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException -> { + is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException, + is CollectionLockedException, + -> { // these exceptions do not generate worthwhile crash reports Timber.i("Showing error dialog but not sending a crash report.") showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false)) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 73df41266d34..5cfd63d28f17 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.storage.isLegacyStorage import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate @@ -69,7 +70,7 @@ object InitialActivity { try { CollectionManager.getColUnsafe() return null - } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + } catch (e: CollectionLockedException) { Timber.w(e) StartupFailure.DatabaseLocked } catch (e: BackendException.BackendDbException.BackendDbFileTooNewException) { 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 ce92f5375bfc..3c2aae249baf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -585,7 +585,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { DIALOG_CONFIRM_DATABASE_CHECK -> res().getString(CommonString.check_db_warning) DIALOG_CONFIRM_RESTORE_BACKUP -> res().getString(CommonString.restore_backup) DIALOG_ONE_WAY_SYNC_FROM_SERVER -> res().getString(CommonString.backup_full_sync_from_server_question) - DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary) + DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary_new, res().getString(CommonString.col_path)) INCOMPATIBLE_DB_VERSION -> { var databaseVersion = -1 try { diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index d028635b97ae..a7b1a4124c4f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -119,7 +119,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ā€˜%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8282cf475a3c..8c56fa4b4368 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -8,6 +8,7 @@ import com.ichi2.anki.CollectionManager.tryWithCol import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.CollectionManager.withLeaseForTest +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import net.ankiweb.rsdroid.Backend -import net.ankiweb.rsdroid.BackendException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue @@ -395,7 +395,7 @@ class CollectionLeaseTest : RobolectricTest() { runTest { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED try { - assertFailsWith { + assertFailsWith { withColExclusive(CollectionOperation.SYNC) { error("collection could not open") } } assertThat(collectionLease, nullValue()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt index 16a6eef4aea0..a37280942234 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt @@ -5,8 +5,8 @@ package com.ichi2.anki import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.exception.CollectionLockedException import net.ankiweb.rsdroid.BackendException -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import net.ankiweb.rsdroid.BackendException.BackendImportException import net.ankiweb.rsdroid.exceptions.BackendIoException import net.ankiweb.rsdroid.exceptions.BackendSyncException @@ -66,7 +66,7 @@ class CollectionManagerImportTest : RobolectricTest() { val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) } assertEquals(expectedMessage, failure.localizedMessage) - assertIs(failure.suppressed.single()) + assertIs(failure.suppressed.single()) } finally { CollectionManager.emulatedOpenFailure = null } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt index 1cf6d04952cd..7c3c407fa678 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -3,12 +3,14 @@ package com.ichi2.anki import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.test.StandardTestDispatcher import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -22,8 +24,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() { try { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED - val failure = assertFailsWith { requestCollection() } + val failure = assertFailsWith { requestCollection() } + assertIs(failure.cause) assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" }) val caller = failure.suppressed.single() assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" }) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt index 85e4729ed17d..83f638bfed51 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt @@ -3,9 +3,9 @@ package com.ichi2.anki import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.instanceOf @@ -51,8 +51,8 @@ class CollectionStorageModeTest( fun `test access respects collection opening failures`() = runTest { withNullCollection { - assertThat(runCatching { col }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) - assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) + assertThat(runCatching { col }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) + assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index 49c7d798aacd..64adb0414879 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -4,7 +4,13 @@ package com.ichi2.anki import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -34,4 +40,47 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) } } + + /** + * #21051: the collection lock is normally held by a second AnkiDroid install sharing the + * AnkiDroid folder. The backend's 'Anki already open' text doesn't explain this on Android, + * where the other app is invisible: [CollectionLockedException] carries guidance naming the + * likely cause instead. + */ + @Test + fun `launchCatchingTask explains a locked collection`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + assertThat(getAlertDialogText(true), containsString("Advanced settings")) + } + + /** See `launchCatchingTask explains a locked collection`: the ViewModel error funnel */ + @Test + fun `launchCatching explains a locked collection`() = + withLockedCollection { + runTest { + var message: String? = null + + launchCatching(errorMessageHandler = { message = it }) { withCol { } }.join() + + assertThat(message, containsString("Advanced settings")) + } + } + + /** Emulates #21051: another AnkiDroid install holds the collection lock */ + private fun withLockedCollection(block: () -> Unit) { + BackendEmulatingOpenConflict.enable() + try { + block() + } finally { + BackendEmulatingOpenConflict.disable() + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index ff9bf5eed72f..f4e2e1d294b4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -385,7 +385,7 @@ open class RobolectricTest : } /** - * Emulates a null collection and a `BackendDbLockedException` while [block] runs, + * Emulates a null collection and a `CollectionLockedException` while [block] runs, * restoring normal collection behavior afterwards. * * @see enableNullCollection diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index bbcc1c86f0d9..3bc1d5ba277e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -7,6 +7,7 @@ import androidx.test.filters.MediumTest import com.ichi2.anki.CollectionManager import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin @@ -14,7 +15,6 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.setMain -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.equalTo @@ -167,7 +167,7 @@ class ManageTagsViewModelTest : RobolectricTest() { assertThat(loadedState.visibleNodes, equalTo(previousTags)) assertThat(loadedState.isWorking, equalTo(false)) - assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + assertThat(loadedState.error, instanceOf(CollectionLockedException::class.java)) withQueuedCollectionAccess { val retry = removeTag("science") @@ -188,7 +188,7 @@ class ManageTagsViewModelTest : RobolectricTest() { val viewModel = withLockedCollection { ManageTagsViewModel() } val failure = viewModel.state.value assertThat(failure, instanceOf(ManageTagsState.Error::class.java)) - assertThat((failure as ManageTagsState.Error).error, instanceOf(BackendDbLockedException::class.java)) + assertThat((failure as ManageTagsState.Error).error, instanceOf(CollectionLockedException::class.java)) viewModel.filter("hist") assertThat(viewModel.searchQuery.value, equalTo("hist")) @@ -508,7 +508,7 @@ class ManageTagsViewModelTest : RobolectricTest() { withLockedCollection { refreshTags().join() } - assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + assertThat(loadedState.error, instanceOf(CollectionLockedException::class.java)) assertThat(pendingMessages.value, equalTo(pending)) refreshTags().join() diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt index 808090f8116a..b4c440d98e64 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt @@ -5,7 +5,7 @@ package com.ichi2.testutils import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.CollectionManager import com.ichi2.anki.RobolectricTest -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException +import com.ichi2.anki.exception.CollectionLockedException import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -29,7 +29,7 @@ class BackendEmulatingOpenConflictTest : RobolectricTest() { @Test fun assumeMocksAreValid() { assertThrows( - BackendDbLockedException::class.java, + CollectionLockedException::class.java, ) { CollectionManager.getColUnsafe() } } } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index 9e04b6bfd416..91e7d8a40913 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -27,6 +27,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.Threads import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.isRunningAsUnitTest +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection @@ -393,15 +394,20 @@ object CollectionManager { throw CollectionHelper.systemStorageFailure ?: StorageNotConfiguredException() } ensureBackendInner() - emulatedOpenFailure?.triggerFailure() - if (collection == null || collection!!.dbClosed) { - val collectionPath = collectionFilesTestOverride ?: collectionPathInValidFolder() - collection = - collection( - collectionFiles = collectionPath, - databaseBuilder = { backend -> createDatabaseUsingRustBackend(backend) }, - backend = backend, - ) + try { + emulatedOpenFailure?.triggerFailure() + if (collection == null || collection!!.dbClosed) { + val collectionPath = collectionFilesTestOverride ?: collectionPathInValidFolder() + collection = + collection( + collectionFiles = collectionPath, + databaseBuilder = { backend -> createDatabaseUsingRustBackend(backend) }, + backend = backend, + ) + } + } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + // All consumers should better explain the issue + throw CollectionLockedException(e) } } @@ -599,7 +605,7 @@ object CollectionManager { } enum class CollectionOpenFailure { - /** Raises [BackendException.BackendDbException.BackendDbLockedException] */ + /** Raises [BackendException.BackendDbException.BackendDbLockedException], surfaced as [CollectionLockedException] */ LOCKED, /** Raises [BackendException.BackendFatalError] */ diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt b/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt new file mode 100644 index 000000000000..f48ce9abd7e1 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +import net.ankiweb.rsdroid.BackendException +import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException + +/** + * The collection database is locked by another process: normally a parallel AnkiDroid install. + * + * Thrown in place of [BackendDbLockedException] for a better user-facing error: + * + * * Inform the user two apps using the same folder are not supported. + * * Inform them they need to change the folder (via 'AnkiDroid directory') + * * Inform them that they should use 'Force close' to temporarily solve it. + * * Inform the user the app may not be running. + * + * Note: "Anki already open, or media currently syncing." is currently hardcoded as an error. + * `rslib/src/error/db.rs` + */ +class CollectionLockedException( + cause: BackendDbLockedException, +) : BackendException(messageProvider?.invoke() ?: cause.localizedMessage) { + init { + initCause(cause) + } + + companion object { + /** + * Localized, user-facing guidance shown wherever this exception is displayed. + * + * Set at app startup: this module cannot access the app's string resources. + * TODO: after #21500, use the string directly + */ + var messageProvider: (() -> String)? = null + } +} From 68b30ee42ac919bd06c49b0afb6d1315628429d1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:20:18 +0100 Subject: [PATCH 2/3] fix(login): locked-collection error in a dialog The `CollectionLockedException` guidance is truncated by the login screen's snackbar, so show it in a dialog. Part of 21051 Assisted-by: Claude Fable 5 --- .../com/ichi2/anki/account/LoginFragment.kt | 13 ++++- .../ichi2/anki/account/LoginFragmentTest.kt | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt index 1a6dd2b8711a..3965a07e2e32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt @@ -30,11 +30,14 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString +import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.account.AccountActivity.Companion.START_FROM_DECKPICKER import com.ichi2.anki.dialogs.help.HelpDialog +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.getEndpoint +import com.ichi2.anki.showError import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.bottomCornerClearance @@ -244,7 +247,15 @@ class LoginFragment : Fragment(R.layout.fragment_my_account) { showLoginSuccessDialog() } is LoginState.Error -> { - showSnackbar(text = state.exception.message.toString()) + when (val exception = state.exception) { + // the locked-collection guidance is too long for a snackbar + is CollectionLockedException -> + requireContext().showError( + exception.localizedMessage!!, + exception.toCrashReportData(requireContext(), reportException = false), + ) + else -> showSnackbar(text = exception.message.toString()) + } } is LoginState.Idle -> { /* Not needed */ } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt new file mode 100644 index 000000000000..c527d3310765 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.account + +import android.widget.Button +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputEditText +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.throwOnShowError +import com.ichi2.testutils.BackendEmulatingOpenConflict +import com.ichi2.ui.TextInputEditField +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginFragmentTest : RobolectricTest() { + @Before + override fun setUp() { + super.setUp() + BackendEmulatingOpenConflict.enable() + } + + @After + override fun tearDown() { + super.tearDown() + BackendEmulatingOpenConflict.disable() + } + + /** + * #21051: the locked-collection guidance is multi-sentence, so it is shown in a dialog + * rather than truncated in the login screen's snackbar + */ + @Test + fun `login with a locked collection shows guidance`() { + throwOnShowError = false + val activity = startRegularActivity(AccountActivity.getIntent(targetContext)) + activity.findViewById(R.id.username).setText("user@example.com") + activity.findViewById(R.id.password).setText("hunter2") + + activity.findViewById