From ef792b10d10584af0fcce862ab61eb4413cdbca0 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Thu, 3 Sep 2026 17:03:11 +0300 Subject: [PATCH 1/2] fix(input): restore fast Shift double-tap Require a release boundary instead of an arbitrary 100 ms floor, preserving duplicate-press protection while allowing legitimate fast Caps Lock taps. Closes #146. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../keyboard/internal/KeyboardState.kt | 9 +- .../keyboard/internal/KeyboardStateTest.kt | 159 +++++++++++++++++- 3 files changed, 154 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0ca6167..aac4931bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt index 70709a416..660da7094 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt @@ -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() @@ -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) diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt index 6d97ad94b..d25539581 100644 --- a/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt +++ b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt @@ -1,11 +1,94 @@ 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 shiftTapAfterDoubleTapWindowUnlocksCaps() = withFakeClock { clock -> + val actions = RecordingSwitchActions(clock) + val state = loadedKeyboardState(actions) + + tapShift(state) + clock.uptimeMillis += 50 + tapShift(state) + clock.uptimeMillis += DOUBLE_TAP_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() @@ -24,6 +107,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` { 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, @@ -33,14 +135,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() + val keyboardSelections = mutableListOf() + val shiftLockTransitions = mutableListOf() + 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 @@ -49,11 +181,20 @@ 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 + DOUBLE_TAP_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 } + + private companion object { + const val DOUBLE_TAP_TIMEOUT_MILLIS = 300L + } } From fdf6763fc6ea109132a465e31423db464001268e Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Thu, 3 Sep 2026 17:12:59 +0300 Subject: [PATCH 2/2] test(input): cover Shift event boundaries Exercise duplicate presses from shifted state and post-chord timing, and share the production double-tap timeout with the fake timer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/internal/TimerHandler.java | 4 +- .../keyboard/internal/KeyboardStateTest.kt | 47 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java index c6a3fb374..95e56b2a3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java @@ -28,6 +28,7 @@ public final class TimerHandler extends LeakGuardHandlerWrapper 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; @@ -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); } diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt index d25539581..c89aaa432 100644 --- a/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt +++ b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt @@ -66,6 +66,44 @@ class KeyboardStateTest { 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) @@ -74,7 +112,7 @@ class KeyboardStateTest { tapShift(state) clock.uptimeMillis += 50 tapShift(state) - clock.uptimeMillis += DOUBLE_TAP_TIMEOUT_MILLIS + clock.uptimeMillis += TimerHandler.DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS tapShift(state) assertEquals( @@ -182,7 +220,8 @@ class KeyboardStateTest { override fun setCustomKeyboard(customIndex: Int) { customLayouts += customIndex } override fun requestUpdatingShiftState(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?) = Unit override fun startDoubleTapShiftKeyTimer() { - doubleTapTimerDeadline = clock.uptimeMillis + DOUBLE_TAP_TIMEOUT_MILLIS + doubleTapTimerDeadline = + clock.uptimeMillis + TimerHandler.DOUBLE_TAP_SHIFT_KEY_TIMEOUT_MILLIS } override val isInDoubleTapShiftKeyTimeout get() = doubleTapTimerDeadline?.let { clock.uptimeMillis < it } == true @@ -193,8 +232,4 @@ class KeyboardStateTest { override fun switchOneHandedMode() = Unit override fun toggleFloatingKeyboard() = Unit } - - private companion object { - const val DOUBLE_TAP_TIMEOUT_MILLIS = 300L - } }