Skip to content

feat: add ViewModel progress infrastructure [DEV]#20729

Open
criticalAY wants to merge 1 commit intoankidroid:mainfrom
criticalAY:feat/withprogress
Open

feat: add ViewModel progress infrastructure [DEV]#20729
criticalAY wants to merge 1 commit intoankidroid:mainfrom
criticalAY:feat/withprogress

Conversation

@criticalAY
Copy link
Copy Markdown
Contributor

@criticalAY criticalAY commented Apr 12, 2026

Purpose / Description

Introduce a common pattern for progress notifications in ViewModels, decoupling progress dialog management from Activity/Fragment context.

Fixes

Approach

  • The ViewModel exposes a state flow. The Activity/Fragment calls observeProgress(viewModel) once, and the LoadingDialogFragment is shown/dismissed automatically based on state changes.
  • Cancellation follows the same pattern as the old API (pass an onCancel callback to withProgress). The observer wires it to the dialog's cancel listener. (I actually followed a different pattern here then I saw David's comment on original issue)

How Has This Been Tested?

Unit test

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 force-pushed the feat/withprogress branch 2 times, most recently from b6fabdb to c278581 Compare April 12, 2026 14:47
Copy link
Copy Markdown
Member

@david-allison david-allison left a comment

Choose a reason for hiding this comment

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

This looks exceptional. One question on the race condition

Comment thread AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressManager.kt Outdated
Comment on lines +46 to +48
showJob?.cancel()
showJob = null
dismissLoadingDialog()
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.

Is there a race condition if this and Active are running simultaneously?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ahh oki, quick fix, here are tests to verify (Used both Gemini and Claude to get the tests)

@Test
    fun `Idle during show delay cancels pending dialog without race`() =
        runTest {
            val manager = ProgressManager()
            val operationBlocker = CompletableDeferred<Unit>()
            var dialogShown = false
            var dialogDismissed = false

            // Simulate what observeProgress does: collect state, delay before "showing"
            val observer =
                launch {
                    var showJob: kotlinx.coroutines.Job? = null
                    manager.progress.collect { state ->
                        when (state) {
                            is ViewModelProgress.Idle -> {
                                showJob?.cancel()
                                showJob = null
                                dialogDismissed = true
                            }
                            is ViewModelProgress.Active -> {
                                showJob =
                                    launch {
                                        kotlinx.coroutines.delay(600)
                                        dialogShown = true
                                    }
                            }
                        }
                    }
                }

            // Start an operation — state goes Active, showJob starts its 600ms delay
            val operation =
                launch {
                    manager.withProgress(message = "quick op") {
                        operationBlocker.await()
                    }
                }

            // Advance 300ms — halfway through the delay, dialog NOT yet shown
            testScheduler.advanceTimeBy(300)
            assertEquals(false, dialogShown, "Dialog should not be shown during delay")

            // Operation completes — state goes Idle, showJob gets cancelled
            operationBlocker.complete(Unit)
            testScheduler.advanceUntilIdle()

            // Advance well past the 600ms — dialog should STILL not be shown
            testScheduler.advanceTimeBy(1000)
            assertEquals(false, dialogShown, "Dialog must not show after Idle cancelled the showJob")
            assertEquals(true, dialogDismissed, "Idle branch should have run")
            assertIs<ViewModelProgress.Idle>(manager.progress.value)

            observer.cancel()
            operation.cancel()
        }

    /**
     * Verifies that rapid Active state emissions during the 600ms delay
     * do not bypass it. Uses the fixed observer logic with a `dialogVisible`
     * guard — updates only reach the dialog after the delay has elapsed.
     */
    @Test
    fun `fixed observer - rapid Active states do not bypass the 600ms delay`() =
        runTest {
            val progressFlow = MutableStateFlow<ViewModelProgress>(ViewModelProgress.Idle)
            var dialogShownCount = 0

            val observer =
                launch {
                    var showJob: kotlinx.coroutines.Job? = null
                    var dialogVisible = false
                    progressFlow.collect { state ->
                        when (state) {
                            is ViewModelProgress.Idle -> {
                                showJob?.cancel()
                                showJob = null
                                dialogVisible = false
                            }
                            is ViewModelProgress.Active -> {
                                if (showJob == null) {
                                    showJob =
                                        launch {
                                            kotlinx.coroutines.delay(600)
                                            dialogVisible = true
                                            dialogShownCount++
                                        }
                                } else if (dialogVisible) {
                                    // Only update if dialog is actually showing
                                    dialogShownCount++
                                }
                                // If delay is still pending, do nothing — no bypass
                            }
                        }
                    }
                }

            // First Active — starts the 600ms delay
            progressFlow.value =
                ViewModelProgress.Active(
                    message = "Loading...",
                    amount = ProgressContext.Amount(current = 1, max = 100),
                    cancellable = false,
                )
            testScheduler.advanceTimeBy(100)
            assertEquals(0, dialogShownCount, "Dialog should not show before 600ms")

            // Second Active at t=100ms — with the fix, this is ignored during delay
            progressFlow.value =
                ViewModelProgress.Active(
                    message = "Loading...",
                    amount = ProgressContext.Amount(current = 2, max = 100),
                    cancellable = false,
                )
            testScheduler.advanceTimeBy(1)
            assertEquals(0, dialogShownCount, "Fixed: dialog must not show at 101ms")

            // Advance past the delay — now the dialog shows exactly once
            testScheduler.advanceTimeBy(600)
            assertEquals(1, dialogShownCount, "Dialog should show once after the full 600ms delay")

            observer.cancel()
        }
        ```

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not adding them to actual tests, feels too much maybe

Comment thread AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt Outdated
@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Apr 15, 2026
Introduce a common pattern for progress notifications in ViewModels,
decoupling progress dialog management from Activity/Fragment context.
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants