diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt index 1d19d84d465d..598bfb8c5423 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantEditorViewModel.kt @@ -20,6 +20,8 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Note import com.ichi2.anki.libanki.NotetypeJson import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import com.ichi2.anki.utils.ext.getAllClozeTextFields import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableSharedFlow @@ -36,9 +38,12 @@ import kotlin.math.max */ class InstantEditorViewModel : ViewModel(), - OnErrorListener { + OnErrorListener, + HasProgress { override val onError = MutableSharedFlow() + override val progressManager = ProgressManager() + /** Errors or Warnings related to the edit fields that might occur when trying to save note */ val instantEditorError = MutableSharedFlow() @@ -123,20 +128,21 @@ class InstantEditorViewModel : * @param skipClozeCheck Indicates whether to skip the cloze field check. * @return A [SaveNoteResult] indicating the outcome of the operation. */ - suspend fun checkAndSaveNote(skipClozeCheck: Boolean = false): SaveNoteResult { - if (skipClozeCheck) { - return saveNote() - } + suspend fun checkAndSaveNote(skipClozeCheck: Boolean = false): SaveNoteResult = + progressManager.withProgress(InstantEditorProgress.SAVING_NOTE) { + if (skipClozeCheck) { + return@withProgress saveNote() + } - val note = editorNote - val result = checkNoteFieldsResponse(note) - if (result is NoteFieldsCheckResult.Failure) { - return SaveNoteResult.Warning(result.localizedMessage) + val note = editorNote + val result = checkNoteFieldsResponse(note) + if (result is NoteFieldsCheckResult.Failure) { + return@withProgress SaveNoteResult.Warning(result.localizedMessage) + } + Timber.d("Note fields check successful, saving note") + instantEditorError.emit(null) + saveNote() } - Timber.d("Note fields check successful, saving note") - instantEditorError.emit(null) - return saveNote() - } /** Adds the note to the collection. * @return If the operation is successful, returns [SaveNoteResult.Success], @@ -409,6 +415,10 @@ class InstantEditorViewModel : } } +enum class InstantEditorProgress { + SAVING_NOTE, +} + /** * Represents the result of saving a note operation. * Has three possible outcomes: `Success`, `Failure`, and `Warning`. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantNoteEditorActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantNoteEditorActivity.kt index 71970f6b1ca6..ac976052a7a3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantNoteEditorActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/instantnoteeditor/InstantNoteEditorActivity.kt @@ -18,6 +18,7 @@ import android.widget.EditText import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope @@ -43,6 +44,8 @@ import com.ichi2.anki.dialogs.startDeckSelection import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.NotetypeJson import com.ichi2.anki.model.SelectableDeck +import com.ichi2.anki.progress.ViewModelProgress +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.withProgress @@ -122,6 +125,7 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed ) setupErrorListeners() + observeProgress(viewModel) { progress -> getString(progress.messageRes) } prepareEditorDialog() registerDeckSelectedHandler(action = ::onDeckSelected) @@ -377,11 +381,24 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed * retrieves the field content, and saves the note */ private fun checkAndSave() { + if (isSaveIgnored()) return extractFieldValues() saveNoteWithProgress(skipClozeCheck = false) } + private fun isSaveIgnored(): Boolean { + if (isFinishing) { + Timber.i("Ignoring save, the editor is closing") + return true + } + if (viewModel.progressManager.progress.value is ViewModelProgress.Active) { + Timber.i("Ignoring save while a note is saving") + return true + } + return false + } + private fun handleSaveNoteResult(result: SaveNoteResult) { when (result) { is SaveNoteResult.Failure -> { @@ -513,7 +530,7 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed AlertDialog.Builder(this).show { message(text = errorMessage) positiveButton(text = TR.actionsSave()) { - saveNoteWithProgress(skipClozeCheck = true) + if (!isSaveIgnored()) saveNoteWithProgress(skipClozeCheck = true) } negativeButton(CommonString.dialog_cancel) } @@ -521,11 +538,7 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed private fun saveNoteWithProgress(skipClozeCheck: Boolean) { lifecycleScope.launch { - val result = - withProgress(resources.getString(CommonString.saving_facts)) { - viewModel.checkAndSaveNote(skipClozeCheck = skipClozeCheck) - } - handleSaveNoteResult(result) + handleSaveNoteResult(viewModel.checkAndSaveNote(skipClozeCheck = skipClozeCheck)) } } @@ -652,3 +665,10 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed SHOW_EDITOR_DIALOG, } } + +@get:StringRes +private val InstantEditorProgress.messageRes: Int + get() = + when (this) { + InstantEditorProgress.SAVING_NOTE -> CommonString.saving_facts + } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorNoClozeSnapshotReviewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorNoClozeSnapshotReviewTest.kt new file mode 100644 index 000000000000..2839ece18b83 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorNoClozeSnapshotReviewTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.instanteditor + +import android.content.DialogInterface +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.ViewModelProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel +import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity +import com.ichi2.testutils.withCollectionAccessOn +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog + +@RunWith(AndroidJUnit4::class) +class InstantEditorNoClozeSnapshotReviewTest : RobolectricTest() { + @Test + fun `confirmation saves the fields that produced the no-cloze warning`() = + runTest { + val originalText = "Hello world" + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, originalText) + } + val activity = + withCollectionAccessOn(Handler(Looper.getMainLooper()).asCoroutineDispatcher()) { + startRegularActivity(intent) + } + onView(withId(R.id.switch_edit_mode_button)).inRoot(isDialog()).perform(click()) + val editor = ShadowDialog.getLatestDialog() as AlertDialog + val viewModel = ViewModelProvider(activity)[InstantEditorViewModel::class.java] + val notesBefore = col.noteCount() + + withCollectionAccessOn(StandardTestDispatcher(testScheduler)) { + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + // This window remains interactive while validation waits in the collection queue. + editor.findViewById(R.id.edit_field_text)!!.setText("") + } + advanceUntilIdle() + val warning = ShadowDialog.getLatestDialog() as AlertDialog + assertTrue("no-cloze warning must be shown", warning !== editor && warning.isShowing) + assertEquals( + CollectionManager.TR.addingYouHaveAClozeDeletionNote(), + warning.findViewById(android.R.id.message)!!.text.toString(), + ) + + warning.getButton(DialogInterface.BUTTON_POSITIVE).performClick() + shadowOf(Looper.getMainLooper()).idle() + advanceUntilIdle() + + assertTrue("confirmed note is saved", activity.isFinishing) + assertEquals("one note added", notesBefore + 1, col.noteCount()) + assertEquals( + "confirmation must not insert unvalidated replacement fields", + originalText, + col.getNote(viewModel.editorNote.id).fields[0], + ) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt index 259517a41b84..4e400724b407 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantEditorViewModelTest.kt @@ -4,16 +4,20 @@ package com.ichi2.anki.instanteditor import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import com.ichi2.anki.CollectionManager import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.instantnoteeditor.InstantEditorProgress import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity import com.ichi2.anki.instantnoteeditor.SaveNoteResult import com.ichi2.anki.libanki.testutils.AnkiTest +import com.ichi2.anki.progress.ViewModelProgress import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) class InstantEditorViewModelTest : RobolectricTest() { @@ -103,6 +107,21 @@ class InstantEditorViewModelTest : RobolectricTest() { assertEquals(CollectionManager.TR.addingTheFirstFieldIsEmpty(), saveNoteResult(result)) } + @Test + fun `saving a note reports progress`() = + runViewModelTest { + editorNote.setField(0, "{{c1::Hello}}") + + progressManager.progress.test { + assertIs(awaitItem()) + checkAndSaveNote() + val active = assertIs>(awaitItem()) + assertEquals(InstantEditorProgress.SAVING_NOTE, active.message) + assertIs(awaitItem()) + expectNoEvents() + } + } + @Test fun testCheckAndSaveNote_NullEditorNote_ReturnsFailure() = runViewModelTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantNoteEditorActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantNoteEditorActivityTest.kt new file mode 100644 index 000000000000..141f719a5ee7 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/instanteditor/InstantNoteEditorActivityTest.kt @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav + +package com.ichi2.anki.instanteditor + +import android.content.DialogInterface +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.ViewModelProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel +import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity +import com.ichi2.anki.progress.ViewModelProgress +import com.ichi2.testutils.withCollectionAccessOn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog +import kotlin.coroutines.CoroutineContext + +@RunWith(AndroidJUnit4::class) +class InstantNoteEditorActivityTest : RobolectricTest() { + @Test + fun `ignored Save does not overwrite the note being saved`() = + runTest { + val originalText = "{{c1::Hello}} world" + val activity = startInstantNoteEditor(originalText) + onView(withId(R.id.switch_edit_mode_button)).inRoot(isDialog()).perform(click()) + val editor = ShadowDialog.getLatestDialog() as AlertDialog + val viewModel = ViewModelProvider(activity)[InstantEditorViewModel::class.java] + val notesBefore = col.noteCount() + val pendingOperations = ArrayDeque() + val dispatcher = + object : CoroutineDispatcher() { + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) { + pendingOperations.addLast(block) + } + } + + withCollectionAccessOn(dispatcher) { + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + assertTrue(viewModel.progressManager.progress.value is ViewModelProgress.Active) + + // Complete field validation, but leave insertion queued while the dialog is interactive. + pendingOperations.removeFirst().run() + assertEquals("note insertion is pending", 1, pendingOperations.size) + editor.findViewById(R.id.edit_field_text)!!.setText("") + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + + while (pendingOperations.isNotEmpty()) { + pendingOperations.removeFirst().run() + } + } + advanceUntilIdle() + + assertTrue("editor closes after saving", activity.isFinishing) + assertEquals("notes added", notesBefore + 1, col.noteCount()) + assertEquals("saved fields must match the validated fields", originalText, col.getNote(viewModel.editorNote.id).fields[0]) + } + + @Test + fun `tapping Save twice in the no-cloze confirmation adds one note`() = + runTest { + startInstantNoteEditor("Hello world") + val notesBefore = col.noteCount() + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + advanceUntilIdle() + val warning = ShadowDialog.getLatestDialog() as AlertDialog + val save = warning.getButton(DialogInterface.BUTTON_POSITIVE) + assertTrue("warning is visible", warning.isShowing) + + withCollectionAccessOn(StandardTestDispatcher(testScheduler)) { + // Queue both button callbacks before AlertDialog's posted dismissal runs. + save.performClick() + save.performClick() + shadowOf(Looper.getMainLooper()).idle() + } + advanceUntilIdle() + assertEquals("notes added", notesBefore + 1, col.noteCount()) + } + + @Test + fun `tapping Save twice while the note is saving adds one note`() = + runTest { + startInstantNoteEditor("{{c1::Hello}} world") + val notesBefore = col.noteCount() + + withCollectionAccessOn(StandardTestDispatcher(testScheduler)) { + repeat(2) { + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + } + } + advanceUntilIdle() + + assertEquals("notes added", notesBefore + 1, col.noteCount()) + } + + @Test + fun `tapping Save again after the note is saved adds one note`() = + runTest { + val activity = startInstantNoteEditor("{{c1::Hello}} world") + val notesBefore = col.noteCount() + + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + advanceUntilIdle() + assertTrue("editor is closing", activity.isFinishing) + + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + advanceUntilIdle() + + assertEquals("notes added", notesBefore + 1, col.noteCount()) + } + + @Test + fun `back while the note is saving does not close the editor`() = + runTest { + val activity = startInstantNoteEditor("{{c1::Hello}} world") + + withCollectionAccessOn(StandardTestDispatcher(testScheduler)) { + onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click()) + activity.onBackPressedDispatcher.onBackPressed() + + assertFalse("editor closed while saving", activity.isFinishing) + } + advanceUntilIdle() + + assertTrue("editor closes once the note is saved", activity.isFinishing) + } + + private suspend fun startInstantNoteEditor(text: String): InstantNoteEditorActivity { + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + return withCollectionAccessOn(Handler(Looper.getMainLooper()).asCoroutineDispatcher()) { + startRegularActivity(intent) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/CollectionManagerTestUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/CollectionManagerTestUtils.kt new file mode 100644 index 000000000000..2680b8a04e05 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/CollectionManagerTestUtils.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import com.ichi2.anki.CollectionManager +import kotlinx.coroutines.CoroutineDispatcher + +suspend fun withCollectionAccessOn( + dispatcher: CoroutineDispatcher, + block: suspend () -> T, +): T { + val previousQueue = CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false) + try { + return block() + } finally { + CollectionManager.setTestDispatcher(previousQueue) + } +}