Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
97f1198
refactor: extract showException from runCatching
criticalAY Oct 16, 2026
6d7b716
fix(deck-picker): show errors instead of crashing
criticalAY Oct 16, 2026
2492353
fix(empty-cards): show errors instead of crashing
criticalAY Oct 12, 2026
0c97b2c
refactor(deck-picker): refresh once after rebuild
criticalAY Oct 12, 2026
bca04f1
fix(deck-picker): keep an error until its dialog is dismissed
criticalAY Oct 21, 2026
32fddfa
fix(deck-picker): skip the follow-up when selecting a deck fails
criticalAY Oct 23, 2026
797703b
fix(deck-picker): keep the deck list when the current deck can't be read
criticalAY Oct 23, 2026
b2bc8d0
fix(deck-picker): show errors from the other deck actions through the…
criticalAY Oct 25, 2026
a24aabe
test(deck-picker): wait for the deck rows before clicking one
criticalAY Oct 25, 2026
2fa5d89
fix(deck-picker): show errors once database recovery closes
criticalAY Oct 26, 2026
252a2d9
fix(deck-picker): reload the deck list after a partly applied action
criticalAY Oct 26, 2026
cbed887
fix(deck-picker): only delete the deck the side panel shows
criticalAY Oct 28, 2026
64b0ccc
fix(deck-picker): take the shown deck from the state the panel drew
criticalAY Nov 2, 2026
5c392dc
fix(deck-picker): don't let a refresh undo a newer deck selection
criticalAY Nov 2, 2026
73e94fc
fix(deck-picker): update the focused deck through the collection queue
criticalAY Nov 2, 2026
456a702
fix(deck-picker): don't lose a collapse to a second tap or a refresh
criticalAY Nov 2, 2026
a2d4394
fix(deck-picker): keep the deck tree in step with the collection
criticalAY Nov 3, 2026
711483c
fix(deck-picker): focus the deck that is current when a refresh ends
criticalAY Nov 3, 2026
d4f5be3
fix(deck-picker): empty the filtered deck that was chosen
criticalAY Nov 3, 2026
3392179
fix(deck-picker): don't queue an error that is already waiting
criticalAY Nov 3, 2026
00e4d79
fix(deck-picker): set a tapped deck's focus in the collection queue
criticalAY Nov 3, 2026
cb95d05
fix(deck-picker): delete a deck once when asked to twice
criticalAY Nov 3, 2026
ceada3b
fix(deck-picker): drop corruption errors that waited for recovery
criticalAY Nov 3, 2026
e09fe04
fix(deck-picker): show side panel errors through the error queue
criticalAY Nov 3, 2026
d536e03
fix(deck-picker): hide the spinner when the deck list fails to load
criticalAY Nov 3, 2026
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
98 changes: 54 additions & 44 deletions AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,48 +157,57 @@ suspend fun <T> FragmentActivity.runCatching(
showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false))
return null
}
when (exc) {
is CancellationException -> {
throw exc // CancellationException should be re-thrown to propagate it to the parent coroutine
}
is BackendInterruptedException -> {
Timber.w(exc, errorMessage)
exc.localizedMessage?.let { showSnackbar(it) }
}
is StorageNotConfiguredException -> {
// expected before first-run setup completes: no crash report
// edge case when 'ensureStorageIsReady' was insufficient
Timber.w(exc, errorMessage)
if (!isFinishing) redirectToMainEntryPoint()
}
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))
}
// must precede `is BackendException`: BackendDbCorruptException is a subclass
is SQLiteDatabaseCorruptException, is BackendDbCorruptException -> {
Timber.e(exc, errorMessage)
DatabaseCorruption.isDetected = true
if (callerTrace != null) Timber.e(callerTrace)
(this as? AnkiActivity)
?.showDatabaseErrorDialog(
errorDialogType = DatabaseErrorDialogType.DIALOG_LOAD_FAILED,
exceptionData = DatabaseErrorDialog.CustomExceptionData.fromException(exc),
)
}
is BackendException -> {
Timber.e(exc, errorMessage)
if (callerTrace != null) Timber.e(callerTrace)
showError(exc.localizedMessage!!, exc.toCrashReportData(this))
}
else -> {
Timber.e(exc, errorMessage)
if (callerTrace != null) Timber.e(callerTrace)
showError(exc)
}
if (exc is CancellationException) {
throw exc // CancellationException should be re-thrown to propagate it to the parent coroutine
}
showException(exc, errorMessage, callerTrace)
}
return null
}

fun FragmentActivity.showException(
exc: Exception,
errorMessage: String? = null,
callerTrace: String? = null,
): AlertDialog? {
when (exc) {
is BackendInterruptedException -> {
Timber.w(exc, errorMessage)
exc.localizedMessage?.let { showSnackbar(it) }
}
is StorageNotConfiguredException -> {
// expected before first-run setup completes: no crash report
// edge case when 'ensureStorageIsReady' was insufficient
Timber.w(exc, errorMessage)
if (!isFinishing) redirectToMainEntryPoint()
}
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.")
return showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false))
}
// must precede `is BackendException`: BackendDbCorruptException is a subclass
is SQLiteDatabaseCorruptException, is BackendDbCorruptException -> {
Timber.e(exc, errorMessage)
DatabaseCorruption.isDetected = true
if (callerTrace != null) Timber.e(callerTrace)
(this as? AnkiActivity)
?.showDatabaseErrorDialog(
errorDialogType = DatabaseErrorDialogType.DIALOG_LOAD_FAILED,
exceptionData = DatabaseErrorDialog.CustomExceptionData.fromException(exc),
)
}
is BackendException -> {
Timber.e(exc, errorMessage)
if (callerTrace != null) Timber.e(callerTrace)
return showError(exc.localizedMessage!!, exc.toCrashReportData(this))
}
else -> {
Timber.e(exc, errorMessage)
if (callerTrace != null) Timber.e(callerTrace)
return showError(exc)
}
}
return null
Expand Down Expand Up @@ -322,14 +331,14 @@ fun Fragment.launchCatchingViewTask(
fun Context.showError(
message: CharSequence,
crashReportData: CrashReportData?,
) {
): AlertDialog? {
crashReportData.throwIfDialogUnusable(message.toString())

Timber.i("Error dialog displayed")

val helpAction = crashReportData?.helpAction?.takeIf { it.canExecute(this) }

try {
return try {
AlertDialog
.Builder(this)
.create {
Expand Down Expand Up @@ -357,6 +366,7 @@ fun Context.showError(
// issue 12718: activity provided by `context` was not running
Timber.w(ex, "unable to display error dialog")
crashReportData?.sendCrashReport()
null
}
}

Expand Down
96 changes: 66 additions & 30 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.database.SQLException
import android.database.sqlite.SQLiteDatabaseCorruptException
import android.graphics.Color
import android.graphics.PixelFormat
import android.os.Build
Expand Down Expand Up @@ -68,6 +69,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.withStarted
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.work.NetworkType
Expand Down Expand Up @@ -137,6 +139,7 @@ import com.ichi2.anki.deckpicker.SyncIconState
import com.ichi2.anki.dialogs.AsyncDialogFragment
import com.ichi2.anki.dialogs.BackupPromptDialog
import com.ichi2.anki.dialogs.CreateDeckDialog
import com.ichi2.anki.dialogs.DatabaseErrorDialog
import com.ichi2.anki.dialogs.DatabaseErrorDialog.CustomExceptionData
import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
import com.ichi2.anki.dialogs.DeckPickerAnalyticsOptInDialog
Expand Down Expand Up @@ -197,6 +200,7 @@ import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity
import com.ichi2.anki.utils.Destination
import com.ichi2.anki.utils.ShortcutUtils
import com.ichi2.anki.utils.awaitDismissal
import com.ichi2.anki.utils.ext.dismissAllDialogFragments
import com.ichi2.anki.utils.ext.doOnScrolled
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
Expand All @@ -205,6 +209,7 @@ import com.ichi2.anki.utils.ext.positionIsVisible
import com.ichi2.anki.utils.ext.setFragmentResultListener
import com.ichi2.anki.utils.ext.setImageDrawableSafe
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.utils.observeMessages
import com.ichi2.anki.widgets.DeckAdapter
import com.ichi2.anki.widgets.DeckHierarchyLinesDecoration
import com.ichi2.anki.worker.SyncMediaWorker
Expand All @@ -231,8 +236,11 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbCorruptException
import net.ankiweb.rsdroid.Translations
import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException.BackendCollectionNotOpenException
import timber.log.Timber
Expand Down Expand Up @@ -498,6 +506,34 @@ open class DeckPicker :
)
}

private fun findDatabaseRecovery() =
supportFragmentManager.fragments.filterIsInstance<DatabaseErrorDialog>().firstOrNull {
it.lifecycle.currentState != Lifecycle.State.DESTROYED
}

private suspend fun awaitDatabaseRecovery(): Boolean {
var corruptionPromptClosed = false
while (true) {
val recovery = findDatabaseRecovery() ?: return corruptionPromptClosed
if (recovery.requireDialogType() == DatabaseErrorDialogType.DIALOG_LOAD_FAILED) corruptionPromptClosed = true
recovery.lifecycle.currentStateFlow.first { it == Lifecycle.State.DESTROYED }
yield()
}
}

private suspend fun withStartedAfterRecovery(block: (afterCorruptionPrompt: Boolean) -> Unit) {
var afterCorruptionPrompt = false
do {
if (awaitDatabaseRecovery()) afterCorruptionPrompt = true
val recoveryOpened =
withStarted {
val opened = findDatabaseRecovery() != null
if (!opened) block(afterCorruptionPrompt)
opened
}
} while (recoveryOpened)
}

// ----------------------------------------------------------------------------
// ANDROID ACTIVITY METHODS
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -951,6 +987,12 @@ open class DeckPicker :
fragment?.refreshInterface()
}

fun onCreatedDeckSelected(param: Unit) {
updateDeckList()
tryShowStudyOptionsPanel()
invalidateOptionsMenu()
}

fun onStartupResponse(response: StartupResponse) {
Timber.d("onStartupResponse: %s", response)
when (response) {
Expand Down Expand Up @@ -992,11 +1034,15 @@ open class DeckPicker :
}

fun onError(errorMessage: String) {
AlertDialog
.Builder(this)
.setTitle(CommonString.vague_error)
.setMessage(errorMessage)
.show()
launchCatchingTask {
withStartedAfterRecovery {
AlertDialog
.Builder(this@DeckPicker)
.setTitle(CommonString.vague_error)
.setMessage(errorMessage)
.show()
}
}
}

viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted)
Expand All @@ -1015,6 +1061,14 @@ open class DeckPicker :
viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon)
viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts)
viewModel.onError.launchCollectionInLifecycleScope(::onError)
observeMessages(viewModel.pendingErrors, viewModel::errorShown) { error ->
var dialog: AlertDialog? = null
withStartedAfterRecovery { afterCorruptionPrompt ->
val corruption = error is BackendDbCorruptException || error is SQLiteDatabaseCorruptException
if (!afterCorruptionPrompt || !corruption) dialog = showException(error)
}
dialog?.awaitDismissal()
}
viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler)
viewModel.flowOfOptionsMenuState.filterNotNull().launchCollectionInLifecycleScope(::onOptionsMenuUpdated)
viewModel.flowOfStudiedTodayStats.launchCollectionInLifecycleScope(::onStudiedTodayChanged)
Expand All @@ -1025,6 +1079,7 @@ open class DeckPicker :
viewModel.flowOfFocusedDeck.launchCollectionInLifecycleScope(::onFocusedDeckChanged)
viewModel.flowOfResizingDividerVisible.launchCollectionInLifecycleScope(::onResizingDividerVisibilityChanged)
viewModel.flowOfDecksReloaded.launchCollectionInLifecycleScope(::onDecksReloaded)
viewModel.flowOfCreatedDeckSelected.launchCollectionInLifecycleScope(::onCreatedDeckSelected)
viewModel.flowOfStartupResponse.filterNotNull().launchCollectionInLifecycleScope(::onStartupResponse)
viewModel.flowOfShowContextMenu.launchCollectionInLifecycleScope(::showDeckPickerContextMenu)
viewModel.flowOfShowRightClickContextMenu.launchCollectionInLifecycleScope(::showDeckPickerRightClickContextMenu)
Expand Down Expand Up @@ -2299,12 +2354,12 @@ open class DeckPicker :
val study =
withCol {
decks.select(did)
viewModel.focusedDeck = did
currentDeckStudyState()
}
deckListAdapter.updateSelectedDeck(did)
// Also forget the last deck used by the Browser
CardBrowser.clearLastDeckId()
viewModel.focusedDeck = did

if (study.availability == StudyAvailability.CardsAvailable) {
openReviewerOrStudyOptions(selectionType)
Expand Down Expand Up @@ -2415,41 +2470,22 @@ open class DeckPicker :
* [StudyOptionsFragment] panel follow the new deck instead of the previously current deck, which
* is often the hidden Default deck.
*/
private fun onDeckCreated(deckId: DeckId) =
launchCatchingTask {
viewModel.selectDeck(deckId).join()
updateDeckList()
tryShowStudyOptionsPanel()
invalidateOptionsMenu()
}
private fun onDeckCreated(deckId: DeckId) = viewModel.selectCreatedDeck(deckId)

/**
* Deletes the provided deck, child decks, and all cards inside.
* @param did ID of the deck to delete
*/
fun deleteDeck(did: DeckId) =
launchCatchingTask {
viewModel.deleteDeck(did).join()
}
fun deleteDeck(did: DeckId) = viewModel.deleteDeck(did)

private fun deleteSelectedDeck() =
launchCatchingTask {
viewModel.deleteSelectedDeck().join()
}
private fun deleteSelectedDeck() = viewModel.deleteSelectedDeck(shownDeckId = fragment?.shownDeckId)

@NeedsTest("14285: regression test to ensure UI is updated after this call")
fun rebuildFiltered(did: DeckId) {
launchCatchingTask {
viewModel.rebuildFilteredDeck(did).join()
updateDeckList()
tryShowStudyOptionsPanel()
}
viewModel.rebuildFilteredDeck(did)
}

private fun emptyFiltered(did: DeckId) {
launchCatchingTask {
viewModel.emptyFilteredDeck(did).join()
}
viewModel.emptyFilteredDeck(did)
}

override fun onAttachedToWindow() {
Expand Down
18 changes: 17 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.ichi2.anki.utils.ext.launchCollectionInViewLifecycleScope
import com.ichi2.anki.utils.ext.setFragmentResultListener
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.ui.CollectionMediaImageGetter
import kotlinx.coroutines.CancellationException
import org.intellij.lang.annotations.Language
import timber.log.Timber

Expand All @@ -76,6 +77,11 @@ class StudyOptionsFragment :
@VisibleForTesting
internal val viewModel: StudyOptionsViewModel by viewModels()

val selectedDeckId: DeckId get() = viewModel.selectedDeckId

var shownDeckId: DeckId? = null
private set

private lateinit var deckInfoLayout: Group
private lateinit var buttonStart: Button
private lateinit var textDeckName: TextView
Expand Down Expand Up @@ -351,7 +357,16 @@ class StudyOptionsFragment :

fun refreshInterface() {
Timber.d("Refreshing StudyOptionsFragment")
launchCatchingViewTask { viewModel.refreshData() }
launchCatchingViewTask {
try {
viewModel.refreshData()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
val deckPicker = activity as? DeckPicker ?: throw e
deckPicker.viewModel.reportError(e)
}
}
}

private fun rebuildUi(state: StudyOptionsState) {
Expand All @@ -374,6 +389,7 @@ class StudyOptionsFragment :
displayedDeckId = viewModel.selectedDeckId

updateDeckNameView(state)
shownDeckId = state.deckIdOrNull()

when (state) {
is StudyOptionsState.Empty -> {
Expand Down
Loading
Loading