From c8ba99d1a1c5c293e7b5ab9b3abc1bb5f3cc0cb9 Mon Sep 17 00:00:00 2001 From: Amirhosein Akhlaghpoor Date: Sun, 26 Apr 2026 14:44:26 +0000 Subject: [PATCH] flutter: shift after one shot IME capitalization (#14695) * flutter: shift after one shot IME capitalization Signed-off-by: Amirhossein Akhlaghpour * flutter: clarify stale mobile shift handling Signed-off-by: Amirhossein Akhlaghpour * fix(android): gboard shift stuck Signed-off-by: fufesou * fix(android): gboard shift stuck, remove unused param Signed-off-by: fufesou * fix(android): gboard shift stuck, release shift before sending events Signed-off-by: fufesou * chore(flutter): document stale mobile shift release flow Signed-off-by: Amirhossein Akhlaghpour --------- Signed-off-by: Amirhossein Akhlaghpour Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/models/input_model.dart | 72 +++++++++++ flutter/lib/models/input_modifier_utils.dart | 38 ++++++ flutter/pubspec.yaml | 4 +- flutter/test/input_modifier_utils_test.dart | 125 +++++++++++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 flutter/lib/models/input_modifier_utils.dart create mode 100644 flutter/test/input_modifier_utils_test.dart diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index ab9278217..427072677 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'dart:ui' as ui; import 'package:desktop_multi_window/desktop_multi_window.dart'; @@ -15,6 +16,7 @@ import 'package:get/get.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../models/state_model.dart'; +import 'input_modifier_utils.dart'; import 'relative_mouse_model.dart'; import '../common.dart'; import '../consts.dart'; @@ -697,6 +699,38 @@ class InputModel { } } + // Safe: this only re-dispatches synthesized Shift key-up events. + // The key-up path clears the tracked Shift state so this does not loop. + void _releaseTrackedShiftKeyEventIfNeeded() { + final leftShift = toReleaseKeys.lastLShiftKeyEvent; + final rightShift = toReleaseKeys.lastRShiftKeyEvent; + if (leftShift != null) { + handleKeyEvent(leftShift); + } + if (rightShift != null) { + handleKeyEvent(rightShift); + } + } + + // Safe: this only re-dispatches synthesized Shift key-up events. + // The raw key-up path clears the tracked Shift state so this does not loop. + void _releaseTrackedRawShiftKeyEventIfNeeded() { + final leftShift = toReleaseRawKeys.lastLShiftKeyEvent; + final rightShift = toReleaseRawKeys.lastRShiftKeyEvent; + if (leftShift != null) { + handleRawKeyEvent(RawKeyUpEvent( + data: leftShift.data, + character: leftShift.character, + )); + } + if (rightShift != null) { + handleRawKeyEvent(RawKeyUpEvent( + data: rightShift.data, + character: rightShift.character, + )); + } + } + KeyEventResult handleRawKeyEvent(RawKeyEvent e) { if (isViewOnly) return KeyEventResult.handled; if (isViewCamera) return KeyEventResult.handled; @@ -751,6 +785,27 @@ class InputModel { toReleaseRawKeys.updateKeyUp(key, e); } + // On some mobile soft-keyboard paths, Flutter may leave cached Shift state + // set even though the current raw key event is not shifted anymore. + if (e is RawKeyDownEvent && + shouldReleaseStaleMobileShift( + isMobile: isMobile, + cachedShiftPressed: shift, + actualShiftPressed: e.isShiftPressed, + logicalKey: e.logicalKey, + hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null || + toReleaseRawKeys.lastRShiftKeyEvent != null, + )) { + if (kDebugMode) { + debugPrint( + 'input: releasing stale mobile Shift before replaying tracked raw ' + 'key-up (logicalKey=${e.logicalKey.keyLabel}, ' + 'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)', + ); + } + _releaseTrackedRawShiftKeyEventIfNeeded(); + } + // * Currently mobile does not enable map mode if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) { mapKeyboardModeRaw(e, iosCapsLock); @@ -794,6 +849,8 @@ class InputModel { iosCapsLock = _getIosCapsFromCharacter(e); } + // Update cached modifier state before sending the event. The stale mobile + // Shift release check below relies on this cached state. if (e is KeyUpEvent) { handleKeyUpEventModifiers(e); } else if (e is KeyDownEvent) { @@ -831,6 +888,21 @@ class InputModel { } } } + + // On some mobile soft-keyboard paths, Flutter may leave cached Shift state + // set even though the current key event is not shifted anymore. + if (e is KeyDownEvent && + shouldReleaseStaleMobileShift( + isMobile: isMobile, + cachedShiftPressed: shift, + actualShiftPressed: HardwareKeyboard.instance.isShiftPressed, + logicalKey: e.logicalKey, + hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null || + toReleaseKeys.lastRShiftKeyEvent != null, + )) { + _releaseTrackedShiftKeyEventIfNeeded(); + } + final isDesktopAndMapMode = isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode); if (isMobileAndMapMode || isDesktopAndMapMode) { diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart new file mode 100644 index 000000000..e65c32790 --- /dev/null +++ b/flutter/lib/models/input_modifier_utils.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; + +/// Returns true when a stale mobile one-shot Shift state should be released +/// by replaying a tracked Shift key-down as a synthesized key-up. +/// +/// This is only valid on mobile when Flutter's cached Shift state is still on +/// (`cachedShiftPressed == true`) but the current hardware/raw event reports +/// Shift as off (`actualShiftPressed == false`). +/// +/// A tracked Shift key-down is required so the caller can safely synthesize the +/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the +/// Shift key event itself must be processed first; otherwise we could release +/// the tracked key while still handling the original Shift press/release. +/// Callers should evaluate this only after their cached modifier state has been +/// updated for the current event. +/// +/// When this returns true, the caller logs a line like: +/// `input: releasing stale mobile Shift before replaying tracked raw key-up` +/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`. +bool shouldReleaseStaleMobileShift({ + required bool isMobile, + required bool cachedShiftPressed, + required bool actualShiftPressed, + required LogicalKeyboardKey logicalKey, + required bool hasTrackedShiftKeyDown, +}) { + if (!isMobile || !cachedShiftPressed || actualShiftPressed) { + return false; + } + if (!hasTrackedShiftKeyDown) { + return false; + } + if (logicalKey == LogicalKeyboardKey.shiftLeft || + logicalKey == LogicalKeyboardKey.shiftRight) { + return false; + } + return true; +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index eb6d76161..eddf5a19d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -113,8 +113,8 @@ dependencies: dev_dependencies: icons_launcher: ^2.0.4 - #flutter_test: - #sdk: flutter + flutter_test: + sdk: flutter build_runner: ^2.4.6 freezed: ^2.4.2 flutter_lints: ^2.0.2 diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart new file mode 100644 index 000000000..2e1971753 --- /dev/null +++ b/flutter/test/input_modifier_utils_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; + +void main() { + group('shouldReleaseStaleMobileShift', () { + test('does not release when cached shift is already false', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: false, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases one-shot mobile shift after a text key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release manually toggled shift without tracked key down', + () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: false, + ), + isFalse, + ); + }); + + test('does not release when shift is still physically pressed', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: true, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on non-mobile platforms', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: false, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases on enter key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.enter, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('releases on arrow key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.arrowLeft, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release on modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftLeft, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on shiftRight modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftRight, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + }); +}