Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 57 additions & 29 deletions AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -282,16 +281,16 @@ fun Context.showError(

Timber.i("Error dialog displayed")

val helpAction = crashReportData?.helpAction?.takeIf { it.canExecute(this) }
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This complexity comes from the fact that we cannot call showDatabaseErrorDialog without context being an AnkiActivity derivative


try {
AlertDialog
.Builder(this)
.create {
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() }
Expand All @@ -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()
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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? {
Expand All @@ -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
}
Expand Down Expand Up @@ -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"
}
}
34 changes: 34 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 David Allison <davidallisongithub@gmail.com>
// 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<Exception> { col.sched.counts() }
assertTrue(ex.isDeckNotFoundInLimitsMapException())
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression cover: backend does not change the text

}
}
Loading