// SPDX-License-Identifier: GPL-3.0-only package helium314.keyboard.latin import android.inputmethodservice.InputMethodService import android.os.Bundle import android.os.Handler import android.os.Message import android.text.InputType import android.view.KeyEvent import android.view.inputmethod.* import androidx.core.content.edit import helium314.keyboard.ShadowInputMethodManager2 import helium314.keyboard.ShadowLocaleManagerCompat import helium314.keyboard.event.Event import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.keyboard.MainKeyboardView import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastAddedWord import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo import helium314.keyboard.latin.common.Constants import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.inputlogic.InputLogic import helium314.keyboard.latin.inputlogic.SpaceState import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ScriptUtils import helium314.keyboard.latin.utils.getTimestamp import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith import org.mockito.Mockito import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowLog import java.util.* import kotlin.math.min import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(shadows = [ ShadowLocaleManagerCompat::class, ShadowInputMethodManager2::class, ShadowInputMethodService::class, ShadowKeyboardSwitcher::class, ShadowHandler::class, ShadowFacilitator2::class, ]) class InputLogicTest { private lateinit var latinIME: LatinIME private val settingsValues get() = Settings.getValues() private val inputLogic get() = latinIME.mInputLogic private val connection: RichInputConnection get() = inputLogic.mConnection private val composerReader = InputLogic::class.java.getDeclaredField("mWordComposer").apply { isAccessible = true } private val composer get() = composerReader.get(inputLogic) as WordComposer private val spaceStateReader = InputLogic::class.java.getDeclaredField("mSpaceState").apply { isAccessible = true } private val spaceState get() = spaceStateReader.get(inputLogic) as Int private val beforeComposingReader = RichInputConnection::class.java.getDeclaredField("mCommittedTextBeforeComposingText").apply { isAccessible = true } private val connectionTextBeforeComposingText get() = (beforeComposingReader.get(connection) as CharSequence).toString() private val composingReader = RichInputConnection::class.java.getDeclaredField("mComposingText").apply { isAccessible = true } private val connectionComposingText get() = (composingReader.get(connection) as CharSequence).toString() @BeforeTest fun setUp() { latinIME = Robolectric.setupService(LatinIME::class.java) // start logging only after latinIME is created, avoids showing the stack traces if library is not found ShadowLog.setupLogging() ShadowLog.stream = System.out } @Test fun inputCode() { reset() input('c') assertEquals("c", textBeforeCursor) assertEquals("c", getText()) assertEquals("", textAfterCursor) assertEquals("c", composingText) latinIME.mHandler.onFinishInput() assertEquals("", composingText) } @Test fun delete() { reset() setText("hello there ") functionalKeyPress(KeyCode.DELETE) assertEquals("hello there", text) assertEquals("there", composingText) } @Test fun deleteInsideWord() { reset() setText("hello you there") setCursorPosition(8) // after o in you functionalKeyPress(KeyCode.DELETE) assertEquals("hello yu there", text) // todo: do we really want an empty composing text in this case? // setting whole word composing will delete text behind cursor // setting part before cursor as composing may be bad if user just wants to adjust a letter and result is some autocorrect assertEquals("", composingText) } @Test fun insertLetterIntoWord() { reset() setText("hello") setCursorPosition(3) // after first l input('i') assertEquals("helilo", getWordAtCursor()) assertEquals("helilo", getText()) assertEquals(4, getCursorPosition()) assertEquals(4, cursor) assertEquals("", composingText) } @Test fun insertLetterIntoWordWithWeirdEditor() { reset() currentInputType = 180225 // should not change much, but just to be sure setText("hello") setCursorPosition(3, weirdTextField = true) // after first l input('i') assertEquals("helilo", getWordAtCursor()) assertEquals("helilo", getText()) assertEquals(4, getCursorPosition()) assertEquals(4, cursor) } @Test fun insertLetterIntoOneOfSeveralWords() { reset() setText("hello my friend") setCursorPosition(7) // between m and y input('a') assertEquals("may", getWordAtCursor()) assertEquals("hello may friend", getText()) assertEquals(8, getCursorPosition()) assertEquals(8, cursor) } // todo: make it work, but it might not be that simple because adding is done in combiner // https://github.com/Helium314/HeliBoard/issues/214 @Test fun insertLetterIntoWordHangul() { if (BuildConfig.BUILD_TYPE == "runTests") return reset() currentScript = ScriptUtils.SCRIPT_HANGUL chainInput("ㅛㅎㄹㅎㅕㅛ") setCursorPosition(3) input('ㄲ') // fails, as expected from the hangul issue when processing the event in onCodeInput assertEquals("ㅛㅎㄹㄲ혀ㅛ", getWordAtCursor()) assertEquals("ㅛㅎㄹㄲ혀ㅛ", getText()) assertEquals("ㅛㅎㄹㄲ혀ㅛ", textBeforeCursor + textAfterCursor) assertEquals(4, getCursorPosition()) assertEquals(4, cursor) } // see issue 1447 @Test fun separatorAfterHangul() { reset() currentScript = ScriptUtils.SCRIPT_HANGUL chainInput("ㅛ.") assertEquals("ㅛ.", text) } // see issue 1551 (debug only) @Test fun deleteHangul() { reset() currentScript = ScriptUtils.SCRIPT_HANGUL setText("ㅛㅛ ") functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) } @Test fun separatorUnselectsWord() { reset() setText("hello") assertEquals("hello", composingText) input('.') assertEquals("", composingText) } @Test fun autospace() { reset() setText("hello") input('.') input('a') assertEquals("hello.a", textBeforeCursor) latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } setText("hello") input('.') input('a') assertEquals("hello. a", textBeforeCursor) } @Test fun autospaceButWithTextAfter() { reset() setText("hello there") setCursorPosition(5) // after hello input('.') input('a') assertEquals("hello.a", textBeforeCursor) assertEquals("hello.a there", text) latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } setText("hello there") setCursorPosition(5) // after hello input('.') input('a') assertEquals("hello. a", textBeforeCursor) assertEquals("hello. a there", text) } @Test fun noAutospaceInUrlField() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("example.net") assertEquals("example. net", text) lastAddedWord = "" setText("") setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("example.net") assertEquals("", lastAddedWord) assertEquals("example.net", text) assertEquals("example.net", composingText) } @Test fun noAutospaceInUrlFieldWhenPickingSuggestion() { reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("exam") pickSuggestion("example") assertEquals("example", text) input('.') assertEquals("example.", text) } @Test fun noAutospaceForDetectedUrl() { // "light" version, should work without url detection reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("http://example.net") assertEquals("http://example.net", text) assertEquals("http", lastAddedWord) assertEquals("example.net", composingText) } @Test fun noAutospaceForDetectedEmail() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("mail@example.com") assertEquals("mail@example.com", text) assertEquals("mail@example", lastAddedWord) // todo: do we want this? not really nice, but don't want to be too aggressive with URL detection disabled assertEquals("com", composingText) // todo: maybe this should still see the whole address as a single word? or don't be too aggressive? setText("") lastAddedWord = "" latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("mail@example.com") assertEquals("", lastAddedWord) assertEquals("mail@example.com", composingText) } @Test fun urlDetectionThings() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("...h") assertEquals("...h", text) assertEquals("h", composingText) reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("bla..") assertEquals("bla..", text) assertEquals("", composingText) reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("bla.c") assertEquals("bla.c", text) assertEquals("bla.c", composingText) reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } latinIME.prefs().edit { putBoolean(Settings.PREF_SHIFT_REMOVES_AUTOSPACE, true) } input("bla") input('.') functionalKeyPress(KeyCode.SHIFT) // should remove the phantom space (in addition to normal effect) input('c') assertEquals("bla.c", text) assertEquals("bla.c", composingText) } @Test fun stripSeparatorsBeforeAddingToHistoryWithURLDetection() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("example.com.") assertEquals("example.com.", composingText) input(' ') assertEquals("example.com", lastAddedWord) } @Test fun dontSelectConsecutiveSeparatorsWithURLDetection() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("bla..") assertEquals("", composingText) assertEquals("bla..", text) } @Test fun selectDoesSelect() { reset() setText("this is some text") setCursorPosition(3, 8) assertEquals("s is ", text.substring(3, 8)) } @Test fun noComposingForPasswordFields() { reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) input('a') input('b') assertEquals("", composingText) latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } input('.') input('c') assertEquals("", composingText) } @Test fun `don't select whole thing as composing word if URL detection disabled`() { reset() setText("http://example.com") setCursorPosition(13) // between l and e assertEquals("example", composingText) } @Test fun `select whole thing except http(s) as composing word if URL detection enabled and selecting`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setText("http://example.com") setCursorPosition(13) // between l and e assertEquals("example.com", composingText) setText("http://bla.com http://example.com ") setCursorPosition(29) // between l and e assertEquals("example.com", composingText) } @Test fun `select whole thing except http(s) as composing word if URL detection enabled and typing`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("http://example.com") assertEquals("example.com", composingText) } @Test fun `don't add partial URL to history`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setText("http:/") // just so lastAddedWord isn't set to http chainInput("/bla.com") assertEquals("", lastAddedWord) } @Test fun urlProperlySelected() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) setText("http://example.com/here") setCursorPosition(18) // after .com functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) // delete com // todo: do we really want no composing text? // probably not... try not to break composing assertEquals("", composingText) chainInput("net") assertEquals("example.net", composingText) } @Test fun urlProperlySelectedWhenNotDeletingFullTld() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setText("http://example.com/here") setCursorPosition(18) // after .com functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) // delete om // todo: this is a weird difference to deleting the full TLD (see urlProperlySelected) // what do we want here? (probably consistency) assertEquals("example.c/here", composingText) chainInput("z") assertEquals("", composingText) // todo: this is a weird difference to deleting the full TLD // assertEquals("example.cz", composingText) // fails, but probably would be better than above } @Test fun dontCommitPartialUrlBeforeFirstPeriod() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } // type http://bla. -> bla not selected, but clearly url, also means http://bla is committed which we probably don't want chainInput("http://bla.") assertEquals("bla.", composingText) } @Test fun `intermediate commits in text field without protocol`() { reset() chainInput("bla.") assertEquals("bla", lastAddedWord) chainInput("com/") assertEquals("com", lastAddedWord) chainInput("img.jpg") assertEquals("img", lastAddedWord) assertEquals("jpg", composingText) } @Test fun `intermediate commit in text field without protocol and with URL detection`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("bla.com/img.jpg") assertEquals("bla", lastAddedWord) assertEquals("bla.com/img.jpg", composingText) } @Test fun `only protocol commit in text field with protocol and URL detection`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("http://bla.com/img.jpg") assertEquals("http", lastAddedWord) assertEquals("bla.com/img.jpg", composingText) } @Test fun `no intermediate commit in URL field with protocol`() { reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("http://bla.com/img.jpg") assertEquals("http", lastAddedWord) // todo: somehow avoid? assertEquals("http://bla.com/img.jpg", text) assertEquals("bla.com/img.jpg", composingText) } @Test fun `no intermediate commit in URL field with protocol and URL detection`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("http://bla.com/img.jpg") assertEquals("http", lastAddedWord) // todo: somehow avoid? assertEquals("http://bla.com/img.jpg", text) assertEquals("bla.com/img.jpg", composingText) } @Test fun `no intermediate commit in URL field without protocol`() { reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("bla.com/img.jpg") assertEquals("", lastAddedWord) assertEquals("bla.com/img.jpg", text) assertEquals("bla.com/img.jpg", composingText) } @Test fun `no intermediate commit in URL field without protocol and with URL detection`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) chainInput("bla.com/img.jpg") assertEquals("", lastAddedWord) assertEquals("bla.com/img.jpg", text) assertEquals("bla.com/img.jpg", composingText) } @Test fun `don't accidentally detect some other text fields as URI`() { // see comment in InputLogic.textBeforeCursorMayBeUrlOrSimilar reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE) chainInput("Hey,why") assertEquals("Hey, why", text) } @Test fun `URL detection does not trigger on non-words`() { // first make sure it works without URL detection reset() chainInput("15:50-17") assertEquals("15:50-17", text) assertEquals("", composingText) // then with URL detection reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } chainInput("15:50-17") assertEquals("15:50-17", text) assertEquals("", composingText) } @Test fun `autospace after selecting a suggestion`() { reset() pickSuggestion("this") input('b') assertEquals("this b", text) assertEquals("b", composingText) } @Test fun `autospace works in URL field when input isn't URL`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) pickSuggestion("this") input('b') assertEquals("this b", text) assertEquals("b", composingText) } // https://github.com/Helium314/HeliBoard/issues/215 // https://github.com/Helium314/HeliBoard/issues/229 @Test fun `autospace works in URL field when input isn't URL, also for multiple suggestions`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) pickSuggestion("this") pickSuggestion("is") assertEquals("this is", text) pickSuggestion("not") assertEquals("this is not", text) input('c') assertEquals("this is not c", text) assertEquals("c", composingText) } @Test fun `emoji is added to dictionary`() { // check both text and codepoint input reset() chainInput("hello ") input(0x1F36D) assertEquals(StringUtils.newSingleCodePointString(0x1F36D), lastAddedWord) reset() chainInput("hello ") input("🤗") assertEquals("\uD83E\uDD17", lastAddedWord) reset() chainInput("hello ") input("why 🤗 ") // not added because it's not only emoji (input can come from pasting) assertEquals("hello", lastAddedWord) } @Test fun `emoji uses phantom space`() { // check both text and codepoint input reset() pickSuggestion("hi") input("🤗") assertEquals("\uD83E\uDD17", lastAddedWord) assertEquals("hi \uD83E\uDD17", text) reset() pickSuggestion("hi") input(0x1F36D) assertEquals(StringUtils.newSingleCodePointString(0x1F36D), lastAddedWord) assertEquals("hi ${StringUtils.newSingleCodePointString(0x1F36D)}", text) } // https://github.com/Helium314/HeliBoard/issues/230 @Test fun `no autospace after opening quotes`() { reset() chainInput("\"Hi\" \"h") assertEquals("\"Hi\" \"h", text) assertEquals("h", composingText) reset() chainInput("\"Hi\", \"h") assertEquals("\"Hi\", \"h", text) assertEquals("h", composingText) } @Test fun `autospace works in URL field when starting with quotes`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) } setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) input("\"") pickSuggestion("this") input("i") assertEquals("\"this i", text) } @Test fun `double space results in period and space, and delete removes the period`() { reset() chainInput("hello") input(' ') input(' ') assertEquals("hello. ", text) functionalKeyPress(KeyCode.DELETE) assertEquals("hello ", text) } @Test fun `no weird space inside multi-"`() { reset() chainInput("\"\"\"") assertEquals("\"\"\"", text) reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("\"\"\"") assertEquals("\"\"\"", text) } @Test fun `autospace still happens after "`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("\"hello\"you") assertEquals("\"hello\" you", text) } @Test fun `autospace still happens after " if next word is in quotes`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("\"hello\"\"you\"") assertEquals("\"hello\" \"you\"", text) } @Test fun `autospace propagates over "`() { reset() input('"') pickSuggestion("hello") assertEquals(spaceState, SpaceState.PHANTOM) // picking a suggestion sets phantom space state chainInput("\"you") assertEquals("\"hello\" you", text) } @Test fun `autospace still happens after " if nex word is in " and after comma`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("\"hello\",\"you\"") assertEquals("\"hello\", \"you\"", text) } @Test fun `autospace in json editor`() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) } chainInput("{\"label\":\"") assertEquals("{\"label\": \"", text) input('c') assertEquals("{\"label\": \"c", text) } @Test fun `text input and delete`() { reset() input("hello") assertEquals("hello", text) functionalKeyPress(KeyCode.DELETE) assertEquals("hell", text) reset() input("hello ") assertEquals("hello ", text) functionalKeyPress(KeyCode.DELETE) assertEquals("hello", text) } @Test fun `emoji text input and delete`() { reset() input("🕵🏼") functionalKeyPress(KeyCode.DELETE) assertEquals("", text) reset() input("\uD83D\uDD75\uD83C\uDFFC") input(' ') assertEquals("🕵🏼 ", text) functionalKeyPress(KeyCode.DELETE) functionalKeyPress(KeyCode.DELETE) assertEquals("", text) } @Test fun `revert autocorrect on delete`() { reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) chainInput("hullo") getAutocorrectedWithSpaceAfter("hello", "hullo") assertEquals("hello ", text) functionalKeyPress(KeyCode.DELETE) assertEquals("hullo", text) reset() setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) latinIME.prefs().edit { putBoolean(Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, false) } chainInput("hullo") getAutocorrectedWithSpaceAfter("hello", "hullo") functionalKeyPress(KeyCode.DELETE) assertEquals("hello", text) } @Test fun `remove glide typing word on delete`() { reset() glideTypingInput("hello") assertEquals("hello", text) functionalKeyPress(KeyCode.DELETE) assertEquals("", text) // todo: now we want some way to disable delete-all on backspace, either per setting or something else // need to avoid getting into the mWordComposer.isBatchMode() part of handleBackspaceEvent } @Test fun timestamp() { reset() chainInput("hello") functionalKeyPress(KeyCode.TIMESTAMP) assertEquals("hello" + getTimestamp(latinIME), text) } // ------- helper functions --------- // should be called before every test, so the same state is guaranteed private fun reset() { // reset input connection & facilitator currentScript = ScriptUtils.SCRIPT_LATIN text = "" batchEdit = 0 currentInputType = InputType.TYPE_CLASS_TEXT lastAddedWord = "" // reset settings latinIME.prefs().edit { clear() } setText("") // (re)sets selection and composing word } private fun chainInput(text: String) = text.forEach { input(it.code) } private fun input(char: Char) = input(char.code) private fun input(codePoint: Int) { require(codePoint > 0) { "not a codePoint: $codePoint" } val oldBefore = textBeforeCursor val oldAfter = textAfterCursor val insert = StringUtils.newSingleCodePointString(codePoint) val phantomSpaceToInsert = if (spaceState == SpaceState.PHANTOM) " " else "" latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(codePoint)) handleMessages() if (currentScript != ScriptUtils.SCRIPT_HANGUL // check fails if hangul combiner merges symbols && !(codePoint == Constants.CODE_SPACE && oldBefore.lastOrNull() == ' ') // check fails when 2 spaces are converted into a period && !latinIME.mInputLogic.mSuggestedWords.mWillAutoCorrect // autocorrect obviously creates inconsistencies ) { if (phantomSpaceToInsert.isEmpty()) assertEquals(oldBefore + insert, textBeforeCursor) else // in some cases autospace might be suppressed assert(oldBefore + phantomSpaceToInsert + insert == textBeforeCursor || oldBefore + insert == textBeforeCursor) } assertEquals(oldAfter, textAfterCursor) assertEquals(textBeforeCursor + textAfterCursor, getText()) checkConnectionConsistency() } private fun functionalKeyPress(keyCode: Int) { require(keyCode < 0) { "not a functional key code: $keyCode" } latinIME.onEvent(Event.createSoftwareKeypressEvent(Event.NOT_A_CODE_POINT, keyCode, 0, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false)) handleMessages() checkConnectionConsistency() } // almost the same as codePoint input, but calls different latinIME function private fun input(insert: String) { val oldBefore = textBeforeCursor val oldAfter = textAfterCursor val phantomSpaceToInsert = if (spaceState == SpaceState.PHANTOM) " " else "" latinIME.onTextInput(insert) handleMessages() if (phantomSpaceToInsert.isEmpty()) assertEquals(oldBefore + insert, textBeforeCursor) else // in some cases autospace might be suppressed assert(oldBefore + phantomSpaceToInsert + insert == textBeforeCursor || oldBefore + insert == textBeforeCursor) assert(oldBefore + insert == textBeforeCursor || "$oldBefore $insert" == textBeforeCursor) assertEquals(oldAfter, textAfterCursor) assertEquals(textBeforeCursor + textAfterCursor, getText()) checkConnectionConsistency() } private fun getWordAtCursor() = connection.getWordRangeAtCursor(settingsValues.mSpacingAndPunctuations, currentScript)?.mWord private fun setCursorPosition(start: Int, end: Int = start, weirdTextField: Boolean = false) { val ei = EditorInfo() ei.inputType = currentInputType ei.initialSelStart = start ei.initialSelEnd = end // imeOptions should not matter // adjust text in inputConnection first, otherwise fixLyingCursorPosition will move cursor // to the end of the text val fullText = textBeforeCursor + selectedText + textAfterCursor assertEquals(fullText, getText()) // need to update ic before, otherwise when reloading text cache from ic, ric will load wrong text before cursor val oldStart = selectionStart val oldEnd = selectionEnd selectionStart = start selectionEnd = end assertEquals(fullText, textBeforeCursor + selectedText + textAfterCursor) latinIME.onUpdateSelection(oldStart, oldEnd, start, end, composingStart, composingEnd) handleMessages() if (weirdTextField) { latinIME.mHandler.onStartInput(ei, true) // essentially does nothing latinIME.mHandler.onStartInputView(ei, true) // does the thing handleMessages() } assertEquals(fullText, getText()) assertEquals(start, selectionStart) assertEquals(end, selectionEnd) checkConnectionConsistency() } // assumes we have nothing selected private fun getCursorPosition(): Int { assertEquals(cursor, connection.expectedSelectionStart) assertEquals(cursor, connection.expectedSelectionEnd) return cursor } // just sets the text and starts input so connection it set up correctly private fun setText(newText: String) { text = newText selectionStart = newText.length selectionEnd = selectionStart composingStart = -1 composingStart = -1 // we need to start input to notify that something changed // restarting is false, so this is seen as a new text field val ei = EditorInfo() ei.inputType = currentInputType latinIME.mHandler.onStartInput(ei, false) latinIME.mHandler.onStartInputView(ei, false) handleMessages() // this is important so the composing span is set correctly checkConnectionConsistency() } // like selecting a suggestion from strip private fun pickSuggestion(suggestion: String) { val info = SuggestedWordInfo(suggestion, "", 0, 0, null, 0, 0) latinIME.pickSuggestionManually(info) checkConnectionConsistency() } // only works when autocorrect is on, separator after word is required private fun getAutocorrectedWithSpaceAfter(suggestion: String, typedWord: String?) { val info = SuggestedWordInfo(suggestion, "", 0, 0, null, 0, 0) val typedInfo = SuggestedWordInfo(typedWord, "", 0, 0, null, 0, 0) val sw = SuggestedWords(ArrayList(listOf(typedInfo, info)), null, typedInfo, false, true, false, 0, 0) latinIME.mInputLogic.setSuggestedWords(sw) // this prepares for autocorrect input(' ') checkConnectionConsistency() } private fun glideTypingInput(word: String) { val info = SuggestedWordInfo(word, "", 0, 0, null, 0, 0) val sw = SuggestedWords(ArrayList(listOf(info)), null, info, true, false, false, 0, 0) latinIME.mInputLogic.onUpdateTailBatchInputCompleted(settingsValues, sw, KeyboardSwitcher.getInstance()) } private fun checkConnectionConsistency() { // RichInputConnection only has composing text up to cursor, but InputConnection has full composing text val expectedConnectionComposingText = if (composingStart == -1 || composingEnd == -1) "" else text.substring(composingStart, min(composingEnd, selectionEnd)) assert(composingText.startsWith(expectedConnectionComposingText)) // RichInputConnection only returns text up to cursor val textBeforeComposingText = if (composingStart == -1) textBeforeCursor else text.substring(0, composingStart) println("consistency: $selectionStart, ${connection.expectedSelectionStart}, $selectionEnd, ${connection.expectedSelectionEnd}, $textBeforeComposingText, " + "$connectionTextBeforeComposingText, $composingText, $connectionComposingText, $textBeforeCursor, ${connection.getTextBeforeCursor(textBeforeCursor.length, 0)}" + ", $textAfterCursor, ${connection.getTextAfterCursor(textAfterCursor.length, 0)}") assertEquals(selectionStart, connection.expectedSelectionStart) assertEquals(selectionEnd, connection.expectedSelectionEnd) assertEquals(textBeforeComposingText, connectionTextBeforeComposingText) assertEquals(expectedConnectionComposingText, connectionComposingText) assertEquals(textBeforeCursor, connection.getTextBeforeCursor(textBeforeCursor.length, 0).toString()) assertEquals(textAfterCursor, connection.getTextAfterCursor(textAfterCursor.length, 0).toString()) } private fun getText() = connection.getTextBeforeCursor(100, 0).toString() + (connection.getSelectedText(0) ?: "") + connection.getTextAfterCursor(100, 0) private fun setInputType(inputType: Int) { // set text to actually apply input type currentInputType = inputType setText(text) } // always need to handle messages for proper simulation private fun handleMessages() { while (messages.isNotEmpty()) { latinIME.mHandler.handleMessage(messages.first()) messages.removeAt(0) } while (delayedMessages.isNotEmpty()) { val msg = delayedMessages.first() if (msg.what != 2) // MSG_UPDATE_SUGGESTION_STRIP, we want to ignore it because it's irrelevant and has a 500 ms timeout latinIME.mHandler.handleMessage(delayedMessages.first()) delayedMessages.removeAt(0) // delayed messages may post further messages, handle before next delayed message while (messages.isNotEmpty()) { latinIME.mHandler.handleMessage(messages.first()) messages.removeAt(0) } } assertEquals(0, messages.size) assertEquals(0, delayedMessages.size) } } private var currentInputType = InputType.TYPE_CLASS_TEXT private var currentScript = ScriptUtils.SCRIPT_LATIN private val messages = mutableListOf() // for latinIME / ShadowInputMethodService private val delayedMessages = mutableListOf() // for latinIME / ShadowInputMethodService // inputconnection stuff private var batchEdit = 0 private var text = "" private var selectionStart = 0 private var selectionEnd = 0 private var composingStart = -1 private var composingEnd = -1 // convenience for access private val textBeforeCursor get() = text.substring(0, selectionStart) private val textAfterCursor get() = text.substring(selectionEnd) private val selectedText get() = text.substring(selectionStart, selectionEnd) private val cursor get() = if (selectionStart == selectionEnd) selectionStart else -1 // composingText should return everything, but RichInputConnection.mComposingText only returns up to cursor private val composingText get() = if (composingStart == -1 || composingEnd == -1) "" else text.substring(composingStart, composingEnd) // essentially this is the text field we're editing in private val ic = object : InputConnection { // pretty clear (though this may be slow depending on the editor) // bad return value here is likely the cause for that weird bug improved/fixed by fixIncorrectLength override fun getTextBeforeCursor(p0: Int, p1: Int): CharSequence = textBeforeCursor.take(p0) // pretty clear (though this may be slow depending on the editor) override fun getTextAfterCursor(p0: Int, p1: Int): CharSequence = textAfterCursor.take(p0) // pretty clear override fun getSelectedText(p0: Int): CharSequence? = if (selectionStart == selectionEnd) null else text.substring(selectionStart, selectionEnd) // inserts text at cursor (right?), and sets it as composing text // this REPLACES currently composing text (even if at a different position) // moves the cursor: positive means relative to composing text start, negative means relative to start override fun setComposingText(newText: CharSequence, cursor: Int): Boolean { // first remove the composing text if any if (composingStart != -1 && composingEnd != -1) text = text.substring(0, composingStart) + text.substring(composingEnd) else // no composing span active, we should remove selected text if (selectionStart != selectionEnd) { text = textBeforeCursor + textAfterCursor selectionEnd = selectionStart } // then set the new text at old composing start // if no composing start, set it at cursor position val insertStart = if (composingStart == -1) selectionStart else composingStart text = text.substring(0, insertStart) + newText + text.substring(insertStart) composingStart = insertStart composingEnd = insertStart + newText.length // the cursor -1 is not clear in documentation, but // "So a value of 1 will always advance you to the position after the full text being inserted" // means that 1 must be composingEnd selectionStart = if (cursor > 0) composingEnd + cursor - 1 else -cursor selectionEnd = selectionStart // todo: this should call InputMethodManager#updateSelection(View, int, int, int, int) // but only after batch edit has ended // this is not used in RichInputMethodManager, but probably ends up in LatinIME.onUpdateSelection // -> DO IT (though it will likely only trigger that belatedSelectionUpdate thing, it might be relevant) return true } override fun setComposingRegion(p0: Int, p1: Int): Boolean { println("setComposingRegion, $p0, $p1") composingStart = p0 composingEnd = p1 return true // never checked } // sets composing text empty, but doesn't change actual text override fun finishComposingText(): Boolean { composingStart = -1 composingEnd = -1 return true // always true } // as per documentation: "This behaves like calling setComposingText(text, newCursorPosition) then finishComposingText()" override fun commitText(p0: CharSequence, p1: Int): Boolean { setComposingText(p0, p1) finishComposingText() return true // whether we added the text } // just tells the text field that we add many updated, and that the editor should not // send status updates until batch edit ended (not actually used for this simulation) override fun beginBatchEdit(): Boolean { ++batchEdit return true // always true } // end a batch edit, but maybe there are multiple batch edits happening override fun endBatchEdit(): Boolean { if (batchEdit > 0) return --batchEdit == 0 return false // returns true if there is still a batch edit ongoing } // should notify about cursor info containing composing text, selection, ... // todo: maybe that could be interesting, implement it? override fun requestCursorUpdates(p0: Int): Boolean { // we call this, but don't have onUpdateCursorAnchorInfo overridden in latinIME, so it does nothing // also currently we don't care about the return value return false } override fun setSelection(p0: Int, p1: Int): Boolean { selectionStart = p0 selectionEnd = p1 // todo: call InputMethodService.onUpdateSelection(int, int, int, int, int, int), but only after batch edit is done! return true } // delete beforeLength before cursor position, and afterLength after cursor position // chars, not codepoints or glyphs // todo: may delete only one half of a surrogate pair, but this should be avoided by RichInputConnection (maybe throw error) override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { // delete only before or after selection text = textBeforeCursor.substring(0, textBeforeCursor.length - beforeLength) + text.substring(selectionStart, selectionEnd) + textAfterCursor.substring(afterLength) // if parts of the composing span are deleted, shorten the span (set end to shorter) if (selectionStart <= composingStart) { composingStart -= beforeLength // is this correct? composingEnd -= beforeLength } else if (selectionStart <= composingEnd) { composingEnd -= beforeLength // is this correct? } if (selectionEnd <= composingStart) { composingStart -= afterLength composingEnd -= afterLength } else if (selectionEnd <= composingEnd) { composingEnd -= afterLength } // update selection selectionStart -= beforeLength selectionEnd -= beforeLength return true } override fun sendKeyEvent(p0: KeyEvent): Boolean { if (p0.action != KeyEvent.ACTION_DOWN) return true // only change the text on key down, like RichInputConnection does if (p0.keyCode == KeyEvent.KEYCODE_DEL) { if (selectionEnd == 0) return true // nothing to delete if (selectedText.isEmpty()) { text = text.substring(0, selectionStart - 1) + text.substring(selectionEnd) selectionStart -= 1 } else { text = text.substring(0, selectionStart) + text.substring(selectionEnd) } selectionEnd = selectionStart return true } val textToAdd = when (p0.keyCode) { KeyEvent.KEYCODE_ENTER -> "\n" KeyEvent.KEYCODE_DEL -> null KeyEvent.KEYCODE_UNKNOWN -> p0.characters else -> StringUtils.newSingleCodePointString(p0.unicodeChar) } if (textToAdd != null) { text = text.substring(0, selectionStart) + textToAdd + text.substring(selectionEnd) selectionStart += textToAdd.length selectionEnd = selectionStart composingStart = -1 composingEnd = -1 } return true } // implementation is only to work with getTextBeforeCursorAndDetectLaggyConnection override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText { return ExtractedText().also { it.startOffset = 0 it.selectionStart = selectionStart it.selectionEnd = selectionEnd } } // only effect is flashing, so whatever... override fun commitCorrection(p0: CorrectionInfo?): Boolean = 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") override fun commitCompletion(p0: CompletionInfo?): Boolean = TODO("Not yet implemented") override fun performEditorAction(p0: Int): Boolean = TODO("Not yet implemented") override fun performContextMenuAction(p0: Int): Boolean = TODO("Not yet implemented") override fun clearMetaKeyStates(p0: Int): Boolean = TODO("Not yet implemented") override fun reportFullscreenMode(p0: Boolean): Boolean = TODO("Not yet implemented") override fun performPrivateCommand(p0: String?, p1: Bundle?): Boolean = TODO("Not yet implemented") override fun getHandler(): Handler = TODO("Not yet implemented") override fun closeConnection() = TODO("Not yet implemented") override fun commitContent(p0: InputContentInfo, p1: Int, p2: Bundle?): Boolean = TODO("Not yet implemented") } // Shadows are handled by Robolectric. @Implementation overrides built-in functionality. // This is used for avoiding crashes (LocaleManagerCompat, InputMethodManager, KeyboardSwitcher) // and for simulating system stuff (InputMethodService for controlling the InputConnection, which // more or less is the contents of the text field), and for setting the current script in // KeyboardSwitcher without having to care about InputMethodSubtypes // could also extend LatinIME, it's not final anyway @Implements(InputMethodService::class) class ShadowInputMethodService { @Implementation fun getCurrentInputEditorInfo() = EditorInfo().apply { inputType = currentInputType // anything else? } @Implementation fun getCurrentInputConnection() = ic @Implementation fun isInputViewShown() = true // otherwise selection updates will do nothing } @Implements(Handler::class) class ShadowHandler { @Implementation fun sendMessage(message: Message) { messages.add(message) } @Implementation fun sendMessageDelayed(message: Message, delay: Long) { delayedMessages.add(message) } } @Implements(KeyboardSwitcher::class) class ShadowKeyboardSwitcher { @Implementation // basically only needed for null check fun getMainKeyboardView(): MainKeyboardView = Mockito.mock(MainKeyboardView::class.java) @Implementation // only affects view fun setKeyboard(keyboardId: Int, toggleState: KeyboardSwitcher.KeyboardSwitchState) = Unit @Implementation // only affects view fun setOneHandedModeEnabled(enabled: Boolean) = Unit @Implementation fun getCurrentKeyboardScript() = currentScript } @Implements(DictionaryFacilitatorImpl::class) class ShadowFacilitator2 { @Implementation fun addToUserHistory(suggestion: String, wasAutoCapitalized: Boolean, ngramContext: NgramContext, timeStampInSeconds: Long, blockPotentiallyOffensive: Boolean) { lastAddedWord = suggestion } companion object { var lastAddedWord = "" } }