Skip to content

Commit 02365b1

Browse files
committed
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
1 parent e3690dd commit 02365b1

2 files changed

Lines changed: 91 additions & 29 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.ichi2.anki
1919
import android.app.Activity
2020
import android.app.Dialog
2121
import android.content.Context
22+
import android.content.ContextWrapper
2223
import android.content.DialogInterface
2324
import android.database.sqlite.SQLiteDatabaseCorruptException
2425
import android.net.Uri
@@ -37,9 +38,6 @@ import anki.collection.Progress
3738
import com.ichi2.anki.CollectionManager.TR
3839
import com.ichi2.anki.CrashReportData.Companion.throwIfDialogUnusable
3940
import com.ichi2.anki.CrashReportData.Companion.toCrashReportData
40-
import com.ichi2.anki.CrashReportData.HelpAction
41-
import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink
42-
import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions
4341
import com.ichi2.anki.android.AnkiBroadcastReceiver
4442
import com.ichi2.anki.common.annotations.UseContextParameter
4543
import com.ichi2.anki.common.coroutines.applicationScope
@@ -49,6 +47,7 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
4947
import com.ichi2.anki.exception.StorageAccessException
5048
import com.ichi2.anki.pages.DeckOptionsDestination
5149
import com.ichi2.anki.snackbar.showSnackbar
50+
import com.ichi2.anki.ui.internationalization.sentenceCase
5251
import com.ichi2.anki.utils.openUrl
5352
import com.ichi2.utils.create
5453
import com.ichi2.utils.message
@@ -282,16 +281,16 @@ fun Context.showError(
282281

283282
Timber.i("Error dialog displayed")
284283

284+
val helpAction = crashReportData?.helpAction?.takeIf { it.canExecute(this) }
285+
285286
try {
286287
AlertDialog
287288
.Builder(this)
288289
.create {
289290
title(R.string.vague_error)
290291
message(text = message)
291292
positiveButton(R.string.dialog_ok)
292-
if (crashReportData?.helpAction != null) {
293-
neutralButton(R.string.help)
294-
}
293+
helpAction?.let { neutralButton(text = it.buttonText(this@showError)) }
295294
if (crashReportData?.reportableException == true) {
296295
Timber.w("sending crash report on close")
297296
setOnDismissListener { crashReportData.sendCrashReport() }
@@ -301,10 +300,7 @@ fun Context.showError(
301300
setOnShowListener {
302301
neutralButton?.setOnClickListener {
303302
lifecycle.coroutineScope.launch {
304-
val shouldDismiss = crashReportData!!.helpAction!!.execute(context = context)
305-
if (shouldDismiss) {
306-
dismiss()
307-
}
303+
if (helpAction!!.execute(this@showError)) dismiss()
308304
}
309305
}
310306
}
@@ -318,25 +314,13 @@ fun Context.showError(
318314
}
319315
}
320316

321-
/**
322-
* @return Whether the dialog should be dismissed
323-
*/
324-
suspend fun HelpAction.execute(context: Context): Boolean {
317+
/** The dialog's [Context] is wrapped (e.g. ContextThemeWrapper); walk the chain to find the activity. */
318+
internal tailrec fun Context.findAnkiActivity(): AnkiActivity? =
325319
when (this) {
326-
is AnkiBackendLink -> {
327-
context.openUrl(this.link)
328-
return false
329-
}
330-
OpenDeckOptions -> {
331-
// if we're in the error dialog, we have no context of the deck which caused the exception
332-
// assume it's the current deck
333-
val openCurrentDeckOptions = DeckOptionsDestination.fromCurrentDeck()
334-
context.startActivity(openCurrentDeckOptions.toIntent(context))
335-
// dismiss the dialog - the user should have resolved the issue
336-
return true
337-
}
320+
is AnkiActivity -> this
321+
is ContextWrapper -> baseContext.findAnkiActivity()
322+
else -> null
338323
}
339-
}
340324

341325
/** In most cases, you'll want [AnkiActivity.withProgress]
342326
* instead. This lower-level routine can be used to integrate your own
@@ -679,6 +663,7 @@ data class CrashReportData(
679663
fun shouldReportException(): Boolean {
680664
if (!reportableException) return false
681665
if (exception.isInvalidFsrsParametersException()) return false
666+
if (exception.isDeckNotFoundInLimitsMapException()) return false
682667
if (exception is BackendInvalidInputException && exception.message == "missing template") return false
683668
return true
684669
}
@@ -699,12 +684,50 @@ data class CrashReportData(
699684
* - Open the deck options
700685
*/
701686
sealed class HelpAction {
687+
/** Label for the 'help' button on the error dialog. Defaults to "Help". */
688+
open fun buttonText(context: Context): CharSequence = context.getString(R.string.help)
689+
690+
/** `false` hides the help button. */
691+
open fun canExecute(context: Context): Boolean = true
692+
693+
/** Perform the action. @return whether the error dialog should be dismissed. */
694+
abstract suspend fun execute(context: Context): Boolean
695+
702696
data class AnkiBackendLink(
703697
val link: Uri,
704-
) : HelpAction()
698+
) : HelpAction() {
699+
override suspend fun execute(context: Context): Boolean {
700+
context.openUrl(link)
701+
return false
702+
}
703+
}
705704

706705
/** Open the deck options for the current deck */
707-
data object OpenDeckOptions : HelpAction()
706+
data object OpenDeckOptions : HelpAction() {
707+
override suspend fun execute(context: Context): Boolean {
708+
// if we're in the error dialog, we have no context of the deck which caused the exception
709+
// assume it's the current deck
710+
val openCurrentDeckOptions = DeckOptionsDestination.fromCurrentDeck()
711+
context.startActivity(openCurrentDeckOptions.toIntent(context))
712+
// dismiss the dialog - the user should have resolved the issue
713+
return true
714+
}
715+
}
716+
717+
/** Opens 'Check Database' */
718+
data object OpenCheckDatabase : HelpAction() {
719+
override fun buttonText(context: Context): CharSequence = with(context) { TR.sentenceCase.checkDatabase }
720+
721+
override fun canExecute(context: Context): Boolean = context.findAnkiActivity() != null
722+
723+
override suspend fun execute(context: Context): Boolean {
724+
Timber.i("Opening 'Check Database'")
725+
context.findAnkiActivity()!!.showDatabaseErrorDialog(
726+
errorDialogType = DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK,
727+
)
728+
return true
729+
}
730+
}
708731

709732
companion object {
710733
fun from(e: Throwable): HelpAction? {
@@ -720,6 +743,7 @@ data class CrashReportData(
720743

721744
if (link != null) return AnkiBackendLink(link)
722745
if (e.isInvalidFsrsParametersException()) return OpenDeckOptions
746+
if (e.isDeckNotFoundInLimitsMapException()) return OpenCheckDatabase
723747

724748
return null
725749
}
@@ -768,5 +792,9 @@ data class CrashReportData(
768792
} catch (_: Throwable) {
769793
false
770794
}
795+
796+
@VisibleForTesting
797+
internal fun Throwable.isDeckNotFoundInLimitsMapException(): Boolean =
798+
this is BackendInvalidInputException && message == "deck not found in limits map"
771799
}
772800
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-FileCopyrightText: 2026 David Allison <davidallisongithub@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import com.ichi2.anki.CrashReportData.Companion.isDeckNotFoundInLimitsMapException
8+
import com.ichi2.anki.libanki.QueueType
9+
import com.ichi2.testutils.EmptyApplication
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.annotation.Config
13+
import kotlin.test.assertFailsWith
14+
import kotlin.test.assertTrue
15+
16+
@RunWith(AndroidJUnit4::class)
17+
@Config(application = EmptyApplication::class)
18+
class CrashReportDataTest : RobolectricTest() {
19+
/** #15195: corrupt deck hierarchy raises 'deck not found in limits map' */
20+
@Test
21+
fun `deck not found in limits map regression test`() {
22+
addDeck("A::B::C").withNote(QueueType.New)
23+
val deckBDid = col.decks.idForName("A::B")!!
24+
val deckADid = col.decks.idForName("A")!!
25+
26+
// Drop A::B -> A::B::C is under 'A', but has no entry in the limits map
27+
col.db.execute("delete from decks where id = ?", deckBDid)
28+
29+
col.decks.select(deckADid)
30+
31+
val ex = assertFailsWith<Exception> { col.sched.counts() }
32+
assertTrue(ex.isDeckNotFoundInLimitsMapException())
33+
}
34+
}

0 commit comments

Comments
 (0)