diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index c15837bc3d71..a27cc012ec4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -16,6 +16,7 @@ package com.ichi2.anki.ui.windows.reviewer import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import anki.collection.OpChanges import anki.frontend.SetSchedulingStatesRequest import anki.scheduler.CardAnswer.Rating @@ -60,7 +61,10 @@ import com.ichi2.anki.servicelayer.isSuspendNoteAvailable import com.ichi2.anki.settings.Prefs import com.ichi2.anki.tryRedo import com.ichi2.anki.tryUndo +import com.ichi2.anki.ui.windows.reviewer.autoadvance.AnswerAction import com.ichi2.anki.ui.windows.reviewer.autoadvance.AutoAdvance +import com.ichi2.anki.ui.windows.reviewer.autoadvance.AutoAdvanceAction +import com.ichi2.anki.ui.windows.reviewer.autoadvance.QuestionAction import com.ichi2.anki.utils.CollectionPreferences import com.ichi2.anki.utils.Destination import com.ichi2.anki.utils.ext.answerCard @@ -83,7 +87,8 @@ class ReviewerViewModel( savedStateHandle: SavedStateHandle, ) : CardViewerViewModel(savedStateHandle), ChangeManager.Subscriber, - BindingProcessor { + BindingProcessor, + AutoAdvance.ActionListener { private var queueState: Deferred = asyncIO { withCol { sched.currentQueueState() } @@ -123,7 +128,7 @@ class ReviewerViewModel( private val stateMutationJs: Deferred = asyncIO { withCol { cardStateCustomizer } } private var typedAnswer = "" - private val autoAdvance = AutoAdvance(this) + private val autoAdvance = AutoAdvance(viewModelScope, this, currentCard) private val isHtmlTypeAnswerEnabled = Prefs.isHtmlTypeAnswerEnabled val answerTimer = AnswerTimer() @@ -714,6 +719,18 @@ class ReviewerViewModel( return true } + override suspend fun onAutoAdvanceAction(action: AutoAdvanceAction) { + when (action) { + QuestionAction.SHOW_ANSWER -> onShowAnswer() + QuestionAction.SHOW_REMINDER -> actionFeedbackFlow.emit(TR.studyingQuestionTimeElapsed()) + AnswerAction.BURY_CARD -> buryCard() + AnswerAction.ANSWER_AGAIN -> answerCard(Rating.AGAIN) + AnswerAction.ANSWER_GOOD -> answerCard(Rating.GOOD) + AnswerAction.ANSWER_HARD -> answerCard(Rating.HARD) + AnswerAction.SHOW_REMINDER -> actionFeedbackFlow.emit(TR.studyingAnswerTimeElapsed()) + } + } + // Based in https://github.com/ankitects/anki/blob/1f95d030bbc7ebcc004ffe1e2be2a320c9fe1e94/qt/aqt/reviewer.py#L201 // and https://github.com/ankitects/anki/blob/1f95d030bbc7ebcc004ffe1e2be2a320c9fe1e94/qt/aqt/reviewer.py#L219 override fun opExecuted( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt new file mode 100644 index 000000000000..935851d1925d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.anki.libanki.DeckConfig +import com.ichi2.anki.libanki.DeckConfig.Companion.ANSWER_ACTION + +enum class AnswerAction( + val code: Int, +) : AutoAdvanceAction { + BURY_CARD(0), + ANSWER_AGAIN(1), + ANSWER_GOOD(2), + ANSWER_HARD(3), + SHOW_REMINDER(4), + ; + + companion object { + fun from(code: Int): AnswerAction = AnswerAction.entries.firstOrNull { it.code == code } ?: BURY_CARD + + val DeckConfig.answerAction: AnswerAction + get() = AnswerAction.from(jsonObject.optInt(ANSWER_ACTION)) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt index 7adbef72d66f..a07ecd14dc21 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt @@ -15,15 +15,13 @@ */ package com.ichi2.anki.ui.windows.reviewer.autoadvance -import anki.scheduler.CardAnswer.Rating -import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.asyncIO -import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.libanki.Card -import com.ichi2.anki.reviewer.AutomaticAnswerAction -import com.ichi2.anki.ui.windows.reviewer.ReviewerViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Implementation of the `Auto Advance` deck options @@ -36,8 +34,18 @@ import kotlinx.coroutines.delay * @see AutoAdvanceSettings */ class AutoAdvance( - val viewModel: ReviewerViewModel, + private val scope: CoroutineScope, + private val listener: ActionListener, + initialCard: Deferred, ) { + /** + * Listens to the `Auto Advance` actions set in Deck options, + * which can be either a [QuestionAction] or a [AnswerAction]. + */ + fun interface ActionListener { + suspend fun onAutoAdvanceAction(action: AutoAdvanceAction) + } + var isEnabled = false set(value) { field = value @@ -49,9 +57,8 @@ class AutoAdvance( private var answerActionJob: Job? = null private var settings = - viewModel.asyncIO { - val card = viewModel.currentCard.await() - AutoAdvanceSettings.createInstance(card.currentDeckId()) + scope.asyncIO { + AutoAdvanceSettings.createInstance(initialCard.await().currentDeckId()) } private suspend fun durationToShowQuestionFor() = settings.await().durationToShowQuestionFor @@ -72,7 +79,7 @@ class AutoAdvance( fun onCardChange(card: Card) { cancelQuestionAndAnswerActionJobs() settings = - viewModel.asyncIO { + scope.asyncIO { AutoAdvanceSettings.createInstance(card.currentDeckId()) } } @@ -82,12 +89,9 @@ class AutoAdvance( if (!durationToShowQuestionFor().isPositive() || !isEnabled) return questionActionJob = - viewModel.launchCatchingIO { + scope.launch { delay(durationToShowQuestionFor()) - when (questionAction()) { - QuestionAction.SHOW_ANSWER -> viewModel.onShowAnswer() - QuestionAction.SHOW_REMINDER -> showReminder(TR.studyingQuestionTimeElapsed()) - } + listener.onAutoAdvanceAction(questionAction()) } } @@ -96,21 +100,9 @@ class AutoAdvance( if (!durationToShowAnswerFor().isPositive() || !isEnabled) return answerActionJob = - viewModel.launchCatchingIO { + scope.launch { delay(durationToShowAnswerFor()) - when (answerAction()) { - AutomaticAnswerAction.BURY_CARD -> viewModel.buryCard() - AutomaticAnswerAction.ANSWER_AGAIN -> viewModel.answerCard(Rating.AGAIN) - AutomaticAnswerAction.ANSWER_HARD -> viewModel.answerCard(Rating.HARD) - AutomaticAnswerAction.ANSWER_GOOD -> viewModel.answerCard(Rating.GOOD) - AutomaticAnswerAction.SHOW_REMINDER -> showReminder(TR.studyingAnswerTimeElapsed()) - } + listener.onAutoAdvanceAction(answerAction()) } } - - private fun showReminder(message: String) { - viewModel.launchCatchingIO { - viewModel.actionFeedbackFlow.emit(message) - } - } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceAction.kt new file mode 100644 index 000000000000..85e42a98eeeb --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceAction.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +/** + * Common interface for all actions triggered by Auto Advance. + * Implemented by [QuestionAction] and [AnswerAction]. + */ +sealed interface AutoAdvanceAction diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt index 08186e2793c8..0d44d59a306a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt @@ -17,8 +17,7 @@ package com.ichi2.anki.ui.windows.reviewer.autoadvance import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.reviewer.AutomaticAnswerAction -import com.ichi2.anki.reviewer.AutomaticAnswerAction.Companion.answerAction +import com.ichi2.anki.ui.windows.reviewer.autoadvance.AnswerAction.Companion.answerAction import com.ichi2.anki.ui.windows.reviewer.autoadvance.QuestionAction.Companion.questionAction import kotlin.time.Duration import kotlin.time.DurationUnit @@ -26,7 +25,7 @@ import kotlin.time.toDuration data class AutoAdvanceSettings( val questionAction: QuestionAction, - val answerAction: AutomaticAnswerAction, + val answerAction: AnswerAction, val durationToShowQuestionFor: Duration, val durationToShowAnswerFor: Duration, val waitForAudio: Boolean, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt index 89a883f185d6..952e5d49b34d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt @@ -20,7 +20,7 @@ import com.ichi2.anki.libanki.DeckConfig.Companion.QUESTION_ACTION enum class QuestionAction( val code: Int, -) { +) : AutoAdvanceAction { SHOW_ANSWER(0), SHOW_REMINDER(1), ; diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceTest.kt new file mode 100644 index 000000000000..0deb87abc19c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.anki.libanki.Card +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class AutoAdvanceTest { + private val testScope = TestScope(StandardTestDispatcher()) + + @MockK + lateinit var listener: AutoAdvance.ActionListener + + @MockK + lateinit var card: Card + + private lateinit var autoAdvance: AutoAdvance + + private val defaultSettings get() = + AutoAdvanceSettings( + questionAction = QuestionAction.SHOW_ANSWER, + answerAction = AnswerAction.ANSWER_GOOD, + durationToShowQuestionFor = 5.seconds, + durationToShowAnswerFor = 5.seconds, + waitForAudio = false, + ) + + @Before + fun setUp() { + MockKAnnotations.init(this) + every { card.currentDeckId() } returns 1L + coEvery { listener.onAutoAdvanceAction(any()) } just Runs + mockkObject(AutoAdvanceSettings) + coEvery { AutoAdvanceSettings.createInstance(any()) } returns defaultSettings + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun createAutoAdvance(): AutoAdvance = AutoAdvance(testScope, listener, CompletableDeferred(card)) + + @Test + fun `onShowQuestion triggers action after delay when enabled`() = + testScope.runTest { + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + autoAdvance.onShowQuestion() + + // Advance time less than duration (4s) - should NOT fire yet + advanceTimeBy(4.seconds) + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + + // Advance past duration (5s total) - SHOULD fire + advanceTimeBy(2.seconds) + coVerify(exactly = 1) { listener.onAutoAdvanceAction(QuestionAction.SHOW_ANSWER) } + } + + @Test + fun `onShowAnswer triggers action after delay when enabled`() = + testScope.runTest { + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + autoAdvance.onShowAnswer() + + // Advance time less than duration (4s) - should NOT fire yet + advanceTimeBy(4.seconds) + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + + // Advance past duration (5s total) - SHOULD fire + advanceTimeBy(2.seconds) + coVerify(exactly = 1) { listener.onAutoAdvanceAction(AnswerAction.ANSWER_GOOD) } + } + + @Test + fun `actions do not trigger if AutoAdvance is disabled`() = + testScope.runTest { + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = false + testScheduler.advanceUntilIdle() + + autoAdvance.onShowQuestion() + advanceTimeBy(6.seconds) // Past the 5s delay + + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + } + + @Test + fun `setting isEnabled to false cancels pending jobs`() = + testScope.runTest { + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + autoAdvance.onShowQuestion() + + // Advance partially + advanceTimeBy(2.seconds) + + // Disable mid-wait + autoAdvance.isEnabled = false + + // Advance past the original trigger time + advanceTimeBy(4.seconds) + + // Should not have fired + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + } + + @Test + fun `zero duration does not trigger action`() = + testScope.runTest { + coEvery { AutoAdvanceSettings.createInstance(any()) } returns + defaultSettings.copy(durationToShowQuestionFor = 0.seconds) + + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + autoAdvance.onShowQuestion() + advanceTimeBy(10.seconds) + + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + } + + @Test + fun `onCardChange reloads settings`() = + testScope.runTest { + val initialSettings = defaultSettings + val newSettings = + initialSettings.copy( + questionAction = QuestionAction.SHOW_REMINDER, + durationToShowQuestionFor = 2.seconds, + ) + + coEvery { AutoAdvanceSettings.createInstance(any()) } returns initialSettings andThen newSettings + + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + val newCard = mockk(relaxed = true) + every { newCard.currentDeckId() } returns 2L + autoAdvance.onCardChange(newCard) + testScheduler.advanceUntilIdle() + + autoAdvance.onShowQuestion() + advanceTimeBy(3.seconds) + + coVerify { listener.onAutoAdvanceAction(QuestionAction.SHOW_REMINDER) } + } + + @Test + fun `switching from Question to Answer cancels Question job`() = + testScope.runTest { + autoAdvance = createAutoAdvance() + autoAdvance.isEnabled = true + testScheduler.advanceUntilIdle() + + // Start question timer (5s) + autoAdvance.onShowQuestion() + advanceTimeBy(2.seconds) + + autoAdvance.onShowAnswer() + + // Advance time enough that Question timer WOULD have fired (total 6s) + advanceTimeBy(4.seconds) + + // Verify QuestionAction was NEVER fired + coVerify(exactly = 0) { listener.onAutoAdvanceAction(QuestionAction.SHOW_ANSWER) } + + // AnswerAction should be pending (needs 1 more second to reach 5s) + coVerify(exactly = 0) { listener.onAutoAdvanceAction(any()) } + advanceTimeBy(2.seconds) + coVerify(exactly = 1) { listener.onAutoAdvanceAction(AnswerAction.ANSWER_GOOD) } + } +}