Skip to content

fix: deckpicker viewmodel errors - #22504

Open
criticalAY wants to merge 25 commits into
ankidroid:mainfrom
criticalAY:fix/deckpicker-viewmodel-errors
Open

criticalAY wants to merge 25 commits into
ankidroid:mainfrom
criticalAY:fix/deckpicker-viewmodel-errors

Conversation

@criticalAY

@criticalAY criticalAY commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Note

Assisted-by: Opus 5.5

Purpose / Description

Deck Picker crashed if the backend failed while deleting a deck, deleting empty cards, rebuilding or emptying a filtered deck, selecting a new deck or refreshing the list. The ViewModel now catches these errors and shows them, as David asked on #21831.

Fixes

Approach

check commit messages

How Has This Been Tested?

added tests, the error ones crashed before the fix

Learning (optional, can help others)

NA

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@criticalAY criticalAY added the Blocked by dependency Currently blocked by some other dependent / related change label Oct 13, 2026
@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch 2 times, most recently from eec65cf to d543cc9 Compare October 13, 2026 23:36
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Snapshot diff report: Previous regressions were resolved by the latest commit.

@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from d543cc9 to e3885b9 Compare October 14, 2026 01:19
@criticalAY criticalAY removed Blocked by dependency Currently blocked by some other dependent / related change Has Conflicts labels Oct 14, 2026
@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from e3885b9 to 0fb8434 Compare October 14, 2026 02:22
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Oct 15, 2026
@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from 0fb8434 to d8d16bd Compare October 16, 2026 04:17
@criticalAY

Copy link
Copy Markdown
Contributor Author

Legit failure, fixed it

@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Oct 16, 2026

@mikehardy mikehardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Assisted-by: cursor-grok-4.6-medium

The stopped-lifecycle and database-corruption tests from the last request are on this tip. ViewModel failures now go through MessageQueue into showException instead of crashing.

Rebased d8d16bd onto main (7e29d24) cleanly and did not push. Local tip is 695da54f. Lint (lintAll, ktlintCheck, lint-rules:test) passed in 55s. Unit (jacocoUnitTestReport) failed once on CardContentProviderStartupTest and a flaky ReviewerNoParamTest; both passed alone, and neither class is in this diff. DeckPickerErrorTest was 9/9. Emulator tests on emulator-5554 (API 36, playRelease) passed. The XML noise was the usual phone-sized failures plus a tablet assumption in DeckPickerImportTest, which this diff does not touch. ReviewRemindersTest was 9/9.

showException is the same dialog path runCatching already used, including corruption recovery. Delete, empty cards, filtered rebuild and empty, select, and deck-list reload catch into a queue that DeckPicker drains after CREATED and withStarted. Rebuild refresh stays on the ViewModel flow. The Activity no longer also calls updateDeckList and tryShowStudyOptionsPanel after join.

some acceptable findings, no action required, just leaving for posterity: Approach is "check commit messages"; launchCatchingIO still uses the string onError SharedFlow.

@mikehardy mikehardy added Needs Second Approval Has one approval, one more approval to merge and removed Needs Review labels Oct 16, 2026
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Oct 16, 2026
@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from d8d16bd to ac26a8c Compare October 21, 2026 21:28
@criticalAY criticalAY removed Needs Author Reply Waiting for a reply from the original author Has Conflicts labels Oct 21, 2026
@david-allison

david-allison commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

@criticalAY Tests are failing

@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from ac26a8c to a455fa0 Compare October 22, 2026 07:06
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Oct 23, 2026
A refresh that couldn't read the current deck crashed the app while
redrawing the deck list. The list now keeps showing the last decks it
loaded, and the error is shown like the others, so database corruption,
for example while filtering, opens the recovery dialog. A refresh reads
the current deck first, so it still reports one error.

Assisted-by: Claude Opus 5.5
… queue

Errors from actions such as renaming, opening deck options or
unburying were plain messages: they were lost while the screen was
stopped and corruption didn't open the recovery dialog. They now use
the same queue as the other Deck Picker errors.

Assisted-by: Claude Opus 5.5
The deletion test clicked the "Other" deck as soon as the deck tree had
loaded, but the rows are drawn a moment later, so on a slow runner the
click could come before the row existed. It now waits until both decks
are shown.

Assisted-by: Claude Opus 5.5
The next queued error, or a message such as a failed background backup,
opened on top of the database recovery dialog and hid it. They now wait
until no recovery dialog is open and the screen is started, and check
again right before they show, in case another recovery dialog opened
while they waited. They also let a closing dialog be fully removed
first, so a queued corruption error opens recovery instead of becoming
a notification.

