From c9dc2d7a3970335b8ad000755060e2a838949307 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 29 Sep 2026 17:42:32 +0300 Subject: [PATCH 1/4] fix(expander): match regex shortcuts against actual typed text Do not append pending autocorrections to the expansion search buffer. Cover cps corrected to cpp, real cpp triggers, immediate and separator expansion, and undo using the editor input path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/latin/utils/TextExpanderUtils.kt | 7 +-- .../keyboard/latin/InputLogicTest.kt | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 796e6494a..27cb514e9 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -387,11 +387,8 @@ object TextExpanderUtils { if (textBeforeCursor == null || !isEnabled(context)) return null getShortcuts(context) val compiledList = cachedCompiledList ?: return null - val fullText = if (word != null && !textBeforeCursor.endsWith(word, ignoreCase = true)) { - textBeforeCursor + word - } else { - textBeforeCursor - } + // A pending correction is not text the user typed and must not trigger a macro. + val fullText = textBeforeCursor for (item in compiledList) { val entry = item.entry diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index d9b5b2324..99f733d21 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -30,6 +30,7 @@ import helium314.keyboard.latin.inputlogic.SpaceState import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ScriptUtils import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.TextExpanderUtils import helium314.keyboard.latin.utils.getTimestampFormatter import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith @@ -426,6 +427,63 @@ class InputLogicTest { assertEquals("user_mention", text) } + @Test fun correctionToLiteralShortcutDoesNotExpand() { + assertCorrectionDoesNotTriggerShortcut("cpp") + } + + @Test fun correctionToRegexShortcutDoesNotExpand() { + assertCorrectionDoesNotTriggerShortcut("${TextExpanderUtils.REGEX_PREFIX}cpp") + } + + private fun assertCorrectionDoesNotTriggerShortcut(shortcut: String) { + for (immediate in listOf(false, true)) { + configureExpansion(shortcut, immediate) + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) + typeNoAssert("cps") + val correction = SuggestedWordInfo("cpp", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + composer.setAutoCorrection(correction) + assertEquals(true, settingsValues.mAutoCorrectEnabled) + typeNoAssert(" ") + assertEquals("cpp ", text, "immediate=$immediate") + checkConnectionConsistency() + } + } + + @Test fun literalShortcutStillExpandsOnlyFromActualTypedTrigger() { + assertTypedShortcutExpands("cpp") + } + + @Test fun regexShortcutStillExpandsOnlyFromActualTypedTrigger() { + assertTypedShortcutExpands("${TextExpanderUtils.REGEX_PREFIX}cpp") + } + + private fun assertTypedShortcutExpands(shortcut: String) { + for (immediate in listOf(false, true)) { + configureExpansion(shortcut, immediate) + typeNoAssert("cps ") + assertEquals("cps ", text) + typeNoAssert("cpp") + if (!immediate) typeNoAssert(" ") + assertEquals("cps EXPANDED" + if (immediate) "" else " ", text) + if (!immediate) functionalKeyPress(KeyCode.DELETE) + functionalKeyPress(KeyCode.DELETE) + assertEquals("cps cpp", text) + checkConnectionConsistency() + } + } + + private fun configureExpansion(shortcut: String, immediate: Boolean) { + reset() + latinIME.prefs().edit { + putBoolean(TextExpanderUtils.PREF_ENABLED, true) + putBoolean(TextExpanderUtils.PREF_IMMEDIATE, immediate) + putBoolean(TextExpanderUtils.PREF_BACKSPACE_REVERTS, true) + } + TextExpanderUtils.saveShortcuts( + latinIME, mapOf(shortcut to TextExpanderUtils.ShortcutEntry("EXPANDED")) + ) + } + @Test fun numberShortcutWithAtPrefixExpandsOnSpace() { reset() latinIME.prefs().edit().apply { From c431af241917adb830ad404e905d56ea751c4d2e Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 29 Sep 2026 17:45:00 +0300 Subject: [PATCH 2/4] fix(expander): measure shortcut prefixes before autocorrection Compute matched prefix length from the composing typed word, not a longer or shorter correction. Retain selected-candidate behavior for manual picks and verify prefix, surrounding text, cursor, and selection paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/latin/inputlogic/InputLogic.kt | 4 ++- .../keyboard/latin/InputLogicTest.kt | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index cbaabc8d7..f8b2249d7 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -2305,7 +2305,9 @@ class InputLogic( val textBefore = mConnection.getTextBeforeCursor(50, 0) if (textBefore != null) { val textStr = textBefore.toString() - val result = TextExpanderUtils.getExpandedWordForTyped(chosenWord, textStr, mLatinIME) + val expansionWord = if (commitType == LastComposedWord.COMMIT_TYPE_DECIDED_WORD) + mWordComposer.getTypedWord() else chosenWord + val result = TextExpanderUtils.getExpandedWordForTyped(expansionWord, textStr, mLatinIME) if (result != null) { if (mJustRevertedExpandedShortcut != null && result.matchedString.equals(mJustRevertedExpandedShortcut, ignoreCase = true) diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 99f733d21..53fa5a8b9 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -457,6 +457,38 @@ class InputLogicTest { assertTypedShortcutExpands("${TextExpanderUtils.REGEX_PREFIX}cpp") } + @Test fun prefixedRegexExpansionUsesTypedLengthNotCorrectionLength() { + for (correction in listOf("python", "p")) { + configureExpansion("${TextExpanderUtils.REGEX_PREFIX}\\.py", false) + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) + setText("keep .") + typeNoAssert("py") + assertEquals("py", composer.getTypedWord()) + composer.setAutoCorrection( + SuggestedWordInfo(correction, "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + ) + typeNoAssert(" ") + assertEquals("keep EXPANDED ", text, "correction=$correction") + assertEquals(text.length, cursor) + checkConnectionConsistency() + } + } + + @Test fun manuallyPickedSuggestionDoesNotSynthesizeShortcutText() { + for (shortcut in listOf("cpp", "${TextExpanderUtils.REGEX_PREFIX}cpp")) { + configureExpansion(shortcut, false) + typeNoAssert("cps") + pickSuggestion("cpp") + assertEquals("cpp", text) + checkConnectionConsistency() + } + configureExpansion("cpp", false) + typeNoAssert("cpp") + pickSuggestion("chosen") + assertEquals("chosen", text) + checkConnectionConsistency() + } + private fun assertTypedShortcutExpands(shortcut: String) { for (immediate in listOf(false, true)) { configureExpansion(shortcut, immediate) From e8a636b32c5459d7b48d8c125b7d7d907579f0d3 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 29 Sep 2026 18:10:59 +0300 Subject: [PATCH 3/4] fix(expander): preserve explicit manual suggestion choices Skip expansion when a manually picked word differs from the actual composing word. Preserve same-word and case-only shortcut expansion and undo. Cover prefixed and full regex matches, longer and shorter choices, and suffix-only candidates through the editor path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/latin/inputlogic/InputLogic.kt | 4 +- .../keyboard/latin/InputLogicTest.kt | 65 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index f8b2249d7..3f2f7052d 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -2301,7 +2301,9 @@ class InputLogic( Log.d(TAG, "commitChosenWord() : [$chosenWord]") } val isEnabled = TextExpanderUtils.isEnabled(mLatinIME) - if (isEnabled) { + val isDifferentManualPick = commitType == LastComposedWord.COMMIT_TYPE_MANUAL_PICK + && !chosenWord.equals(mWordComposer.getTypedWord(), ignoreCase = true) + if (isEnabled && !isDifferentManualPick) { val textBefore = mConnection.getTextBeforeCursor(50, 0) if (textBefore != null) { val textStr = textBefore.toString() diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 53fa5a8b9..30802078c 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -489,6 +489,71 @@ class InputLogicTest { checkConnectionConsistency() } + @Test fun manuallyPickedLongerSuggestionOverridesMatchingRegexShortcut() { + assertManualSuggestionOverridesMatchingRegexShortcut("python") + } + + @Test fun manuallyPickedShorterSuggestionOverridesMatchingRegexShortcut() { + assertManualSuggestionOverridesMatchingRegexShortcut("p") + } + + @Test fun manuallyPickedSuffixSuggestionOverridesMatchingRegexShortcut() { + assertManualSuggestionOverridesMatchingRegexShortcut("y") + } + + @Test fun manuallyPickedSameLengthSuggestionOverridesMatchingRegexShortcut() { + assertManualSuggestionOverridesMatchingRegexShortcut("by") + } + + private fun assertManualSuggestionOverridesMatchingRegexShortcut(suggestion: String) { + configureExpansion("${TextExpanderUtils.REGEX_PREFIX}\\.py", false) + setText("keep .") + typeNoAssert("py") + assertEquals("py", composer.getTypedWord()) + pickSuggestion(suggestion) + assertEquals("keep .$suggestion", text) + assertEquals(text.length, cursor) + checkConnectionConsistency() + } + + @Test fun manuallyPickedDifferentSuggestionOverridesWholeRegexMatch() { + configureExpansion("${TextExpanderUtils.REGEX_PREFIX}cpp", false) + typeNoAssert("cpp") + pickSuggestion("chosen") + assertEquals("chosen", text) + assertEquals(text.length, cursor) + checkConnectionConsistency() + } + + @Test fun manuallyPickedMatchingShortcutStillExpands() { + for (shortcut in listOf("cpp", "${TextExpanderUtils.REGEX_PREFIX}cpp")) { + for (suggestion in listOf("cpp", "CPP")) { + configureExpansion(shortcut, false) + typeNoAssert("cpp") + pickSuggestion(suggestion) + assertEquals("EXPANDED", text) + assertEquals(text.length, cursor) + functionalKeyPress(KeyCode.DELETE) + assertEquals("cpp", text) + checkConnectionConsistency() + } + } + } + + @Test fun manuallyPickedMatchingPrefixedRegexShortcutStillExpands() { + for (suggestion in listOf("py", "PY")) { + configureExpansion("${TextExpanderUtils.REGEX_PREFIX}\\.py", false) + setText("keep .") + typeNoAssert("py") + pickSuggestion(suggestion) + assertEquals("keep EXPANDED", text) + assertEquals(text.length, cursor) + functionalKeyPress(KeyCode.DELETE) + assertEquals("keep .py", text) + checkConnectionConsistency() + } + } + private fun assertTypedShortcutExpands(shortcut: String) { for (immediate in listOf(false, true)) { configureExpansion(shortcut, immediate) From 659c33dd76f67030ac0e74c37ebacfce96578423 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 29 Sep 2026 18:18:46 +0300 Subject: [PATCH 4/4] fix(expander): avoid reporting corrections replaced by expansion Return the expansion outcome from commitChosenWord and skip correction metadata and candidate statistics when expansion replaced the candidate. Record editor commitCorrection callbacks in tests; preserve real autocorrection notifications with expansion disabled or unmatched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/latin/inputlogic/InputLogic.kt | 9 +++-- .../keyboard/latin/InputLogicTest.kt | 33 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index 3f2f7052d..127a12597 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -2269,7 +2269,8 @@ class InputLogic( val stringToCommit = autoCorrectionOrNull?.mWord ?: typedWord if (stringToCommit != null) { val isBatchMode = mWordComposer.isBatchMode() - commitChosenWord(settingsValues, stringToCommit, LastComposedWord.COMMIT_TYPE_DECIDED_WORD, separator) + val expanded = commitChosenWord(settingsValues, stringToCommit, LastComposedWord.COMMIT_TYPE_DECIDED_WORD, separator) + if (expanded) return if (typedWord != stringToCommit) { mConnection.commitCorrection( CorrectionInfo( @@ -2289,12 +2290,13 @@ class InputLogic( } } + /** Returns whether a shortcut expansion replaced the chosen word. */ private fun commitChosenWord( settingsValues: SettingsValues, chosenWord: String, commitType: Int, separatorString: String - ) { + ): Boolean { var startTimeMillis = 0L if (DebugFlags.DEBUG_ENABLED) { startTimeMillis = System.currentTimeMillis() @@ -2320,7 +2322,7 @@ class InputLogic( mConnection.deleteTextBeforeCursor(result.prefixLength + chosenWord.length) commitExpandedText(result.matchedString, result.expandedText) resetComposingState(true) - return + return true } } } @@ -2359,6 +2361,7 @@ class InputLogic( val runTimeMillis = System.currentTimeMillis() - startTimeMillis Log.d(TAG, "commitChosenWord() : $runTimeMillis ms to run WordComposer.commitWord()") } + return false } private fun wrapSelection(start: Int, end: Int) { diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 30802078c..ca0788522 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -47,6 +47,7 @@ import kotlin.streams.asSequence import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull @RunWith(RobolectricTestRunner::class) @Config(shadows = [ @@ -470,6 +471,30 @@ class InputLogicTest { typeNoAssert(" ") assertEquals("keep EXPANDED ", text, "correction=$correction") assertEquals(text.length, cursor) + assertEquals(emptyList(), committedCorrections) + checkConnectionConsistency() + } + } + + @Test fun unexpandedAutoCorrectionStillNotifiesEditor() { + for (expansionEnabled in listOf(false, true)) { + configureExpansion("${TextExpanderUtils.REGEX_PREFIX}\\.zz", false) + latinIME.prefs().edit { + putBoolean(TextExpanderUtils.PREF_ENABLED, expansionEnabled) + } + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) + setText("keep .") + typeNoAssert("py") + composer.setAutoCorrection( + SuggestedWordInfo("python", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + ) + typeNoAssert(" ") + assertEquals("keep .python ", text) + assertEquals(text.length, cursor) + val correction = assertNotNull(committedCorrections.single()) + assertEquals(6, correction.offset) + assertEquals("py", correction.oldText.toString()) + assertEquals("python", correction.newText.toString()) checkConnectionConsistency() } } @@ -1424,6 +1449,7 @@ class InputLogicTest { lastNgramContext = "" addedWords.clear() ngramContexts.clear() + committedCorrections.clear() messages.clear() delayedMessages.clear() @@ -1671,6 +1697,7 @@ private var selectionStart = 0 private var selectionEnd = 0 private var composingStart = -1 private var composingEnd = -1 +private val committedCorrections = mutableListOf() // convenience for access private val textBeforeCursor get() = text.substring(0, selectionStart) private val textAfterCursor get() = text.substring(selectionEnd) @@ -1827,8 +1854,10 @@ private val ic = object : InputConnection { it.selectionEnd = selectionEnd } } - // only effect is flashing, so whatever... - override fun commitCorrection(p0: CorrectionInfo?): Boolean = true + override fun commitCorrection(p0: CorrectionInfo?): Boolean { + committedCorrections.add(p0) + return true + } // implement only when necessary override fun getCursorCapsMode(p0: Int): Int = TODO("Not yet implemented") override fun deleteSurroundingTextInCodePoints(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")