diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputConnection.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputConnection.java index 2f7d68094..448ebc68f 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputConnection.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputConnection.java @@ -36,6 +36,7 @@ import androidx.annotation.Nullable; import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.StringUtils; +import org.dslul.openboard.inputmethod.latin.common.StringUtilsKt; import org.dslul.openboard.inputmethod.latin.common.UnicodeSurrogate; import org.dslul.openboard.inputmethod.latin.inputlogic.PrivateCommandPerformer; import org.dslul.openboard.inputmethod.latin.settings.SpacingAndPunctuations; @@ -1001,10 +1002,18 @@ public final class RichInputConnection implements PrivateCommandPerformer { return StringUtils.lastPartLooksLikeURL(mCommittedTextBeforeComposingText); } + public boolean nonWordCodePointAndNoSpaceBeforeCursor(final SpacingAndPunctuations spacingAndPunctuations) { + return StringUtilsKt.nonWordCodePointAndNoSpaceBeforeCursor(mCommittedTextBeforeComposingText, spacingAndPunctuations); + } + public boolean spaceBeforeCursor() { return mCommittedTextBeforeComposingText.indexOf(" ") != -1; } + public boolean hasLetterBeforeLastSpaceBeforeCursor() { + return StringUtilsKt.hasLetterBeforeLastSpaceBeforeCursor(mCommittedTextBeforeComposingText); + } + public boolean wordBeforeCursorMayBeEmail() { return mCommittedTextBeforeComposingText.lastIndexOf(" ") < mCommittedTextBeforeComposingText.lastIndexOf("@"); } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt new file mode 100644 index 000000000..52ad51b74 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt @@ -0,0 +1,49 @@ +package org.dslul.openboard.inputmethod.latin.common + +import org.dslul.openboard.inputmethod.latin.settings.SpacingAndPunctuations + +fun loopOverCodePoints(s: CharSequence, run: (Int) -> Boolean) { + val text = if (s is String) s else s.toString() + var offset = 0 + while (offset < text.length) { + val codepoint = text.codePointAt(offset) + if (run(codepoint)) return + offset += Character.charCount(codepoint) + } +} + +fun loopOverCodePointsBackwards(s: CharSequence, run: (Int) -> Boolean) { + val text = if (s is String) s else s.toString() + var offset = text.length + while (offset > 0) { + val codepoint = text.codePointBefore(offset) + if (run(codepoint)) return + offset -= Character.charCount(codepoint) + } +} + +fun nonWordCodePointAndNoSpaceBeforeCursor(s: CharSequence, spacingAndPunctuations: SpacingAndPunctuations): Boolean { + var space = false + var nonWordCodePoint = false + loopOverCodePointsBackwards(s) { + if (!space && Character.isWhitespace(it)) + space = true + if (!nonWordCodePoint && !spacingAndPunctuations.isWordCodePoint(it)) + nonWordCodePoint = true + space && nonWordCodePoint + } + return space && nonWordCodePoint +} + +fun hasLetterBeforeLastSpaceBeforeCursor(s: CharSequence): Boolean { + var letter = false + loopOverCodePointsBackwards(s) { + if (Character.isWhitespace(it)) true + else if (Character.isLetter(it)) { + letter = true + true + } + else false + } + return letter +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/inputlogic/InputLogic.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/inputlogic/InputLogic.java index d6adb825b..a32b10a5b 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/inputlogic/InputLogic.java @@ -849,7 +849,7 @@ public final class InputLogic { if (Character.getType(codePoint) == Character.OTHER_SYMBOL || (sv.isWordSeparator(codePoint) && (Character.isWhitespace(codePoint) // whitespace is always a separator - || !textBeforeCursorMayBeUrlOrSimilar(sv) // if text before is not URL or similar, it's a separator + || !textBeforeCursorMayBeUrlOrSimilar(sv, false) // if text before is not URL or similar, it's a separator || (codePoint == '/' && mWordComposer.lastChar() == '/') // break composing at 2 consecutive slashes ) ) @@ -894,6 +894,8 @@ public final class InputLogic { && settingsValues.mSpacingAndPunctuations.isSometimesWordConnector(mConnection.getCodePointBeforeCursor()) // but not if there are two consecutive sometimesWordConnectors (e.g. "...bla") && !settingsValues.mSpacingAndPunctuations.isSometimesWordConnector(mConnection.getCharBeforeBeforeCursor()) + // and not if there is no letter before the separator + && mConnection.hasLetterBeforeLastSpaceBeforeCursor() ) { final CharSequence text = mConnection.textBeforeCursorUntilLastWhitespaceOrDoubleSlash(); final TextRange range = new TextRange(text, 0, text.length(), text.length(), false); @@ -2108,7 +2110,7 @@ public final class InputLogic { private void insertAutomaticSpaceIfOptionsAndTextAllow(final SettingsValues settingsValues) { if (settingsValues.shouldInsertSpacesAutomatically() && settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces - && !textBeforeCursorMayBeUrlOrSimilar(settingsValues) + && !textBeforeCursorMayBeUrlOrSimilar(settingsValues, true) && !mConnection.textBeforeCursorLooksLikeURL() // adding this check to textBeforeCursorMayBeUrlOrSimilar might not be wanted for word continuation (see effect on unit tests) && !(mConnection.getCodePointBeforeCursor() == Constants.CODE_PERIOD && mConnection.wordBeforeCursorMayBeEmail()) ) { @@ -2116,12 +2118,15 @@ public final class InputLogic { } } - private boolean textBeforeCursorMayBeUrlOrSimilar(final SettingsValues settingsValues) { + private boolean textBeforeCursorMayBeUrlOrSimilar(final SettingsValues settingsValues, final Boolean forAutoSpace) { final EditorInfo ei = getCurrentInputEditorInfo(); // URL field and no space -> may be URL // for whatever absurd reason long message, postal address and email subject have type values that return true when filtering for URI, see https://developer.android.com/reference/android/text/InputType // so we really need to specifically require URI as only type variation - if (ei != null && (ei.inputType & 0x000000f0) == 0x00000010 && !mConnection.spaceBeforeCursor()) + if (ei != null && (ei.inputType & 0x000000f0) == 0x00000010 && + // we never want to commit the first part of the url, but we want to insert autospace if text might be a normal word + (forAutoSpace ? mConnection.nonWordCodePointAndNoSpaceBeforeCursor(settingsValues.mSpacingAndPunctuations) // avoid detecting URL if it could be a word + : !mConnection.spaceBeforeCursor())) return true; // already contains a SometimesWordConnector -> may be URL (not so sure, only do with detection enabled if (settingsValues.mUrlDetectionEnabled && settingsValues.mSpacingAndPunctuations.containsSometimesWordConnector(mWordComposer.getTypedWord())) diff --git a/app/src/main/proguard.flags b/app/src/main/proguard.flags index 46b04ce98..19bbf586e 100644 --- a/app/src/main/proguard.flags +++ b/app/src/main/proguard.flags @@ -26,3 +26,5 @@ # TODO: remove once used in code. -keep class org.dslul.openboard.inputmethod.keyboard.KeyboardLayout { *; } +# after upgrading to gradle 8, stack traces contain "unknown source", which is horribly bad making them rather useless +-keepattributes SourceFile,LineNumberTable diff --git a/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt b/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt index 4c68c8703..0160ae208 100644 --- a/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt +++ b/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt @@ -12,9 +12,11 @@ import org.dslul.openboard.inputmethod.event.Event import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher import org.dslul.openboard.inputmethod.keyboard.MainKeyboardView import org.dslul.openboard.inputmethod.latin.ShadowFacilitator2.Companion.lastAddedWord +import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo import org.dslul.openboard.inputmethod.latin.common.Constants import org.dslul.openboard.inputmethod.latin.common.StringUtils import org.dslul.openboard.inputmethod.latin.inputlogic.InputLogic +import org.dslul.openboard.inputmethod.latin.inputlogic.SpaceState import org.dslul.openboard.inputmethod.latin.settings.Settings import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils @@ -48,6 +50,8 @@ class InputLogicTest { 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 } @@ -425,6 +429,38 @@ class InputLogicTest { 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() + DeviceProtectedUtils.getSharedPreferences(latinIME).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() + DeviceProtectedUtils.getSharedPreferences(latinIME).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) + } + // ------- helper functions --------- // should be called before every test, so the same state is guaranteed @@ -451,12 +487,15 @@ class InputLogicTest { 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 (!settingsValues.mAutospaceAfterPunctuationEnabled) - assertEquals(oldBefore + insert, textBeforeCursor) + if (phantomSpaceToInsert.isEmpty()) + assertEquals(oldBefore + phantomSpaceToInsert + 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() @@ -548,6 +587,13 @@ class InputLogicTest { 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() + } + private fun checkConnectionConsistency() { // RichInputConnection only has composing text up to cursor, but InputConnection has full composing text val expectedConnectionComposingText = if (composingStart == -1 || composingEnd == -1) "" @@ -730,6 +776,34 @@ private val ic = object : InputConnection { 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 + } // implement only when necessary override fun getCursorCapsMode(p0: Int): Int = TODO("Not yet implemented") override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText = TODO("Not yet implemented") @@ -738,7 +812,6 @@ private val ic = object : InputConnection { override fun commitCorrection(p0: CorrectionInfo?): 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 sendKeyEvent(p0: KeyEvent?): 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")