diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 000000000000..da034ae306c1
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ nosuggest
+
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
index d3ae5c9908cf..0f62153c644c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
@@ -36,6 +36,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
+import android.text.InputType
import android.view.GestureDetector
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.KeyEvent
@@ -71,6 +72,7 @@ import androidx.annotation.IdRes
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
+import androidx.core.content.getSystemService
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat
@@ -132,6 +134,7 @@ import com.ichi2.anki.libanki.SoundOrVideoTag
import com.ichi2.anki.libanki.TTSTag
import com.ichi2.anki.libanki.TtsPlayer
import com.ichi2.anki.model.CardStateFilter
+import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
import com.ichi2.anki.multimedia.getAvTag
import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.observability.ChangeManager
@@ -246,6 +249,10 @@ abstract class AbstractFlashcardViewer :
private var cardFrame: FrameLayout? = null
private var touchLayer: FrameLayout? = null
protected var answerField: FixedEditText? = null
+
+ /** Layout-provided default `inputType` for [answerField], captured once and used to restore
+ * state when moving off a card that used `{{nosuggest:type:}}`. See issue #10352. */
+ private var defaultAnswerFieldInputType: Int? = null
protected var flipCardLayout: FrameLayout? = null
private var easeButtonsLayout: LinearLayout? = null
@@ -570,6 +577,7 @@ abstract class AbstractFlashcardViewer :
shortAnimDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
gestureDetectorImpl = LinkDetectingGestureDetector()
TtsVoicesFieldFilter.ensureApplied()
+ NoSuggestFilter.ensureApplied()
}
override fun setupBackPressedCallbacks() {
@@ -738,6 +746,20 @@ abstract class AbstractFlashcardViewer :
protected open fun answerFieldIsFocused(): Boolean = answerField != null && answerField!!.isFocused
+ /**
+ * Apply or restore the `{{nosuggest:type:}}` flag set on [answerField].
+ */
+ private fun applyTypeAnswerSuggestionFlags(noSuggest: Boolean) {
+ val field = answerField ?: return
+ // see ReviewerFragment for why `TYPE_NULL` was selected
+ val targetInputType =
+ if (noSuggest) InputType.TYPE_NULL else (defaultAnswerFieldInputType ?: field.inputType)
+ if (field.inputType != targetInputType) {
+ field.inputType = targetInputType
+ getSystemService()?.restartInput(field)
+ }
+ }
+
val deckOptionsLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
Timber.i("Returned from deck options -> Restarting activity")
@@ -979,7 +1001,10 @@ abstract class AbstractFlashcardViewer :
val params = flipCardLayout!!.layoutParams
params.height = initialFlipCardHeight * 2
}
- answerField = findViewById(R.id.answer_field)
+ answerField =
+ findViewById(R.id.answer_field).also { answerField ->
+ defaultAnswerFieldInputType = answerField.inputType
+ }
initControls()
// Position answer buttons
@@ -1341,6 +1366,7 @@ abstract class AbstractFlashcardViewer :
// Show text entry based on if the user wants to write the answer
answerField?.visibility = View.VISIBLE
answerField?.applyLanguageHint(typeAnswer?.languageHint)
+ applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
} else {
answerField?.visibility = View.GONE
}
@@ -1985,6 +2011,7 @@ abstract class AbstractFlashcardViewer :
// Show text entry based on if the user wants to write the answer
answerField?.visibility = View.VISIBLE
answerField?.applyLanguageHint(typeAnswer?.languageHint)
+ applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
} else {
answerField?.visibility = View.GONE
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
index 35df4fadeef4..bfb86e790ed5 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
@@ -56,6 +56,7 @@ import com.ichi2.anki.logging.FragmentLifecycleLogger
import com.ichi2.anki.logging.LogType
import com.ichi2.anki.logging.ProductionCrashReportingTree
import com.ichi2.anki.logging.RobolectricDebugTree
+import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
import com.ichi2.anki.navigation.initializeNavigator
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.preferences.SharedPreferencesProvider
@@ -215,8 +216,20 @@ open class AnkiDroidApp :
setupLifecycleLogging()
activityAgnosticDialogs = ActivityAgnosticDialogs.register(this)
TtsVoices.launchBuildLocalesJob()
+ applyCustomFieldFilters()
+ }
+
+ /**
+ * Applies AnkiDroid-specific implementations of field filters
+ *
+ * @see com.ichi2.anki.model.FieldFilter
+ * @see com.ichi2.anki.model.FieldFilters
+ */
+ private fun applyCustomFieldFilters() {
// enable {{tts-voices:}} field filter
TtsVoicesFieldFilter.ensureApplied()
+ // enable {{nosuggest:type:}} field filter (issue #10352)
+ NoSuggestFilter.ensureApplied()
}
/**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
index 4176c6452ef7..1f5fca52483a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
@@ -24,6 +24,8 @@ package com.ichi2.anki
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
+import com.ichi2.anki.TtsVoices.availableLocaleData
+import com.ichi2.anki.TtsVoices.availableLocales
import com.ichi2.anki.common.android.appContext
import com.ichi2.anki.common.coroutines.applicationScope
import com.ichi2.anki.i18n.normalize
@@ -220,7 +222,7 @@ object TtsVoices {
/**
* `{{tts-voices:}}` A filter which lists all available TTS Voices for the current engine
*/
-class TtsVoicesFieldFilter : TemplateManager.FieldFilter() {
+class TtsVoicesFieldFilter : TemplateManager.FieldFilter {
// modified from libAnki: tts.py: on_tts_voices
override fun apply(
fieldText: String,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
index 41bc23290836..3d109ebdfbdd 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2021 David Allison
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.cardviewer
@@ -31,6 +17,8 @@ import java.util.regex.Matcher
import java.util.regex.Pattern
/**
+ * Handles 'Type the answer' for the old study screen
+ *
* @param useInputTag use an `` tag to allow for HTML styling
* @param autoFocus Whether the user wants to focus "type in answer"
*/
@@ -45,6 +33,14 @@ class TypeAnswer(
var combining: Boolean = true
private set
+ /**
+ * Whether keyboard suggestions, swiping and autocorrect should be disabled (#10352).
+ *
+ * @see com.ichi2.anki.model.FieldFilters.NoSuggestFilter
+ */
+ var noSuggest: Boolean = false
+ private set
+
/** What the learner actually typed (externally mutable) */
var input = ""
@@ -86,24 +82,18 @@ class TypeAnswer(
res: Resources,
) {
combining = true
+ noSuggest = false
correct = null
val q = card.question(col)
val m = PATTERN.matcher(q)
- var clozeIdx = 0
if (!m.find()) {
return
}
- var fldTag = m.group(1)!!
- // if it's a cloze, extract data
- if (fldTag.startsWith("cloze:")) {
- // get field and cloze position
- clozeIdx = card.ord + 1
- fldTag = fldTag.split(":").toTypedArray()[1]
- }
- if (fldTag.startsWith("nc:")) {
- combining = false
- fldTag = fldTag.split(":").toTypedArray()[1]
- }
+ val parsed = TypeAnswerModifiers.parse(m.group(1)!!)
+ combining = parsed.combining
+ noSuggest = parsed.noSuggest
+ val fldTag = parsed.fieldName
+ val clozeIdx = if (parsed.cloze) card.ord + 1 else 0
// loop through fields for a match
for (fld in card.noteType(col).fields) {
val name = fld.name
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerModifiers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerModifiers.kt
new file mode 100644
index 000000000000..44c26eed1851
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerModifiers.kt
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.cardviewer
+
+/**
+ * Represents modifiers for `[[type:...]]`
+ *
+ * Examples:
+ * - `[[type:nosuggest:nc:Field]]`
+ * - `[[type:cloze:Field]]`
+ */
+internal data class TypeAnswerModifiers(
+ val fieldName: String,
+ val combining: Boolean,
+ val cloze: Boolean,
+ val noSuggest: Boolean,
+) {
+ companion object {
+ /**
+ * Represents the known modifiers in a [[type: ]] declaration, and remaining data
+ */
+ fun parse(rawField: String): TypeAnswerModifiers {
+ var remaining = rawField
+ var combining = true
+ var cloze = false
+ var noSuggest = false
+ while (true) {
+ when {
+ remaining.startsWith("nosuggest:") -> {
+ noSuggest = true
+ remaining = remaining.removePrefix("nosuggest:")
+ }
+ remaining.startsWith("cloze:") -> {
+ cloze = true
+ remaining = remaining.removePrefix("cloze:")
+ }
+ remaining.startsWith("nc:") -> {
+ combining = false
+ remaining = remaining.removePrefix("nc:")
+ }
+ else -> return TypeAnswerModifiers(
+ fieldName = remaining,
+ combining = combining,
+ cloze = cloze,
+ noSuggest = noSuggest,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldFilters.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldFilters.kt
index 0fd12f2d70cf..c8a4357c4319 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldFilters.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldFilters.kt
@@ -1,23 +1,10 @@
-/*
- * Copyright (c) 2026 David Allison
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.model
import androidx.annotation.CheckResult
import com.ichi2.anki.libanki.NotetypeJson
+import com.ichi2.anki.libanki.TemplateManager
// Classes relating to a chain of field filters: {{type:cloze:Front}}
@@ -34,7 +21,7 @@ import com.ichi2.anki.libanki.NotetypeJson
* only be used to validate inserting new filters in the AnkiDroid editor.
*
* @see FieldFilter
- * @see com.ichi2.anki.libanki.TemplateManager.FieldFilter
+ * @see TemplateManager.FieldFilter
*/
data class FilterContext(
/**
@@ -69,6 +56,10 @@ data class FilterContext(
* Applying more filters to these outputs would corrupt them.
*/
val isTerminal: Boolean = false,
+ /**
+ * Whether the chain produces a `[[type:...]]` marker (via the `type:` field filter)
+ */
+ val producesTypeMarker: Boolean = false,
)
/**
@@ -133,6 +124,7 @@ object FieldFilters {
HintFilter,
TypeTheAnswerFilter,
TypeTheAnswerNonCombiningFilter,
+ NoSuggestFilter,
TextToSpeechFilter(),
FuriganaFilter,
KanaFilter,
@@ -240,7 +232,7 @@ object FieldFilters {
object TypeTheAnswerFilter : FieldFilter {
override val name: String = "type"
- override fun updateContext(input: FilterContext) = input.copy(isTerminal = true)
+ override fun updateContext(input: FilterContext) = input.copy(isTerminal = true, producesTypeMarker = true)
}
/**
@@ -263,7 +255,49 @@ object FieldFilters {
object TypeTheAnswerNonCombiningFilter : FieldFilter {
override val name: String = "type:nc"
- override fun updateContext(input: FilterContext) = input.copy(isTerminal = true)
+ override fun updateContext(input: FilterContext) = input.copy(isTerminal = true, producesTypeMarker = true)
+ }
+
+ /**
+ * `{{nosuggest:type:Field}}`: disables keyboard suggestions, swiping and autocorrect
+ *
+ * AnkiDroid-only.
+ *
+ * Converts the rendered `[[type:abc]]` to `[[type:nosuggest:abc]]`, which is then consumed
+ * by the `TypeAnswer` parser.
+ *
+ * See [#10352](https://github.com/ankidroid/Anki-Android/issues/10352).
+ * @see com.ichi2.anki.previewer.TypeAnswer.noSuggest
+ */
+ object NoSuggestFilter :
+ FieldFilter,
+ TemplateManager.FieldFilter {
+ override val name: String = "nosuggest"
+
+ /**
+ * May only be applied to `type:` or `type:nc` filter which are both terminal chains,
+ * therefore, do not check 'super.canApplyTo'
+ */
+ override fun canApplyTo(context: FilterContext): Boolean = context.producesTypeMarker
+
+ override fun updateContext(input: FilterContext): FilterContext = input
+
+ override fun apply(
+ fieldText: String,
+ fieldName: String,
+ filterName: String,
+ ctx: TemplateManager.TemplateRenderContext,
+ ): String {
+ if (filterName != name) return fieldText
+ if (!fieldText.contains("[[type:")) return fieldText
+ if (fieldText.contains("[[type:nosuggest:")) return fieldText
+ return fieldText.replace("[[type:", "[[type:nosuggest:")
+ }
+
+ /** Registers this filter as a runtime transformer with the [TemplateManager]. */
+ fun ensureApplied() {
+ TemplateManager.fieldFilters.putIfAbsent(name, this)
+ }
}
/**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt
index ad78a6b03eb1..95537716cc44 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt
@@ -17,6 +17,7 @@ package com.ichi2.anki.previewer
import android.os.LocaleList
import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.cardviewer.TypeAnswerModifiers
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.Field
@@ -30,14 +31,22 @@ import org.jetbrains.annotations.VisibleForTesting
*
* @see [combining]
* @see [imeHintLocales]
+ * @see [noSuggest]
* */
@NeedsTest("combining and non combining answers are properly parsed")
@NeedsTest("cloze and non cloze 'type in the answer' cards are properly parsed")
+@NeedsTest("nosuggest modifier is parsed and composes with nc/cloze")
class TypeAnswer private constructor(
private val text: String,
/** whether combining characters should be compared. Defined by the presence of the
* `nc:` specifier in the type answer tag */
private val combining: Boolean,
+ /**
+ * Whether keyboard suggestions, swiping and autocorrect should be disabled (#10352).
+ *
+ * @see com.ichi2.anki.model.FieldFilters.NoSuggestFilter
+ */
+ val noSuggest: Boolean,
private val field: Field,
var expectedAnswer: String,
) {
@@ -75,40 +84,32 @@ class TypeAnswer private constructor(
val match = typeAnsRe.find(text) ?: return null
val rawField = match.groups[1]?.value ?: return null
- var combining = true
- val typeAnsFieldName =
- if (rawField.startsWith("cloze:")) {
- rawField.split(":")[1]
- } else if (rawField.startsWith("nc:")) {
- combining = false
- rawField.split(":")[1]
- } else {
- rawField
- }
+ val modifiers = TypeAnswerModifiers.parse(rawField)
val fields = withCol { card.noteType(this).fields }
- val typeAnswerField = fields.firstOrNull { it.name == typeAnsFieldName } ?: return null
- val expectedAnswer = getExpectedTypeInAnswer(card, rawField = rawField, fieldName = typeAnsFieldName)
+ val typeAnswerField = fields.firstOrNull { it.name == modifiers.fieldName } ?: return null
+ val expectedAnswer = getExpectedTypeInAnswer(card, isCloze = modifiers.cloze, fieldName = modifiers.fieldName)
return TypeAnswer(
text = text,
- combining = combining,
+ combining = modifiers.combining,
+ noSuggest = modifiers.noSuggest,
field = typeAnswerField,
expectedAnswer = expectedAnswer,
)
}
/**
- * @param rawField the content (x) of a `{{type:x}}` placeholder
+ * @param isCloze whether the placeholder was a `cloze:` type filter
* @param fieldName the name of the field in the card template
*/
@NeedsTest("cloze type-in-answer are properly parsed")
private suspend fun getExpectedTypeInAnswer(
card: Card,
- rawField: String,
+ isCloze: Boolean,
fieldName: String,
): String {
val expected = withCol { card.note(this@withCol).getItem(fieldName) }
- return if (rawField.startsWith("cloze:")) {
+ return if (isCloze) {
val clozeIdx = card.ord + 1
withCol {
extractClozeForTyping(expected, clozeIdx)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
index 6e0b0f05235f..d75a075625c6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
@@ -19,6 +19,7 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.text.InputType
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
@@ -26,6 +27,7 @@ import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
+import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
@@ -67,6 +69,7 @@ import com.ichi2.anki.pages.DeckOptionsDestination
import com.ichi2.anki.preferences.reviewer.ViewerAction
import com.ichi2.anki.previewer.CardViewerActivity
import com.ichi2.anki.previewer.CardViewerFragment
+import com.ichi2.anki.previewer.TypeAnswer
import com.ichi2.anki.previewer.setFrameStyle
import com.ichi2.anki.previewer.stdHtml
import com.ichi2.anki.reviewer.BindingMap
@@ -271,6 +274,31 @@ class ReviewerFragment :
val isHtmlTypeAnswerEnabled = Prefs.isHtmlTypeAnswerEnabled
lifecycleScope.launch {
val autoFocusTypeAnswer = Prefs.autoFocusTypeAnswer
+ // Default `inputType` from the layout, restored when `{{nosuggest}}` is unused (#10352)
+ val defaultInputType = binding.typeAnswerEditText.inputType
+
+ /**
+ * Sync `inputType` and `imeHintLocales` on the answer `EditText` to match
+ * [typeInAnswer]. Returns `true` if anything changed (caller should `restartInput()`).
+ */
+ fun EditText.syncTypeAnswerProperties(typeInAnswer: TypeAnswer): Boolean {
+ // #10352: TYPE_NULL is used by 'Reword' to remove all suggestions. This works better
+ // than a password as the keyboard won't suggest to open the password manager
+ // other methods did not work for GBoard
+ val targetInputType =
+ if (typeInAnswer.noSuggest) InputType.TYPE_NULL else defaultInputType
+ var changed = false
+ if (inputType != targetInputType) {
+ inputType = targetInputType
+ changed = true
+ }
+ if (imeHintLocales != typeInAnswer.imeHintLocales) {
+ imeHintLocales = typeInAnswer.imeHintLocales
+ changed = true
+ }
+ return changed
+ }
+
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.typeAnswerFlow.collect { typeInAnswer ->
if (typeInAnswer == null) {
@@ -289,8 +317,7 @@ class ReviewerFragment :
binding.typeAnswerContainer.isVisible = true
binding.typeAnswerEditText.apply {
- if (imeHintLocales != typeInAnswer.imeHintLocales) {
- imeHintLocales = typeInAnswer.imeHintLocales
+ if (syncTypeAnswerProperties(typeInAnswer)) {
context?.getSystemService()?.restartInput(this)
}
if (autoFocusTypeAnswer) {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerModifiersTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerModifiersTest.kt
new file mode 100644
index 000000000000..59bb96a94f7e
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerModifiersTest.kt
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.cardviewer
+
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Test
+
+class TypeAnswerModifiersTest {
+ @Test
+ fun `parses bare field`() {
+ val parsed = TypeAnswerModifiers.parse("Back")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Back", combining = true, cloze = false, noSuggest = false)))
+ }
+
+ @Test
+ fun `parses nc only`() {
+ val parsed = TypeAnswerModifiers.parse("nc:Back")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Back", combining = false, cloze = false, noSuggest = false)))
+ }
+
+ @Test
+ fun `parses cloze only`() {
+ val parsed = TypeAnswerModifiers.parse("cloze:Text")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Text", combining = true, cloze = true, noSuggest = false)))
+ }
+
+ @Test
+ fun `parses nosuggest only`() {
+ val parsed = TypeAnswerModifiers.parse("nosuggest:Back")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Back", combining = true, cloze = false, noSuggest = true)))
+ }
+
+ @Test
+ fun `parses nosuggest with nc`() {
+ val parsed = TypeAnswerModifiers.parse("nosuggest:nc:Back")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Back", combining = false, cloze = false, noSuggest = true)))
+ }
+
+ @Test
+ fun `parses nosuggest with cloze`() {
+ val parsed = TypeAnswerModifiers.parse("nosuggest:cloze:Text")
+ assertThat(parsed, equalTo(TypeAnswerModifiers("Text", combining = true, cloze = true, noSuggest = true)))
+ }
+
+ /** Modifiers may be prepended by filters in any order — the parser should tolerate that. */
+ @Test
+ fun `parses modifiers regardless of order`() {
+ val ncFirst = TypeAnswerModifiers.parse("nosuggest:nc:Back")
+ val nosuggestSecond = TypeAnswerModifiers.parse("nc:nosuggest:Back")
+ assertThat(ncFirst.fieldName, equalTo("Back"))
+ assertThat(nosuggestSecond.fieldName, equalTo("Back"))
+ assertThat(ncFirst.combining, equalTo(false))
+ assertThat(nosuggestSecond.combining, equalTo(false))
+ assertThat(ncFirst.noSuggest, equalTo(true))
+ assertThat(nosuggestSecond.noSuggest, equalTo(true))
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/model/FieldFilterChainTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/model/FieldFilterChainTest.kt
index 979ae4d2d6e0..c233057b5fb9 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/model/FieldFilterChainTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/model/FieldFilterChainTest.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2026 David Allison
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.model
@@ -23,6 +9,7 @@ import com.ichi2.anki.model.FieldFilters.FuriganaFilter
import com.ichi2.anki.model.FieldFilters.HintFilter
import com.ichi2.anki.model.FieldFilters.KanaFilter
import com.ichi2.anki.model.FieldFilters.KanjiFilter
+import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
import com.ichi2.anki.model.FieldFilters.TextFilter
import com.ichi2.anki.model.FieldFilters.TextToSpeechFilter
import com.ichi2.anki.model.FieldFilters.TextToSpeechFilter.TextToSpeechOptions
@@ -85,6 +72,7 @@ class FieldFilterChainTest {
HintFilter to "hint",
TypeTheAnswerFilter to "type",
TypeTheAnswerNonCombiningFilter to "type:nc",
+ NoSuggestFilter to "nosuggest",
invalidTextToSpeechFilter() to "tts",
FuriganaFilter to "furigana",
KanaFilter to "kana",
@@ -144,21 +132,54 @@ class FieldFilterChainTest {
}
@Test
- fun `tryAdd - 'type' - no more filters can be added`() {
+ fun `tryAdd - 'type' - only 'nosuggest' can be added`() {
val chain = standardChain().add(TypeTheAnswerFilter)
for (filter in FieldFilters.ALL) {
+ if (filter == NoSuggestFilter) continue
assertNull(chain.tryAdd(filter, allowInvalid = true), message = filter.name)
}
+ assertNotNull(chain.tryAdd(NoSuggestFilter), message = "nosuggest after type")
}
@Test
- fun `tryAdd - 'type-nc' - no more filters can be added`() {
+ fun `tryAdd - 'type-nc' - only 'nosuggest' can be added`() {
val chain = standardChain().add(TypeTheAnswerNonCombiningFilter)
for (filter in FieldFilters.ALL) {
+ if (filter == NoSuggestFilter) continue
assertNull(chain.tryAdd(filter, allowInvalid = true), message = filter.name)
}
+ assertNotNull(chain.tryAdd(NoSuggestFilter), message = "nosuggest after type:nc")
+ }
+
+ @Test
+ fun `tryAdd - 'nosuggest' cannot be added without a preceding 'type' filter`() {
+ // 'nosuggest' only modifies the [[type:...]] marker
+ assertNull(standardChain().tryAdd(NoSuggestFilter), message = "empty chain")
+ assertNull(standardChain().add(HintFilter).tryAdd(NoSuggestFilter), message = "after hint")
+ assertNull(standardChain().add(textToSpeechFilter(), allowInvalid = true).tryAdd(NoSuggestFilter), message = "after tts")
+ }
+
+ @Test
+ fun `tryAdd - 'nosuggest' cannot be added twice`() {
+ val chain = standardChain().add(TypeTheAnswerFilter).add(NoSuggestFilter)
+
+ assertNull(chain.tryAdd(NoSuggestFilter), message = "duplicate nosuggest blocked")
+ }
+
+ @Test
+ fun `render - 'nosuggest' after 'type'`() {
+ val chain = standardChain().add(TypeTheAnswerFilter).add(NoSuggestFilter)
+
+ assertEquals("{{nosuggest:type:$FIELD_NAME}}", chain.render())
+ }
+
+ @Test
+ fun `render - 'nosuggest' after 'type-nc'`() {
+ val chain = standardChain().add(TypeTheAnswerNonCombiningFilter).add(NoSuggestFilter)
+
+ assertEquals("{{nosuggest:type:nc:$FIELD_NAME}}", chain.render())
}
@Test
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/model/NoSuggestFilterTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/model/NoSuggestFilterTest.kt
new file mode 100644
index 000000000000..a4030eedbf74
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/model/NoSuggestFilterTest.kt
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.model
+
+import com.ichi2.anki.libanki.TemplateManager
+import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Test
+import org.mockito.kotlin.mock
+
+/** Tests the runtime side of [NoSuggestFilter] — see [FieldFilterChainTest] for picker rules. */
+class NoSuggestFilterTest {
+ private val ctx = mock()
+
+ private fun apply(
+ fieldText: String,
+ filterName: String = "nosuggest",
+ ) = NoSuggestFilter.apply(fieldText, fieldName = "Back", filterName = filterName, ctx = ctx)
+
+ @Test
+ fun `prepends nosuggest modifier to type marker`() {
+ assertThat(apply("[[type:abc]]"), equalTo("[[type:nosuggest:abc]]"))
+ }
+
+ @Test
+ fun `prepends nosuggest modifier alongside nc`() {
+ assertThat(apply("[[type:nc:abc]]"), equalTo("[[type:nosuggest:nc:abc]]"))
+ }
+
+ @Test
+ fun `prepends nosuggest modifier alongside cloze`() {
+ assertThat(apply("[[type:cloze:abc]]"), equalTo("[[type:nosuggest:cloze:abc]]"))
+ }
+
+ @Test
+ fun `is idempotent when already applied`() {
+ assertThat(apply("[[type:nosuggest:abc]]"), equalTo("[[type:nosuggest:abc]]"))
+ }
+
+ @Test
+ fun `is a no-op when no type marker is present`() {
+ assertThat(apply("plain text"), equalTo("plain text"))
+ }
+
+ @Test
+ fun `is a no-op when filterName is something else`() {
+ assertThat(apply("[[type:abc]]", filterName = "other"), equalTo("[[type:abc]]"))
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt
index 236a6a3325ad..89883b051a17 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2026 David Allison
- *
- * 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 .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.previewer
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -20,6 +7,7 @@ import com.ichi2.anki.libanki.Card
import com.ichi2.testutils.JvmTest
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsString
+import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.not
import org.junit.Test
import org.junit.jupiter.api.assertDoesNotThrow
@@ -40,6 +28,31 @@ class TypeAnswerTest : JvmTest() {
assertThat(result, not(containsString("[[type:Back]]")))
}
+ /** [Issue #10352](https://github.com/ankidroid/Anki-Android/issues/10352) */
+ @Test
+ fun `noSuggest is false when nosuggest modifier is absent`() =
+ runTest {
+ val card = addBasicWithTypingNote("front", "back").firstCard()
+ val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:Back]]"))
+ assertThat(typeAnswer.noSuggest, equalTo(false))
+ }
+
+ @Test
+ fun `noSuggest is true when nosuggest modifier is present`() =
+ runTest {
+ val card = addBasicWithTypingNote("front", "back").firstCard()
+ val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:Back]]"))
+ assertThat(typeAnswer.noSuggest, equalTo(true))
+ }
+
+ @Test
+ fun `nosuggest composes with nc modifier`() =
+ runTest {
+ val card = addBasicWithTypingNote("front", "back").firstCard()
+ val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:nc:Back]]"))
+ assertThat(typeAnswer.noSuggest, equalTo(true))
+ }
+
companion object {
suspend fun TypeAnswer.Companion.createInstance(card: Card) = requireNotNull(TypeAnswer.getInstance(card, VALID_CARD_TEXT))
diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/TemplateManager.kt b/libanki/src/main/java/com/ichi2/anki/libanki/TemplateManager.kt
index b54869bcaca9..3302efc80ee5 100644
--- a/libanki/src/main/java/com/ichi2/anki/libanki/TemplateManager.kt
+++ b/libanki/src/main/java/com/ichi2/anki/libanki/TemplateManager.kt
@@ -292,8 +292,8 @@ class TemplateManager {
* Custom filters can check `filterName` to decide whether it should modify
* `fieldText` or not before returning it
*/
- abstract class FieldFilter {
- abstract fun apply(
+ interface FieldFilter {
+ fun apply(
fieldText: String,
fieldName: String,
filterName: String,