Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.urik.keyboard
import android.annotation.SuppressLint
import android.inputmethodservice.InputMethodService
import android.os.Build
import android.os.SystemClock
import android.util.Size
import android.view.Gravity
import android.view.KeyEvent
Expand Down Expand Up @@ -331,7 +332,14 @@ class UrikInputMethodService :
swipeDetector = swipeDetector,
swipeSpaceManager = swipeSpaceManager,
icProvider = { currentInputConnection },
keyEventSender = { keyCode -> sendDownUpKeyEvents(keyCode) },
keyEventSender = { keyCode ->
val ic = currentInputConnection
if (ic != null) {
val now = SystemClock.uptimeMillis()
ic.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0))
ic.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0))
}
},
keyCharEventSender = { char -> sendCharacterAsKeyEvents(char) }
)

Expand Down Expand Up @@ -1071,6 +1079,10 @@ class UrikInputMethodService :
inputState.isSecureField = SecureFieldDetector.isSecure(info)
inputState.isDirectCommitField = SecureFieldDetector.isDirectCommit(info)
inputState.isRawKeyEventField = SecureFieldDetector.isRawKeyEvent(info)
inputState.isTerminalField = SecureFieldDetector.isTerminalField(info)
if (inputState.isTerminalField) {
viewModel.disableAutoCapForTerminalField()
}
inputState.currentInputAction = ActionDetector.detectAction(info)

val inputType = info?.inputType ?: 0
Expand All @@ -1086,14 +1098,14 @@ class UrikInputMethodService :

