From 02365b199beb186dd48281c5968f8573f206e335 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 11 May 2026 00:46:51 +0100 Subject: [PATCH] fix(scheduler): 'deck not found in limits map' A 'Check database' action is shown on the error message, which resolves the issue. The error is no longer sent to ACRA Fixes 15195 Assisted-by: Claude Opus 4.7 - initial debugging, wrote code which I designed --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 86 ++++++++++++------- .../com/ichi2/anki/CrashReportDataTest.kt | 34 ++++++++ 2 files changed, 91 insertions(+), 29 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 28638818f8a6..949ceb18a015 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -19,6 +19,7 @@ package com.ichi2.anki import android.app.Activity import android.app.Dialog import android.content.Context +import android.content.ContextWrapper import android.content.DialogInterface import android.database.sqlite.SQLiteDatabaseCorruptException import android.net.Uri @@ -37,9 +38,6 @@ import anki.collection.Progress import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CrashReportData.Companion.throwIfDialogUnusable import com.ichi2.anki.CrashReportData.Companion.toCrashReportData -import com.ichi2.anki.CrashReportData.HelpAction -import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink -import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.common.coroutines.applicationScope @@ -49,6 +47,7 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.openUrl import com.ichi2.utils.create import com.ichi2.utils.message @@ -282,6 +281,8 @@ fun Context.showError( Timber.i("Error dialog displayed") + val helpAction = crashReportData?.helpAction?.takeIf { it.canExecute(this) } + try { AlertDialog .Builder(this) @@ -289,9 +290,7 @@ fun Context.showError( title(R.string.vague_error) message(text = message) positiveButton(R.string.dialog_ok) - if (crashReportData?.helpAction != null) { - neutralButton(R.string.help) - } + helpAction?.let { neutralButton(text = it.buttonText(this@showError)) } if (crashReportData?.reportableException == true) { Timber.w("sending crash report on close") setOnDismissListener { crashReportData.sendCrashReport() } @@ -301,10 +300,7 @@ fun Context.showError( setOnShowListener { neutralButton?.setOnClickListener { lifecycle.coroutineScope.launch { - val shouldDismiss = crashReportData!!.helpAction!!.execute(context = context) - if (shouldDismiss) { - dismiss() - } + if (helpAction!!.execute(this@showError)) dismiss() } } } @@ -318,25 +314,13 @@ fun Context.showError( } } -/** - * @return Whether the dialog should be dismissed - */ -suspend fun HelpAction.execute(context: Context): Boolean { +/** The dialog's [Context] is wrapped (e.g. ContextThemeWrapper); walk the chain to find the activity. */ +internal tailrec fun Context.findAnkiActivity(): AnkiActivity? = when (this) { - is AnkiBackendLink -> { - context.openUrl(this.link) - return false - } - OpenDeckOptions -> { - // if we're in the error dialog, we have no context of the deck which caused the exception - // assume it's the current deck - val openCurrentDeckOptions = DeckOptionsDestination.fromCurrentDeck() - context.startActivity(openCurrentDeckOptions.toIntent(context)) - // dismiss the dialog - the user should have resolved the issue - return true - } + is AnkiActivity -> this + is ContextWrapper -> baseContext.findAnkiActivity() + else -> null } -} /** In most cases, you'll want [AnkiActivity.withProgress] * instead. This lower-level routine can be used to integrate your own @@ -679,6 +663,7 @@ data class CrashReportData( fun shouldReportException(): Boolean { if (!reportableException) return false if (exception.isInvalidFsrsParametersException()) return false + if (exception.isDeckNotFoundInLimitsMapException()) return false if (exception is BackendInvalidInputException && exception.message == "missing template") return false return true } @@ -699,12 +684,50 @@ data class CrashReportData( * - Open the deck options */ sealed class HelpAction { + /** Label for the 'help' button on the error dialog. Defaults to "Help". */ + open fun buttonText(context: Context): CharSequence = context.getString(R.string.help) + + /** `false` hides the help button. */ + open fun canExecute(context: Context): Boolean = true + + /** Perform the action. @return whether the error dialog should be dismissed. */ + abstract suspend fun execute(context: Context): Boolean + data class AnkiBackendLink( val link: Uri, - ) : HelpAction() + ) : HelpAction() { + override suspend fun execute(context: Context): Boolean { + context.openUrl(link) + return false + } + } /** Open the deck options for the current deck */ - data object OpenDeckOptions : HelpAction() + data object OpenDeckOptions : HelpAction() { + override suspend fun execute(context: Context): Boolean { + // if we're in the error dialog, we have no context of the deck which caused the exception + // assume it's the current deck + val openCurrentDeckOptions = DeckOptionsDestination.fromCurrentDeck() + context.startActivity(openCurrentDeckOptions.toIntent(context)) + // dismiss the dialog - the user should have resolved the issue + return true + } + } + + /** Opens 'Check Database' */ + data object OpenCheckDatabase : HelpAction() { + override fun buttonText(context: Context): CharSequence = with(context) { TR.sentenceCase.checkDatabase } + + override fun canExecute(context: Context): Boolean = context.findAnkiActivity() != null + + override suspend fun execute(context: Context): Boolean { + Timber.i("Opening 'Check Database'") + context.findAnkiActivity()!!.showDatabaseErrorDialog( + errorDialogType = DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK, + ) + return true + } + } companion object { fun from(e: Throwable): HelpAction? { @@ -720,6 +743,7 @@ data class CrashReportData( if (link != null) return AnkiBackendLink(link) if (e.isInvalidFsrsParametersException()) return OpenDeckOptions + if (e.isDeckNotFoundInLimitsMapException()) return OpenCheckDatabase return null } @@ -768,5 +792,9 @@ data class CrashReportData( } catch (_: Throwable) { false } + + @VisibleForTesting + internal fun Throwable.isDeckNotFoundInLimitsMapException(): Boolean = + this is BackendInvalidInputException && message == "deck not found in limits map" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt new file mode 100644 index 000000000000..5b10a3949416 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 David Allison +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CrashReportData.Companion.isDeckNotFoundInLimitsMapException +import com.ichi2.anki.libanki.QueueType +import com.ichi2.testutils.EmptyApplication +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +class CrashReportDataTest : RobolectricTest() { + /** #15195: corrupt deck hierarchy raises 'deck not found in limits map' */ + @Test + fun `deck not found in limits map regression test`() { + addDeck("A::B::C").withNote(QueueType.New) + val deckBDid = col.decks.idForName("A::B")!! + val deckADid = col.decks.idForName("A")!! + + // Drop A::B -> A::B::C is under 'A', but has no entry in the limits map + col.db.execute("delete from decks where id = ?", deckBDid) + + col.decks.select(deckADid) + + val ex = assertFailsWith { col.sched.counts() } + assertTrue(ex.isDeckNotFoundInLimitsMapException()) + } +}