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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Side-by-side experimental build** — an `experimental` build type (`com.asafmah.leantypedual.exp`, shown as "LeanTypeDual EXP") that installs alongside the normal build instead of replacing it, so input experiments can be compared against a working daily driver. (#141)

### Fixed
- **Fast double-taps on Shift enable Caps Lock again.** The prior duplicate-event workaround rejected legitimate taps less than 100 ms apart; distinct taps are now identified by their press/release boundary instead. (#146)
- **Gesture typing no longer silently returns zero suggestions** when a stroke's touch points never carry pointer id 0 — reachable in two-thumb use (thumb A down, thumb B down, thumb A lifts, thumb B swipes on). Raw MotionEvent pointer ids are now renumbered in first-seen order. (#135)
- **The two-thumb recognition settings no longer appear when they cannot work.** They synthesise touch points for the native gesture decoder; the built-in fallback engine scores a single trail and ignores which thumb drew it, so applying them there corrupted the trail and produced nonsense words. The group is now gated on a loaded gesture library, and explains itself when the spacing mode leaves it inert, instead of showing controls that structurally cannot take effect. (#141)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ class KeyboardState(private val switchActions: SwitchActions) {
// For handling double tap.
private var isInAlphabetUnshiftedFromShifted = false
private var isInDoubleTapShiftKey = false
private var lastShiftPressTime = 0L


private val savedKeyboardState = SavedKeyboardState()

Expand Down Expand Up @@ -554,11 +552,10 @@ class KeyboardState(private val switchActions: SwitchActions) {
shiftKeyState.onPress()
return
}
val now = android.os.SystemClock.uptimeMillis()
isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout && (now - lastShiftPressTime > 100)
lastShiftPressTime = now
// A second tap must have a release boundary; repeated press events are not double taps.
if (!shiftKeyState.isReleasing) return
isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout
if (isInDoubleTapShiftKey) {

if (alphabetShiftState.isManualShifted || isInAlphabetUnshiftedFromShifted) {
// Shift key has been double tapped while in manual shifted or automatic shifted state.
setShiftLocked(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public final class TimerHandler extends LeakGuardHandlerWrapper<DrawingProxy>
private static final int MSG_UPDATE_BATCH_INPUT = 5;
private static final int MSG_DISMISS_KEY_PREVIEW = 6;
private static final int MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT = 7;
static final int DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS = 300;

private final int mIgnoreAltCodeKeyTimeout;
private final int mGestureRecognitionUpdateTime;
Expand Down Expand Up @@ -163,7 +164,8 @@ public boolean isTypingState() {

@Override
public void startDoubleTapShiftKeyTimer() {
sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY), 300);
sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY),
DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS);
}


Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,132 @@
package helium314.keyboard.keyboard.internal

import android.os.SystemClock
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.utils.RecapitalizeMode
import kotlin.test.Test
import kotlin.test.assertEquals
import org.mockito.Mockito

class KeyboardStateTest {
@Test
fun fastDoubleTapShiftLocksCaps() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

tapShift(state)
clock.uptimeMillis += 50
tapShift(state)

assertEquals(
listOf(KeyboardSelection.MANUAL_SHIFTED, KeyboardSelection.SHIFT_LOCKED),
actions.keyboardSelections,
)
assertEquals(listOf(true), actions.shiftLockTransitions)
}

@Test
fun ordinaryDoubleTapShiftWithinTimerLocksCaps() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

tapShift(state)
clock.uptimeMillis += 150
tapShift(state)

assertEquals(
listOf(KeyboardSelection.MANUAL_SHIFTED, KeyboardSelection.SHIFT_LOCKED),
actions.keyboardSelections,
)
assertEquals(listOf(true), actions.shiftLockTransitions)
}

@Test
fun singleTapShiftStaysTemporarilyShifted() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

tapShift(state)

assertEquals(listOf(KeyboardSelection.MANUAL_SHIFTED), actions.keyboardSelections)
assertEquals(emptyList(), actions.shiftLockTransitions)
}

// Regression coverage for LeanBitLab/LeanType#186 and #188: duplicate press delivery
// must not recreate the single-tap Caps Lock bug that the 100 ms delay tried to mask.
@Test
fun duplicateShiftPressWithoutReleaseDoesNotLockCaps() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

state.onPressKey(KeyCode.SHIFT, true, 0, null)
clock.uptimeMillis += 150
state.onPressKey(KeyCode.SHIFT, true, 0, null)

assertEquals(listOf(KeyboardSelection.MANUAL_SHIFTED), actions.keyboardSelections)
assertEquals(emptyList(), actions.shiftLockTransitions)
}

@Test
fun duplicateShiftPressWhilePressingOnShiftedDoesNotLockCaps() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

tapShift(state)
clock.uptimeMillis += TimerHandler.DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS
state.onPressKey(KeyCode.SHIFT, true, 0, null)
clock.uptimeMillis += 150
state.onPressKey(KeyCode.SHIFT, true, 0, null)

assertEquals(listOf(KeyboardSelection.MANUAL_SHIFTED), actions.keyboardSelections)
assertEquals(emptyList(), actions.shiftLockTransitions)
}

@Test
fun shiftTapAfterChordingStartsANewDoubleTapWindow() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

state.onPressKey(KeyCode.SHIFT, true, 0, null)
state.onPressKey('a'.code, false, 0, null)
state.onReleaseKey('a'.code, false, 0, null)
state.onReleaseKey(KeyCode.SHIFT, false, 0, null)
clock.uptimeMillis += 50
tapShift(state)

