From 986c0430eca3f18324a73657cd28c2da9fa2e353 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 19 May 2026 12:57:15 +0100 Subject: [PATCH 1/2] refactor: convert FieldFilter to an interface Assisted-by: Claude Opus 4.7 --- AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt | 4 +++- .../src/main/java/com/ichi2/anki/libanki/TemplateManager.kt | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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/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, From 9e20ac26b88a78f6fa6c71e797b7768efbccda1a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 19 May 2026 13:54:41 +0100 Subject: [PATCH 2/2] feat(study-screen): `nosuggest:type` field filter This removes suggestions from the IME, so a language learner can learn an orthography without hints. It also removes the 'swipe' operation on the keyboard without showing the incognito icon. This input mode (TYPE_NULL) will not be for all users: it makes Mandarin Chinese keyboards output in QWERTY only mode, and likely will disable voice input. It is to be treated as an advanced feature, and is currently a hidden feature, planned to be exposed by the FieldFilter class. `nosuggest` needs to come before `type` in the field filter list: `type` is processed by the backend, and AnkiDroid processes the remaining 'custom' filters so: `{{nosuggest:type:Field}}` becomes [[type:nosuggest:Field]] which is exposed by TypeAnswer, and implemented by the study screen ---- The following were considered: TYPE_TEXT_FLAG_AUTO_COMPLETE = false TYPE_TEXT_FLAG_AUTO_CORRECT = false TYPE_TEXT_FLAG_AUTO_CORRECT = false TYPE_TEXT_VARIATION_FILTER = true IME_FLAG_NO_PERSONALIZED_LEARNING = true The above are not sufficient for GBoard - incognito shows, and the strip showing suggested words is still visible `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD` removed suggestions, but with an in-IME hint to open the password manager and a monospaced font on the field Fixes 10352 https://developer.android.com/reference/android/text/InputType#TYPE_NULL Assisted-by: Claude Opus 4.7 - the code and a lot of bad research --- .idea/dictionaries/project.xml | 7 ++ .../com/ichi2/anki/AbstractFlashcardViewer.kt | 29 +++++++- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 13 ++++ .../com/ichi2/anki/cardviewer/TypeAnswer.kt | 44 +++++------- .../anki/cardviewer/TypeAnswerModifiers.kt | 51 ++++++++++++++ .../java/com/ichi2/anki/model/FieldFilters.kt | 70 ++++++++++++++----- .../com/ichi2/anki/previewer/TypeAnswer.kt | 33 ++++----- .../ui/windows/reviewer/ReviewerFragment.kt | 31 +++++++- .../cardviewer/TypeAnswerModifiersTest.kt | 58 +++++++++++++++ .../ichi2/anki/model/FieldFilterChainTest.kt | 55 ++++++++++----- .../ichi2/anki/model/NoSuggestFilterTest.kt | 50 +++++++++++++ .../ichi2/anki/previewer/TypeAnswerTest.kt | 43 ++++++++---- 12 files changed, 388 insertions(+), 96 deletions(-) create mode 100644 .idea/dictionaries/project.xml create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerModifiers.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/TypeAnswerModifiersTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/model/NoSuggestFilterTest.kt 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/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))