Assisted-by: Claude Opus 5.5
Deleting a deck, and emptying or rebuilding a filtered deck, take more
than one backend call. If a later one failed, for example selecting the
deck that replaces a deleted one, the error was shown but the deck list
and the tablet study options kept showing the old state. The same
happened when selecting a newly created deck failed, so the new deck was
missing from the list. The Deck Picker now reloads after these failures.
It skips the reload when the database is corrupt, as the recovery dialog
handles that and a reload would only queue a second recovery prompt.

Assisted-by: Claude Opus 5.5
On tablets, Delete deck in the toolbar and Shift+Delete delete the
selected deck without asking, because the side panel shows it. If a
failure left the panel showing another deck, for example one whose
deletion had already gone through, or the panel showed something else
such as review reminders, they deleted a deck that wasn't shown. They
now go by the deck the panel last drew, since its data can change
before it redraws, and reload the deck list when that isn't the
selected deck or the panel shows no deck.

Assisted-by: Claude Opus 5.5
@criticalAY
criticalAY force-pushed the fix/deckpicker-viewmodel-errors branch from 68f6a52 to cbed887 Compare October 28, 2026 21:10
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Oct 30, 2026
The side panel recorded the deck it had drawn by asking its ViewModel,
which a refresh can move to another deck before the panel draws it. If
the panel redrew its older state in between, for example when it
started again during the refresh, Shift+Delete deleted the new deck
while the panel still showed the old one. The panel's state now carries
its deck, and the panel records that deck when it draws the state.

Assisted-by: Claude Opus 5.5
A refresh reads the current deck first and moves the deck list's focus
to it at the end, so a deck selected while the refresh ran was replaced
by the one the refresh had read, and the list jumped back to it. The
refresh now leaves the focus alone if it changed in the meantime.

Assisted-by: Claude Opus 5.5
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
A refresh checked that nobody had changed the focused deck and then set
it, but a deck selected between the check and the update was still
replaced by the refresh's older deck. The check and the update now run
together on the collection queue, and selecting or deleting a deck
updates the focus there too, so they can't interleave.

Assisted-by: Claude Opus 5.5
The deck list only shows a deck as collapsed or expanded once the
collection has saved it, so a second tap before that save finished read
the old state and saved the same value again. Collapsing a deck while a
refresh was reading the decks could also be undone when that refresh
showed its older copy of the list. Collapsing and expanding now happen
one at a time, after any refresh that is already running.

Assisted-by: Claude Opus 5.5
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

GPT-6.1

Subject: [PATCH] PR 22504: test replacement refresh expansion race

diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt
new file mode 100644
index 0000000000..3acce2a009
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerReplacementRefreshReviewTest.kt
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.lifecycle.viewModelScope
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import anki.decks.DeckTreeNode
+import com.ichi2.anki.deckpicker.DeckPickerViewModel
+import com.ichi2.anki.libanki.sched.DeckNode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class DeckPickerReplacementRefreshReviewTest : RobolectricTest() {
+    @After
+    fun resetBackend() = BackendFactory.setOverride(null)
+
+    @Test
+    fun `refresh replacing the one awaited by a toggle must not publish stale expansion`() {
+        val readingFirst = CountDownLatch(1)
+        val releaseFirst = CountDownLatch(1)
+        val pauseFirst = AtomicBoolean(false)
+        runBlocking { CollectionManager.discardBackend() }
+        BackendFactory.setOverride {
+            object : Backend() {
+                override fun deckTree(now: Long): DeckTreeNode {
+                    if (pauseFirst.compareAndSet(true, false)) {
+                        readingFirst.countDown()
+                        check(releaseFirst.await(10, TimeUnit.SECONDS))
+                    }
+                    return super.deckTree(now)
+                }
+            }
+        }
+        val parent = addDeck("Parent")
+        addDeck("Parent::Child")
+        val model = DeckPickerViewModel()
+        val initial = model.reloadDeckCounts()
+        advanceRobolectricLooperUntil { initial.isCompleted }
+        val initiallyCollapsed = model.dueTree!!.find(parent)!!.collapsed
+        val treeField = model.javaClass.getDeclaredField("flowOfDeckDueTree").apply { isAccessible = true }
+        @Suppress("UNCHECKED_CAST")
+        val original = treeField.get(model) as MutableStateFlow<DeckNode?>
+        val publishingReplacement = CountDownLatch(1)
+        val releaseReplacement = CountDownLatch(1)
+        val gatePublication = AtomicBoolean(true)
+        treeField.set(model, object : MutableStateFlow<DeckNode?> by original {
+            override var value: DeckNode?
+                get() = original.value
+                set(value) {
+                    if (gatePublication.compareAndSet(true, false)) {
+                        publishingReplacement.countDown()
+                        check(releaseReplacement.await(10, TimeUnit.SECONDS))
+                    }
+                    original.value = value
+                }
+        })
+        val previousQueue = CollectionManager.setTestDispatcher(Dispatchers.IO.limitedParallelism(1), useReentrantLock = false)
+        try {
+            addDeck("New deck that makes the replacement snapshot different")
+            pauseFirst.set(true)
+            val first = model.reloadDeckCounts()
+            assertTrue(readingFirst.await(5, TimeUnit.SECONDS))
+            // This action waits for the first refresh, which a subsequent refresh cancels.
+            val toggle = model.toggleDeckExpand(parent)
+            val replacement = model.reloadDeckCounts()
+            releaseFirst.countDown()
+            // A shared lock may let the toggle finish before the replacement reads its tree.
+            // Give that correct implementation a chance to drain Main as well.
+            if (!publishingReplacement.await(2, TimeUnit.SECONDS)) {
+                advanceRobolectricLooperUntil { publishingReplacement.count == 0L }
+            }
+            val toggleCompleted = CountDownLatch(1)
+            toggle.invokeOnCompletion { toggleCompleted.countDown() }
+            val releaser = thread(name = "release-replacement-refresh") {
+                // A correct implementation can serialize the toggle behind this refresh.
+                // Permit that ordering while exposing the stale publication in the current code.
+                toggleCompleted.await(2, TimeUnit.SECONDS)
+                releaseReplacement.countDown()
+            }
+            advanceRobolectricLooperUntil { first.isCompleted && toggle.isCompleted && replacement.isCompleted }
+            releaser.join()
+            assertEquals(!initiallyCollapsed, col.decks.get(parent)!!.collapsed)
+            assertEquals(
+                col.decks.get(parent)!!.collapsed,
+                model.dueTree!!.find(parent)!!.collapsed,
+                "The replacement refresh must not undo the saved expansion in the displayed tree",
+            )
+        } finally {
+            releaseFirst.countDown()
+            releaseReplacement.countDown()
+            treeField.set(model, original)
+            model.viewModelScope.cancel()
+            CollectionManager.setTestDispatcher(previousQueue)
+        }
+    }
+}

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
Waiting for a running refresh before collapsing a deck wasn't enough: a
second refresh could replace the one being waited for and still show a
tree it read before the collapse was saved. A refresh now shows its
tree, and whether the collection has cards, in the same collection call
that reads them, and collapsing reads, saves and updates the deck in one
collection call, so a tree read before a change can't be shown after it.

Assisted-by: Claude Opus 5.5
A refresh moved the focus to the deck it read when it started, unless
the focus had been set in the meantime. A deck selected another way
while it ran, for example by rebuilding a filtered deck, was then
replaced by the older one. The refresh now reads the current deck again
when it sets the focus. It still reads it before showing the tree too,
so a deck it can't read stops the refresh before the list changes.

Assisted-by: Claude Opus 5.5
Emptying a filtered deck selected it, then emptied whichever deck was
selected in a separate collection call, so a deck tapped in between was
emptied instead. It now empties the deck that was chosen.

Assisted-by: Claude Opus 5.5
A failure that kept happening, such as a refresh that couldn't read the
decks, was queued again by every refresh and by the screen restarting,
so the same error came back after each dismissal and sent another crash
report. An error that matches one still waiting is now dropped.

Assisted-by: Claude Opus 5.5
Tapping a deck set the focus once its collection call had returned, so a
deck selected another way in the meantime, for example by long pressing
it, lost the focus to the tapped one. The tap now sets it in the same
collection call that selects the deck, like the other selections, and
the studied today line is written the same way.

Assisted-by: Claude Opus 5.5
Deleting the same deck twice in quick succession, for example by
pressing Shift+Delete twice, deleted it and then showed an error for the
second request. The second request now finds the deck gone and does
nothing.

Assisted-by: Claude Opus 5.5
A corruption error that came in while the recovery dialog was open
waited for it to close and then opened it again, even after the
collection had been repaired. Such an error is now dropped when the
recovery dialog closes. Errors that waited for other database dialogs
are still shown.

Assisted-by: Claude Opus 5.5
On tablets the side panel showed its own refresh errors straight away,
so a failure that it and the deck list both hit was shown up to three
times on each return to the app, without waiting for the screen to start
or for database recovery. The panel now passes them to the deck
picker's queue.

Assisted-by: Claude Opus 5.5
Only a successful refresh hid the loading spinner, so after a failed
first load it kept spinning over an empty screen. A failed refresh now
hides it too.

Assisted-by: Claude Opus 5.5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Second Approval Has one approval, one more approval to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants