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/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 6d97ad94b..c89aaa432 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,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() @@ -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` { 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 +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() + 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,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