if (inputState.isSecureField) {
clearSecureFieldState()
} else if (!inputState.isUrlOrEmailField && !inputState.isRawKeyEventField) {
} else if (!inputState.isUrlOrEmailField && !inputState.isTerminalField) {
if (inputState.displayBuffer.isNotEmpty() || inputState.wordState.hasContent) {
coordinateStateClear()
}

val textBefore = outputBridge.safeGetTextBeforeCursor(50)
checkAutoCapitalization(textBefore)
} else if (!inputState.isRawKeyEventField) {
} else if (!inputState.isTerminalField) {
if (inputState.displayBuffer.isNotEmpty()) {
val actualTextBefore = outputBridge.safeGetTextBeforeCursor(1)
val actualTextAfter = outputBridge.safeGetTextAfterCursor(1)
Expand Down Expand Up @@ -1479,6 +1491,13 @@ class UrikInputMethodService :
inputState.clearBigramPredictions()

if (inputState.requiresDirectCommit) {
if (!inputState.isSecureField && !inputState.isRawKeyEventField) {
val textBefore = outputBridge.safeGetTextBeforeCursor(1)
if (textBefore.isNotEmpty() && !swipeSpaceManager.isWhitespace(textBefore)) {
outputBridge.commitText(" ", 1)
swipeSpaceManager.markAutoSpaceInserted()
}
}
outputBridge.commitText(validatedWord, 1)
return
}
Expand Down Expand Up @@ -1831,7 +1850,7 @@ class UrikInputMethodService :
viewModel.onEvent(KeyboardEvent.ModeChanged(KeyboardMode.LETTERS))
}

if (imeAction == EditorInfo.IME_ACTION_NONE && !inputState.isRawKeyEventField) {
if (imeAction == EditorInfo.IME_ACTION_NONE && !inputState.isTerminalField) {
val textBefore = outputBridge.safeGetTextBeforeCursor(50)
checkAutoCapitalization(textBefore)
}
Expand All @@ -1842,7 +1861,7 @@ class UrikInputMethodService :

private fun handleBackspace() {
try {
if (inputState.isRawKeyEventField) {
if (inputState.isTerminalField) {
outputBridge.sendBackspace()
return
}
Expand Down Expand Up @@ -2563,6 +2582,10 @@ class UrikInputMethodService :
inputState.isSecureField = SecureFieldDetector.isSecure(attribute)
inputState.isDirectCommitField = SecureFieldDetector.isDirectCommit(attribute)
inputState.isRawKeyEventField = SecureFieldDetector.isRawKeyEvent(attribute)
inputState.isTerminalField = SecureFieldDetector.isTerminalField(attribute)
if (inputState.isTerminalField) {
viewModel.disableAutoCapForTerminalField()
}
inputState.currentInputAction = ActionDetector.detectAction(attribute)

val inputType = attribute?.inputType ?: 0
Expand All @@ -2573,7 +2596,7 @@ class UrikInputMethodService :

if (inputState.isSecureField) {
clearSecureFieldState()
} else if (!inputState.isUrlOrEmailField && !inputState.isRawKeyEventField) {
} else if (!inputState.isUrlOrEmailField && !inputState.isTerminalField) {
val textBefore = outputBridge.safeGetTextBeforeCursor(50)
checkAutoCapitalization(textBefore)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ class InputStateManager(
var isRawKeyEventField: Boolean = false
internal set

@Volatile
var isTerminalField: Boolean = false
internal set

@Volatile
var isUrlOrEmailField: Boolean = false
internal set
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/urik/keyboard/service/OutputBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ class OutputBridge(
}

fun sendBackspace() {
if (state.isRawKeyEventField) {
if (state.isTerminalField) {
keyEventSender(KeyEvent.KEYCODE_DEL)
} else {
val textBefore = safeGetTextBeforeCursor(1)
Expand All @@ -317,7 +317,7 @@ class OutputBridge(
}

fun sendEnter() {
if (state.isRawKeyEventField) {
if (state.isTerminalField) {
keyEventSender(KeyEvent.KEYCODE_ENTER)
} else {
ic?.commitText("\n", 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ constructor(
updateState { it.copy(isShiftPressed = false, isCapsLockOn = false, isAutoShift = false) }
}

fun disableAutoCapForTerminalField() {
updateState { it.copy(isShiftPressed = false, isAutoShift = false) }
}

private fun handleEvent(event: KeyboardEvent) {
when (event) {
is KeyboardEvent.KeyPressed -> handleKeyPress(event.key)
Expand Down
37 changes: 29 additions & 8 deletions app/src/main/java/com/urik/keyboard/utils/SecureFieldDetector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,41 @@ object SecureFieldDetector {
}

/**
* Detects if current input field requires raw key event dispatch (TYPE_NULL or terminal-mode).
* Detects if current input field requires raw key event dispatch (TYPE_NULL terminals).
*
* Matches pure TYPE_NULL and the VISIBLE_PASSWORD + NO_SUGGESTIONS combination used by
* terminal emulators (e.g. ConnectBot) that implement BaseInputConnection with fullEditor=false,
* where commitText is a no-op and only sendKeyEvent reaches the terminal channel.
* TYPE_NULL fields (JuiceSSH, Termius) must receive ALL input — including printable
* characters — via sendKeyEvent. Their InputConnection does not process commitText.
*
* Note: VISIBLE_PASSWORD+NO_SUGGESTIONS (ConnectBot) is NOT included here — those
* fields echo printable characters via commitText and only need key events for DEL/ENTER.
* Use isTerminalField() to cover both patterns.
*/
fun isRawKeyEvent(info: EditorInfo?): Boolean {
if (info == null) return false
if (info.inputType == InputType.TYPE_NULL) return true
return info.inputType == InputType.TYPE_NULL
}

val inputClass = info.inputType and InputType.TYPE_MASK_CLASS
/**
* Detects if current input field is a terminal context requiring auto-cap suppression
* and DEL/ENTER dispatch via key events (without FLAG_SOFT_KEYBOARD).
*
* Covers two distinct terminal patterns:
* - TYPE_NULL: JuiceSSH, Termius — all input via key events
* - VISIBLE_PASSWORD + NO_SUGGESTIONS: ConnectBot — printable chars via commitText,
* DEL/ENTER via key events
*
* isRawKeyEvent (TYPE_NULL) implies isTerminalField, but not vice versa.
*/
fun isTerminalField(info: EditorInfo?): Boolean {
if (info == null) return false

val inputType = info.inputType
if (inputType == InputType.TYPE_NULL) return true

val inputClass = inputType and InputType.TYPE_MASK_CLASS
if (inputClass == InputType.TYPE_CLASS_TEXT) {
val variation = info.inputType and InputType.TYPE_MASK_VARIATION
val flags = info.inputType and InputType.TYPE_MASK_FLAGS
val variation = inputType and InputType.TYPE_MASK_VARIATION
val flags = inputType and InputType.TYPE_MASK_FLAGS
if (variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD &&
flags and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS != 0
) {
Expand Down
122 changes: 115 additions & 7 deletions app/src/test/java/com/urik/keyboard/service/OutputBridgeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ class OutputBridgeTest {
}

@Test
fun `sendBackspace uses key events for raw key event field`() {
whenever(mockState.isRawKeyEventField).thenReturn(true)
fun `sendBackspace uses key events for terminal field`() {
whenever(mockState.isTerminalField).thenReturn(true)

outputBridge.sendBackspace()

Expand All @@ -316,7 +316,7 @@ class OutputBridgeTest {

@Test
fun `sendBackspace uses deleteSurroundingText for normal field`() {
whenever(mockState.isRawKeyEventField).thenReturn(false)
whenever(mockState.isTerminalField).thenReturn(false)
whenever(mockIc.getTextBeforeCursor(eq(1), eq(0))).thenReturn("a")

outputBridge.sendBackspace()
Expand All @@ -327,7 +327,7 @@ class OutputBridgeTest {

@Test
fun `sendBackspace no-ops for normal field with empty text before cursor`() {
whenever(mockState.isRawKeyEventField).thenReturn(false)
whenever(mockState.isTerminalField).thenReturn(false)
whenever(mockIc.getTextBeforeCursor(eq(1), eq(0))).thenReturn("")

outputBridge.sendBackspace()
Expand Down Expand Up @@ -357,8 +357,8 @@ class OutputBridgeTest {
}

@Test
fun `sendEnter uses key events for raw key event field`() {
whenever(mockState.isRawKeyEventField).thenReturn(true)
fun `sendEnter uses key events for terminal field`() {
whenever(mockState.isTerminalField).thenReturn(true)

outputBridge.sendEnter()

Expand All @@ -368,11 +368,119 @@ class OutputBridgeTest {

@Test
fun `sendEnter uses commitText for normal field`() {
whenever(mockState.isRawKeyEventField).thenReturn(false)
whenever(mockState.isTerminalField).thenReturn(false)

outputBridge.sendEnter()

assertEquals(emptyList<Int>(), keyEventSenderCalls)
verify(mockIc).commitText("\n", 1)
}

@Test
fun `commitPreviousSwipeAndInsertSpace inserts space after previous swipe word`() {
whenever(mockState.wordState).thenReturn(WordState(isFromSwipe = true, buffer = "hello"))
whenever(mockState.displayBuffer).thenReturn("hello")
whenever(mockIc.getTextBeforeCursor(1, 0)).thenReturn("o")
whenever(mockSwipeSpaceManager.isWhitespace("o")).thenReturn(false)

outputBridge.commitPreviousSwipeAndInsertSpace()

verify(mockSwipeDetector).updateLastCommittedWord("hello")
verify(mockIc).beginBatchEdit()
verify(mockIc).finishComposingText()
verify(mockIc).commitText(" ", 1)
verify(mockSwipeSpaceManager).markAutoSpaceInserted()
verify(mockIc).endBatchEdit()
verify(mockState).onSwipeCommitted()
}

@Test
fun `commitPreviousSwipeAndInsertSpace skips space when cursor already after whitespace`() {
whenever(mockState.wordState).thenReturn(WordState(isFromSwipe = true, buffer = "hello"))
whenever(mockState.displayBuffer).thenReturn("hello")
whenever(mockIc.getTextBeforeCursor(1, 0)).thenReturn(" ")
whenever(mockSwipeSpaceManager.isWhitespace(" ")).thenReturn(true)

outputBridge.commitPreviousSwipeAndInsertSpace()

verify(mockSwipeDetector).updateLastCommittedWord("hello")
verify(mockIc).beginBatchEdit()
verify(mockIc).finishComposingText()
verify(mockIc, never()).commitText(eq(" "), any())
verify(mockSwipeSpaceManager, never()).markAutoSpaceInserted()
verify(mockIc).endBatchEdit()
verify(mockState).onSwipeCommitted()
}

@Test
fun `commitPreviousSwipeAndInsertSpace returns early when word is not from swipe`() {
whenever(mockState.wordState).thenReturn(WordState(isFromSwipe = false, buffer = "hello"))
whenever(mockState.displayBuffer).thenReturn("hello")

outputBridge.commitPreviousSwipeAndInsertSpace()

verify(mockSwipeDetector, never()).updateLastCommittedWord(any())
verify(mockIc, never()).beginBatchEdit()
verify(mockIc, never()).finishComposingText()
verify(mockIc, never()).commitText(any(), any())
verify(mockState, never()).onSwipeCommitted()
}

@Test
fun `commitPreviousSwipeAndInsertSpace returns early when display buffer is empty`() {
whenever(mockState.wordState).thenReturn(WordState(isFromSwipe = true, buffer = ""))
whenever(mockState.displayBuffer).thenReturn("")

outputBridge.commitPreviousSwipeAndInsertSpace()

verify(mockSwipeDetector, never()).updateLastCommittedWord(any())
verify(mockIc, never()).beginBatchEdit()
verify(mockIc, never()).finishComposingText()
verify(mockIc, never()).commitText(any(), any())
verify(mockState, never()).onSwipeCommitted()
}

@Test
fun `sendCharacter uses commitText for ConnectBot field (terminal but not raw key event)`() {
whenever(mockState.isTerminalField).thenReturn(true)
whenever(mockState.isRawKeyEventField).thenReturn(false)

outputBridge.sendCharacter("a")

assertEquals(emptyList<String>(), keyCharEventSenderCalls)
verify(mockIc).commitText("a", 1)
}

@Test
fun `sendBackspace uses key events for ConnectBot field (terminal but not raw key event)`() {
whenever(mockState.isTerminalField).thenReturn(true)
whenever(mockState.isRawKeyEventField).thenReturn(false)

outputBridge.sendBackspace()

assertEquals(listOf(KeyEvent.KEYCODE_DEL), keyEventSenderCalls)
verify(mockIc, never()).deleteSurroundingText(any(), any())
}

@Test
fun `sendEnter uses key events for ConnectBot field (terminal but not raw key event)`() {
whenever(mockState.isTerminalField).thenReturn(true)
whenever(mockState.isRawKeyEventField).thenReturn(false)

outputBridge.sendEnter()

assertEquals(listOf(KeyEvent.KEYCODE_ENTER), keyEventSenderCalls)
verify(mockIc, never()).commitText(any(), any())
}

@Test
fun `sendSpace uses commitText for ConnectBot field (terminal but not raw key event)`() {
whenever(mockState.isTerminalField).thenReturn(true)
whenever(mockState.isRawKeyEventField).thenReturn(false)

outputBridge.sendSpace()

assertEquals(emptyList<Int>(), keyEventSenderCalls)
verify(mockIc).commitText(" ", 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,32 @@ class KeyboardViewModelTest {
assertEquals(KeyboardMode.SYMBOLS, viewModel.state.value.currentMode)
}

@Test
fun `disableAutoCapForTerminalField clears both shift and autoShift atomically`() = runTest {
viewModel.enableAutoCapitalization()
assertTrue(viewModel.state.value.isShiftPressed)
assertTrue(viewModel.state.value.isAutoShift)

viewModel.disableAutoCapForTerminalField()

assertFalse(viewModel.state.value.isShiftPressed)
assertFalse(viewModel.state.value.isAutoShift)
}

@Test
fun `disableAutoCapForTerminalField is a no-op when caps lock is on`() = runTest {
viewModel.onEvent(KeyboardEvent.KeyPressed(KeyboardKey.Action(KeyboardKey.ActionType.CAPS_LOCK)))
assertTrue(viewModel.state.value.isCapsLockOn)

viewModel.enableAutoCapitalization()

viewModel.disableAutoCapForTerminalField()

assertTrue(viewModel.state.value.isCapsLockOn)
assertFalse(viewModel.state.value.isShiftPressed)
assertFalse(viewModel.state.value.isAutoShift)
}

private fun createMockLayout(mode: KeyboardMode): KeyboardLayout = KeyboardLayout(
mode = mode,
rows = emptyList()
Expand Down
Loading