From a66d777b81eb8868b5d326fc34ccecd4fb1fe248 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:55:15 +0100 Subject: [PATCH 1/3] fix(libanki): clear the notetype cache on undo/redo Matches upstream: pylib/anki/collection.py https://github.com/ankitects/anki/commit/9f3f6bab7dfda8250cc8ff3617c287c6f23e3267 Partially fixes 20510 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/libanki/Collection.kt | 16 ++++++++-- .../com/ichi2/anki/libanki/NotetypesTest.kt | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index e8131fbcec4a..2a38871aae7a 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -1185,14 +1185,26 @@ class Collection( * directly mutating the database). */ @LibAnkiAlias("undo") - fun undo(): OpChangesAfterUndo = backend.undo() + fun undo(): OpChangesAfterUndo { + val out = backend.undo() + if (out.changes.notetype) { + notetypes.clearCache() + } + return out + } /** * Returns result of backend redo operation, or throws UndoEmpty. */ @RustCleanup("document exception") @LibAnkiAlias("redo") - fun redo(): OpChangesAfterUndo = backend.redo() + fun redo(): OpChangesAfterUndo { + val out = backend.redo() + if (out.changes.notetype) { + notetypes.clearCache() + } + return out + } @Deprecated("Not implemented") @LibAnkiAlias("op_made_changes") diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt index 8c48dda5a80c..0550b677777d 100644 --- a/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt +++ b/libanki/src/test/java/com/ichi2/anki/libanki/NotetypesTest.kt @@ -16,6 +16,9 @@ package com.ichi2.anki.libanki +import android.annotation.SuppressLint +import anki.notetypes.StockNotetype +import anki.notetypes.notetypeId import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException import net.ankiweb.rsdroid.exceptions.BackendNotFoundException @@ -25,6 +28,32 @@ import org.junit.Test import kotlin.test.assertFailsWith class NotetypesTest : InMemoryAnkiTest() { + @Test // #20510 + fun `notetype cache is invalidated on undo and redo`() { + val notetype = col.notetypes.basic + + fun fieldCount() = + col.notetypes + .get(notetype.id)!! + .fields.size + + col.notetypes.addFieldLegacy(notetype, col.notetypes.newField("NewField")) + assertThat("field added", fieldCount(), equalTo(3)) + + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = notetype.id }, + forceKind = StockNotetype.Kind.KIND_BASIC, + ) + assertThat("fields after restore", fieldCount(), equalTo(2)) + + col.undo() + assertThat("fields after undo", fieldCount(), equalTo(3)) + + col.redo() + assertThat("fields after redo", fieldCount(), equalTo(2)) + } + @Test fun `getSingleNotetypeOfNotes - multiple`() { val notes = addNotes(2) From 49e4ece69bf616b6051d08472b343f784be90a21 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:54 +0100 Subject: [PATCH 2/3] refactor(note-editor): extract onTemplateEditorResult() Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 366d9ee231ff..7310192aa194 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -328,40 +328,44 @@ class NoteEditorFragment : private val requestTemplateEditLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult(), - NoteEditorActivityResultCallback { - // Note type can change regardless of exit type - update ourselves and CardBrowser - reloadRequired = true - editorNote!!.notetype = getColUnsafe.notetypes.get(editorNote!!.noteTypeId)!! - if (currentEditedCard == null || - !editorNote!! - .cardIds(getColUnsafe) - .contains(currentEditedCard!!.id) - ) { - if (!addNote) { - /* This can occur, for example, if the - * card type was deleted or if the note - * type was changed without moving this - * card to another type. */ - Timber.d("onActivityResult() template edit return - current card is gone, close note editor") - showSnackbar(getString(R.string.template_for_current_card_deleted)) - closeNoteEditor() - } else { - Timber.d("onActivityResult() template edit return, in add mode, just re-display") - updateCards(editorNote!!.notetype) - } - } else { - Timber.d("onActivityResult() template edit return - current card exists") - // reload current card - the template ordinals are possibly different post-edit - currentEditedCard = getColUnsafe.getCard(currentEditedCard!!.id) - @NeedsTest("#17282 returning from template editor saves further made changes") - // make sure the card's note is available going forward - currentEditedCard!!.note(getColUnsafe) - editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference - updateCards(editorNote!!.notetype) - } - }, + NoteEditorActivityResultCallback { onTemplateEditorResult() }, ) + /** Handles a return from the [CardTemplateEditor] */ + @VisibleForTesting + internal fun onTemplateEditorResult() { + // Note type can change regardless of exit type - update ourselves and CardBrowser + reloadRequired = true + editorNote!!.notetype = getColUnsafe.notetypes.get(editorNote!!.noteTypeId)!! + if (currentEditedCard == null || + !editorNote!! + .cardIds(getColUnsafe) + .contains(currentEditedCard!!.id) + ) { + if (!addNote) { + /* This can occur, for example, if the + * card type was deleted or if the note + * type was changed without moving this + * card to another type. */ + Timber.d("onActivityResult() template edit return - current card is gone, close note editor") + showSnackbar(getString(R.string.template_for_current_card_deleted)) + closeNoteEditor() + } else { + Timber.d("onActivityResult() template edit return, in add mode, just re-display") + updateCards(editorNote!!.notetype) + } + } else { + Timber.d("onActivityResult() template edit return - current card exists") + // reload current card - the template ordinals are possibly different post-edit + currentEditedCard = getColUnsafe.getCard(currentEditedCard!!.id) + @NeedsTest("#17282 returning from template editor saves further made changes") + // make sure the card's note is available going forward + currentEditedCard!!.note(getColUnsafe) + editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference + updateCards(editorNote!!.notetype) + } + } + private val ioEditorLauncher = registerForActivityResult( ActivityResultContracts.GetContent(), From 7414fca79002e00fed8d012e79f9c4929d6fc4ad Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:12:44 +0100 Subject: [PATCH 3/3] fix(note-editor): handle 'Restore to Default' Restoring to default can update the fields in the note type. The Note Editor now rebuilds the fields, preserving text where possible (using the same code as when the spinner is changed) Fixes 20510 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 6 ++- .../java/com/ichi2/anki/NoteEditorTest.kt | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 7310192aa194..8b173b51a9aa 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -352,7 +352,8 @@ class NoteEditorFragment : closeNoteEditor() } else { Timber.d("onActivityResult() template edit return, in add mode, just re-display") - updateCards(editorNote!!.notetype) + // the fields may have changed, so rebuild them + refreshNoteData(FieldChangeType.changeFieldCount(shouldReplaceNewlines())) } } else { Timber.d("onActivityResult() template edit return - current card exists") @@ -362,7 +363,8 @@ class NoteEditorFragment : // make sure the card's note is available going forward currentEditedCard!!.note(getColUnsafe) editorNote = currentEditedCard!!.note // update the NoteEditor's working note reference - updateCards(editorNote!!.notetype) + // the fields may have changed, so rebuild them + setNote(editorNote, FieldChangeType.changeFieldCount(shouldReplaceNewlines())) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt index 5e64b85d57cc..a9c50cff934c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt @@ -4,6 +4,7 @@ package com.ichi2.anki +import android.annotation.SuppressLint import android.app.Activity import android.content.ClipData import android.content.Intent @@ -19,6 +20,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.config.ConfigKey +import anki.notetypes.StockNotetype +import anki.notetypes.notetypeId import com.ichi2.anki.NoteEditorTest.FromScreen.DECK_LIST import com.ichi2.anki.NoteEditorTest.FromScreen.REVIEWER import com.ichi2.anki.api.AddContentApi.Companion.DEFAULT_DECK_ID @@ -65,6 +68,49 @@ class NoteEditorTest : RobolectricTest() { ) } + @Test // #20510 + fun `editing - fields are refreshed after the template editor restores the note type to default`() { + val basic = col.notetypes.byName("Basic")!! + col.notetypes.addFieldLegacy(basic, col.notetypes.newField("ThirdField")) + + val editor = getNoteEditorEditingExistingBasicNote("Hello", "World", REVIEWER) + assertThat("field count before restore", editor.editFields!!.size, equalTo(3)) + + // simulate 'Restore to Default' being performed inside the CardTemplateEditor + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = basic.id }, + StockNotetype.Kind.KIND_BASIC, + ) + editor.onTemplateEditorResult() + + assertThat("field count after restore", editor.editFields!!.size, equalTo(2)) + assertThat(editor.currentFieldStrings.toList(), contains("Hello", "World")) + } + + @Test // #20510 + fun `adding - fields are refreshed after the template editor restores the note type to default`() { + val basic = col.notetypes.byName("Basic")!! + col.notetypes.addFieldLegacy(basic, col.notetypes.newField("ThirdField")) + + val editor = + getNoteEditorAdding(NoteType.BASIC) + .withFirstField("Hello") + .build() + assertThat("field count before restore", editor.editFields!!.size, equalTo(3)) + + // simulate 'Restore to Default' being performed inside the CardTemplateEditor + @SuppressLint("CheckResult") + col.notetypes.restoreNotetypeToStock( + notetypeId { ntid = basic.id }, + StockNotetype.Kind.KIND_BASIC, + ) + editor.onTemplateEditorResult() + + assertThat("field count after restore", editor.editFields!!.size, equalTo(2)) + assertThat("typed text is preserved", editor.currentFieldStrings[0], equalTo("Hello")) + } + @Test fun errorSavingNoteWithNoFirstFieldDisplaysNoFirstField() = runTest {