assertEquals(
listOf(
KeyboardSelection.MANUAL_SHIFTED,
KeyboardSelection.ALPHABET,
KeyboardSelection.MANUAL_SHIFTED,
),
actions.keyboardSelections,
)
assertEquals(emptyList(), actions.shiftLockTransitions)
}

@Test
fun shiftTapAfterDoubleTapWindowUnlocksCaps() = withFakeClock { clock ->
val actions = RecordingSwitchActions(clock)
val state = loadedKeyboardState(actions)

tapShift(state)
clock.uptimeMillis += 50
tapShift(state)
clock.uptimeMillis += TimerHandler.DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS
tapShift(state)

assertEquals(
listOf(
KeyboardSelection.MANUAL_SHIFTED,
KeyboardSelection.SHIFT_LOCKED,
KeyboardSelection.SHIFT_LOCK_SHIFTED,
KeyboardSelection.ALPHABET,
),
actions.keyboardSelections,
)
assertEquals(listOf(true, false), actions.shiftLockTransitions)
}

@Test
fun customLayoutRestoresAfterSymbolsAndKeyboardReload() {
val actions = RecordingSwitchActions()
Expand All @@ -24,6 +145,25 @@ class KeyboardStateTest {
assertEquals(listOf(2, 2), actions.customLayouts)
}

private fun loadedKeyboardState(actions: RecordingSwitchActions) = KeyboardState(actions).also {
it.onLoadKeyboard(0, null, false)
actions.keyboardSelections.clear()
actions.shiftLockTransitions.clear()
}

private fun tapShift(state: KeyboardState) {
state.onPressKey(KeyCode.SHIFT, true, 0, null)
state.onReleaseKey(KeyCode.SHIFT, false, 0, null)
}

private fun withFakeClock(block: (FakeClock) -> Unit) {
val clock = FakeClock()
Mockito.mockStatic(SystemClock::class.java).use { systemClock ->
systemClock.`when`<Long> { SystemClock.uptimeMillis() }.thenAnswer { clock.uptimeMillis }
block(clock)
}
}

private fun functionalEvent(code: Int) = helium314.keyboard.event.Event.createSoftwareKeypressEvent(
helium314.keyboard.event.Event.NOT_A_CODE_POINT,
code,
Expand All @@ -33,14 +173,44 @@ class KeyboardStateTest {
false,
)

private class RecordingSwitchActions : KeyboardState.SwitchActions {
private class FakeClock(var uptimeMillis: Long = 1_000)

private enum class KeyboardSelection {
ALPHABET,
MANUAL_SHIFTED,
AUTOMATIC_SHIFTED,
SHIFT_LOCKED,
SHIFT_LOCK_SHIFTED,
}

private class RecordingSwitchActions(
private val clock: FakeClock = FakeClock(),
) : KeyboardState.SwitchActions {
val customLayouts = mutableListOf<Int>()
val keyboardSelections = mutableListOf<KeyboardSelection>()
val shiftLockTransitions = mutableListOf<Boolean>()
private var doubleTapTimerDeadline: Long? = null
private var isShiftLocked = false

override fun setAlphabetKeyboard() = Unit
override fun setAlphabetManualShiftedKeyboard() = Unit
override fun setAlphabetAutomaticShiftedKeyboard() = Unit
override fun setAlphabetShiftLockedKeyboard() = Unit
override fun setAlphabetShiftLockShiftedKeyboard() = Unit
override fun setAlphabetKeyboard() {
keyboardSelections += KeyboardSelection.ALPHABET
if (isShiftLocked) shiftLockTransitions += false
isShiftLocked = false
}
override fun setAlphabetManualShiftedKeyboard() {
keyboardSelections += KeyboardSelection.MANUAL_SHIFTED
}
override fun setAlphabetAutomaticShiftedKeyboard() {
keyboardSelections += KeyboardSelection.AUTOMATIC_SHIFTED
}
override fun setAlphabetShiftLockedKeyboard() {
keyboardSelections += KeyboardSelection.SHIFT_LOCKED
if (!isShiftLocked) shiftLockTransitions += true
isShiftLocked = true
}
override fun setAlphabetShiftLockShiftedKeyboard() {
keyboardSelections += KeyboardSelection.SHIFT_LOCK_SHIFTED
}
override fun setEmojiKeyboard() = Unit
override fun setClipboardKeyboard() = Unit
override fun setNumpadKeyboard() = Unit
Expand All @@ -49,9 +219,15 @@ class KeyboardStateTest {
override fun setSymbolsShiftedKeyboard() = Unit
override fun setCustomKeyboard(customIndex: Int) { customLayouts += customIndex }
override fun requestUpdatingShiftState(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?) = Unit
override fun startDoubleTapShiftKeyTimer() = Unit
override val isInDoubleTapShiftKeyTimeout = false
override fun cancelDoubleTapShiftKeyTimer() = Unit
override fun startDoubleTapShiftKeyTimer() {
doubleTapTimerDeadline =
clock.uptimeMillis + TimerHandler.DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS
}
override val isInDoubleTapShiftKeyTimeout
get() = doubleTapTimerDeadline?.let { clock.uptimeMillis < it } == true
override fun cancelDoubleTapShiftKeyTimer() {
doubleTapTimerDeadline = null
}
override fun setOneHandedModeEnabled(enabled: Boolean) = Unit
override fun switchOneHandedMode() = Unit
override fun toggleFloatingKeyboard() = Unit
Expand Down
Loading