From 0725a335a06b1e0bc6c23c78f9d725287010387d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 11 Nov 2026 15:39:38 +0200 Subject: [PATCH] Report hardware keyboard keys in Android TextInput onKeyPress ReactEditText.onKeyDown reports every key event the view receives, from a hardware keyboard or forwarded by an input method through InputConnection.sendKeyEvent, by its KeyboardEvent.key value, with altKey, ctrlKey, metaKey and shiftKey. A Ctrl or Meta shortcut is named by its key, and so is an Alt shortcut where Alt types nothing on the layout. A key that types a character after a dead key is reported as the composed character, and system keys such as Back and volume are not reported. ReactEditTextInputConnectionWrapper reports only the text an input method types, so no key is reported twice. The key table follows the UI Events key values as Firefox for Android pairs them with key codes, so View onKeyDown also names Home, End, the function keys and Ctrl shortcuts instead of reporting them as Unidentified. --- .../AndroidTextInputNativeComponent.js | 15 +- .../Components/TextInput/TextInput.flow.js | 8 + .../react/uimanager/events/KeyEvent.kt | 35 +- .../react/uimanager/events/KeyValue.kt | 215 +++++++++++ .../react/views/textinput/ReactEditText.kt | 31 ++ .../ReactEditTextInputConnectionWrapper.kt | 26 -- .../textinput/ReactTextInputKeyPressEvent.kt | 17 +- .../uimanager/events/KeyDownEventTest.kt | 50 +++ .../react/uimanager/events/KeyValueTest.kt | 49 +++ .../textinput/ReactEditTextKeyPressTest.kt | 346 ++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 12 +- .../Components/TextInput/TextInput.d.ts | 6 +- 12 files changed, 742 insertions(+), 68 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyDownEventTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyValueTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index fd8fd9919d6d..eb542d05423a 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -437,9 +437,22 @@ export type AndroidTextInputNativeProps = Readonly<{ * This will be called with `{ nativeEvent: { key: keyValue } }` * where `keyValue` is `'Enter'` or `'Backspace'` for respective keys and * the typed-in character otherwise including `' '` for space. + * On Android, keys pressed on a hardware keyboard are reported as well, each + * by its `KeyboardEvent.key` value (such as `'ArrowLeft'`, `'Shift'` or + * `'F1'`), with `altKey`, `ctrlKey`, `metaKey` and `shiftKey` set for the + * modifier keys held. * Fires before `onChange` callbacks. */ - onKeyPress?: ?BubblingEventHandler>, + onKeyPress?: ?BubblingEventHandler< + Readonly<{ + target: Int32, + key: string, + altKey: boolean, + ctrlKey: boolean, + metaKey: boolean, + shiftKey: boolean, + }>, + >, /** * Invoked on content scroll with `{ nativeEvent: { contentOffset: { x, y } } }`. diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 91bc7901343d..fb3cbee53923 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -101,6 +101,10 @@ type TextInputKeyPressEventData = Readonly<{ key: string, target?: ?number, eventCount: number, + altKey?: boolean, + ctrlKey?: boolean, + metaKey?: boolean, + shiftKey?: boolean, ... }>; @@ -850,6 +854,10 @@ type TextInputBaseProps = Readonly<{ * This will be called with `{ nativeEvent: { key: keyValue } }` * where `keyValue` is `'Enter'` or `'Backspace'` for respective keys and * the typed-in character otherwise including `' '` for space. + * On Android, keys pressed on a hardware keyboard are reported as well, each + * by its `KeyboardEvent.key` value (such as `'ArrowLeft'`, `'Shift'` or + * `'F1'`), with `altKey`, `ctrlKey`, `metaKey` and `shiftKey` set for the + * modifier keys held. * Fires before `onChange` callbacks. */ onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt index 802221e40939..ac25c8870314 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt @@ -19,7 +19,7 @@ internal abstract class KeyEvent( // Extract all needed data from keyEvent immediately to avoid storing the AndroidKeyEvent itself private val keyCode: Int = keyEvent.keyCode - private val unicodeChar: Int = keyEvent.unicodeChar + private val key: String = KeyValue.of(keyEvent) private val isAltPressed: Boolean = keyEvent.isAltPressed private val isCtrlPressed: Boolean = keyEvent.isCtrlPressed private val isMetaPressed: Boolean = keyEvent.isMetaPressed @@ -35,7 +35,7 @@ internal abstract class KeyEvent( eventData.putInt("target", viewTag) // W3C KeyboardEvent properties - eventData.putString("key", getKeyString()) + eventData.putString("key", key) eventData.putString("code", getCodeString()) // Modifier keys @@ -50,13 +50,6 @@ internal abstract class KeyEvent( return eventData } - private fun getKeyString(): String { - return when { - unicodeChar != 0 && !Character.isISOControl(unicodeChar) -> unicodeChar.toChar().toString() - else -> KEY_NAME_MAP[keyCode] ?: UNIDENTIFIED - } - } - private fun getCodeString(): String { return CODE_MAP[keyCode] ?: UNIDENTIFIED } @@ -128,29 +121,5 @@ internal abstract class KeyEvent( AndroidKeyEvent.KEYCODE_DPAD_CENTER to "Enter", ) } - - private val KEY_NAME_MAP: Map by - lazy(LazyThreadSafetyMode.PUBLICATION) { - mapOf( - AndroidKeyEvent.KEYCODE_ENTER to "Enter", - AndroidKeyEvent.KEYCODE_DPAD_CENTER to "Enter", - AndroidKeyEvent.KEYCODE_SPACE to " ", - AndroidKeyEvent.KEYCODE_TAB to "Tab", - AndroidKeyEvent.KEYCODE_DEL to "Backspace", - AndroidKeyEvent.KEYCODE_ESCAPE to "Escape", - AndroidKeyEvent.KEYCODE_SHIFT_LEFT to "Shift", - AndroidKeyEvent.KEYCODE_SHIFT_RIGHT to "Shift", - AndroidKeyEvent.KEYCODE_CTRL_LEFT to "Control", - AndroidKeyEvent.KEYCODE_CTRL_RIGHT to "Control", - AndroidKeyEvent.KEYCODE_ALT_LEFT to "Alt", - AndroidKeyEvent.KEYCODE_ALT_RIGHT to "Alt", - AndroidKeyEvent.KEYCODE_META_LEFT to "Meta", - AndroidKeyEvent.KEYCODE_META_RIGHT to "Meta", - AndroidKeyEvent.KEYCODE_DPAD_UP to "ArrowUp", - AndroidKeyEvent.KEYCODE_DPAD_DOWN to "ArrowDown", - AndroidKeyEvent.KEYCODE_DPAD_LEFT to "ArrowLeft", - AndroidKeyEvent.KEYCODE_DPAD_RIGHT to "ArrowRight", - ) - } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt new file mode 100644 index 000000000000..636c39011d28 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events + +import android.view.KeyCharacterMap +import android.view.KeyEvent as AndroidKeyEvent + +/** + * The [UI Events key value](https://www.w3.org/TR/uievents-key/) of an Android key event, with key + * codes paired to key values as Firefox for Android pairs them + * ([NativeKeyToDOMKeyName.h](https://searchfox.org/mozilla-central/source/widget/NativeKeyToDOMKeyName.h)). + */ +internal object KeyValue { + + fun of(event: AndroidKeyEvent): String = of(event.keyCode, typedCharacter(event)) + + /** The key value of the key [keyCode] when it types [character], as [typedCharacter] reads it. */ + fun of(keyCode: Int, character: Int): String = + when { + isDeadKey(character) -> DEAD + character != 0 && !Character.isISOControl(character) -> String(Character.toChars(character)) + else -> NAMED_KEYS[keyCode] ?: UNIDENTIFIED + } + + /** + * The character [event] types with Control and Meta released, and with Alt released too when Alt + * types nothing on the layout, because the key value of a shortcut such as Ctrl+Z or Alt+E is the + * character of its key. + */ + fun typedCharacter(event: AndroidKeyEvent): Int { + val character = event.getUnicodeChar(event.metaState and SHORTCUT_MODIFIERS.inv()) + return if (character != 0 || !event.isAltPressed) { + character + } else { + event.getUnicodeChar(event.metaState and (SHORTCUT_MODIFIERS or ALT_MODIFIERS).inv()) + } + } + + fun isDeadKey(character: Int): Boolean = (character and KeyCharacterMap.COMBINING_ACCENT) != 0 + + private const val SHORTCUT_MODIFIERS: Int = + AndroidKeyEvent.META_CTRL_MASK or AndroidKeyEvent.META_META_MASK + private const val ALT_MODIFIERS: Int = AndroidKeyEvent.META_ALT_MASK + private const val DEAD: String = "Dead" + private const val UNIDENTIFIED: String = "Unidentified" + + private val NAMED_KEYS: Map by + lazy(LazyThreadSafetyMode.PUBLICATION) { + mapOf( + AndroidKeyEvent.KEYCODE_ALT_LEFT to "Alt", + AndroidKeyEvent.KEYCODE_ALT_RIGHT to "Alt", + AndroidKeyEvent.KEYCODE_CAPS_LOCK to "CapsLock", + AndroidKeyEvent.KEYCODE_CTRL_LEFT to "Control", + AndroidKeyEvent.KEYCODE_CTRL_RIGHT to "Control", + AndroidKeyEvent.KEYCODE_FUNCTION to "Fn", + AndroidKeyEvent.KEYCODE_META_LEFT to "Meta", + AndroidKeyEvent.KEYCODE_META_RIGHT to "Meta", + AndroidKeyEvent.KEYCODE_NUM_LOCK to "NumLock", + AndroidKeyEvent.KEYCODE_SCROLL_LOCK to "ScrollLock", + AndroidKeyEvent.KEYCODE_SHIFT_LEFT to "Shift", + AndroidKeyEvent.KEYCODE_SHIFT_RIGHT to "Shift", + AndroidKeyEvent.KEYCODE_SYM to "Symbol", + AndroidKeyEvent.KEYCODE_DPAD_CENTER to "Enter", + AndroidKeyEvent.KEYCODE_ENTER to "Enter", + AndroidKeyEvent.KEYCODE_NUMPAD_ENTER to "Enter", + AndroidKeyEvent.KEYCODE_TAB to "Tab", + AndroidKeyEvent.KEYCODE_DPAD_DOWN to "ArrowDown", + AndroidKeyEvent.KEYCODE_DPAD_LEFT to "ArrowLeft", + AndroidKeyEvent.KEYCODE_DPAD_RIGHT to "ArrowRight", + AndroidKeyEvent.KEYCODE_DPAD_UP to "ArrowUp", + AndroidKeyEvent.KEYCODE_MOVE_END to "End", + AndroidKeyEvent.KEYCODE_MOVE_HOME to "Home", + AndroidKeyEvent.KEYCODE_PAGE_DOWN to "PageDown", + AndroidKeyEvent.KEYCODE_PAGE_UP to "PageUp", + AndroidKeyEvent.KEYCODE_DEL to "Backspace", + AndroidKeyEvent.KEYCODE_CLEAR to "Clear", + AndroidKeyEvent.KEYCODE_COPY to "Copy", + AndroidKeyEvent.KEYCODE_CUT to "Cut", + AndroidKeyEvent.KEYCODE_FORWARD_DEL to "Delete", + AndroidKeyEvent.KEYCODE_INSERT to "Insert", + AndroidKeyEvent.KEYCODE_PASTE to "Paste", + AndroidKeyEvent.KEYCODE_MENU to "ContextMenu", + AndroidKeyEvent.KEYCODE_ESCAPE to "Escape", + AndroidKeyEvent.KEYCODE_HELP to "Help", + AndroidKeyEvent.KEYCODE_BREAK to "Pause", + AndroidKeyEvent.KEYCODE_ZOOM_IN to "ZoomIn", + AndroidKeyEvent.KEYCODE_ZOOM_OUT to "ZoomOut", + AndroidKeyEvent.KEYCODE_BRIGHTNESS_DOWN to "BrightnessDown", + AndroidKeyEvent.KEYCODE_BRIGHTNESS_UP to "BrightnessUp", + AndroidKeyEvent.KEYCODE_MEDIA_EJECT to "Eject", + AndroidKeyEvent.KEYCODE_POWER to "Power", + AndroidKeyEvent.KEYCODE_SYSRQ to "PrintScreen", + AndroidKeyEvent.KEYCODE_SLEEP to "Standby", + AndroidKeyEvent.KEYCODE_WAKEUP to "WakeUp", + AndroidKeyEvent.KEYCODE_HENKAN to "Convert", + AndroidKeyEvent.KEYCODE_LANGUAGE_SWITCH to "GroupNext", + AndroidKeyEvent.KEYCODE_SWITCH_CHARSET to "ModeChange", + AndroidKeyEvent.KEYCODE_MUHENKAN to "NonConvert", + AndroidKeyEvent.KEYCODE_EISU to "Eisu", + AndroidKeyEvent.KEYCODE_KATAKANA_HIRAGANA to "HiraganaKatakana", + AndroidKeyEvent.KEYCODE_KANA to "KanjiMode", + AndroidKeyEvent.KEYCODE_ZENKAKU_HANKAKU to "ZenkakuHankaku", + AndroidKeyEvent.KEYCODE_F1 to "F1", + AndroidKeyEvent.KEYCODE_F2 to "F2", + AndroidKeyEvent.KEYCODE_F3 to "F3", + AndroidKeyEvent.KEYCODE_F4 to "F4", + AndroidKeyEvent.KEYCODE_F5 to "F5", + AndroidKeyEvent.KEYCODE_F6 to "F6", + AndroidKeyEvent.KEYCODE_F7 to "F7", + AndroidKeyEvent.KEYCODE_F8 to "F8", + AndroidKeyEvent.KEYCODE_F9 to "F9", + AndroidKeyEvent.KEYCODE_F10 to "F10", + AndroidKeyEvent.KEYCODE_F11 to "F11", + AndroidKeyEvent.KEYCODE_F12 to "F12", + AndroidKeyEvent.KEYCODE_CHANNEL_DOWN to "ChannelDown", + AndroidKeyEvent.KEYCODE_CHANNEL_UP to "ChannelUp", + AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD to "MediaFastForward", + AndroidKeyEvent.KEYCODE_MEDIA_PAUSE to "MediaPause", + AndroidKeyEvent.KEYCODE_MEDIA_PLAY to "MediaPlay", + AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE to "MediaPlayPause", + AndroidKeyEvent.KEYCODE_MEDIA_RECORD to "MediaRecord", + AndroidKeyEvent.KEYCODE_MEDIA_REWIND to "MediaRewind", + AndroidKeyEvent.KEYCODE_MEDIA_STOP to "MediaStop", + AndroidKeyEvent.KEYCODE_MEDIA_NEXT to "MediaTrackNext", + AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS to "MediaTrackPrevious", + AndroidKeyEvent.KEYCODE_VOLUME_DOWN to "AudioVolumeDown", + AndroidKeyEvent.KEYCODE_VOLUME_UP to "AudioVolumeUp", + AndroidKeyEvent.KEYCODE_VOLUME_MUTE to "AudioVolumeMute", + AndroidKeyEvent.KEYCODE_MUTE to "MicrophoneVolumeMute", + AndroidKeyEvent.KEYCODE_CALCULATOR to "LaunchCalculator", + AndroidKeyEvent.KEYCODE_CALENDAR to "LaunchCalendar", + AndroidKeyEvent.KEYCODE_CONTACTS to "LaunchContacts", + AndroidKeyEvent.KEYCODE_ENVELOPE to "LaunchMail", + AndroidKeyEvent.KEYCODE_MUSIC to "LaunchMusicPlayer", + AndroidKeyEvent.KEYCODE_EXPLORER to "LaunchWebBrowser", + AndroidKeyEvent.KEYCODE_BOOKMARK to "BrowserFavorites", + AndroidKeyEvent.KEYCODE_FORWARD to "BrowserForward", + AndroidKeyEvent.KEYCODE_SEARCH to "BrowserSearch", + AndroidKeyEvent.KEYCODE_APP_SWITCH to "AppSwitch", + AndroidKeyEvent.KEYCODE_CALL to "Call", + AndroidKeyEvent.KEYCODE_CAMERA to "Camera", + AndroidKeyEvent.KEYCODE_FOCUS to "CameraFocus", + AndroidKeyEvent.KEYCODE_ENDCALL to "EndCall", + AndroidKeyEvent.KEYCODE_BACK to "GoBack", + AndroidKeyEvent.KEYCODE_HOME to "GoHome", + AndroidKeyEvent.KEYCODE_HEADSETHOOK to "HeadsetHook", + AndroidKeyEvent.KEYCODE_NOTIFICATION to "Notification", + AndroidKeyEvent.KEYCODE_MANNER_MODE to "MannerMode", + AndroidKeyEvent.KEYCODE_TV to "TV", + AndroidKeyEvent.KEYCODE_3D_MODE to "TV3DMode", + AndroidKeyEvent.KEYCODE_TV_ANTENNA_CABLE to "TVAntennaCable", + AndroidKeyEvent.KEYCODE_TV_AUDIO_DESCRIPTION to "TVAudioDescription", + AndroidKeyEvent.KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN to "TVAudioDescriptionMixDown", + AndroidKeyEvent.KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP to "TVAudioDescriptionMixUp", + AndroidKeyEvent.KEYCODE_TV_CONTENTS_MENU to "TVContentsMenu", + AndroidKeyEvent.KEYCODE_TV_DATA_SERVICE to "TVDataService", + AndroidKeyEvent.KEYCODE_TV_INPUT to "TVInput", + AndroidKeyEvent.KEYCODE_TV_INPUT_COMPONENT_1 to "TVInputComponent1", + AndroidKeyEvent.KEYCODE_TV_INPUT_COMPONENT_2 to "TVInputComponent2", + AndroidKeyEvent.KEYCODE_TV_INPUT_COMPOSITE_1 to "TVInputComposite1", + AndroidKeyEvent.KEYCODE_TV_INPUT_COMPOSITE_2 to "TVInputComposite2", + AndroidKeyEvent.KEYCODE_TV_INPUT_HDMI_1 to "TVInputHDMI1", + AndroidKeyEvent.KEYCODE_TV_INPUT_HDMI_2 to "TVInputHDMI2", + AndroidKeyEvent.KEYCODE_TV_INPUT_HDMI_3 to "TVInputHDMI3", + AndroidKeyEvent.KEYCODE_TV_INPUT_HDMI_4 to "TVInputHDMI4", + AndroidKeyEvent.KEYCODE_TV_INPUT_VGA_1 to "TVInputVGA1", + AndroidKeyEvent.KEYCODE_TV_NETWORK to "TVNetwork", + AndroidKeyEvent.KEYCODE_TV_NUMBER_ENTRY to "TVNumberEntry", + AndroidKeyEvent.KEYCODE_TV_POWER to "TVPower", + AndroidKeyEvent.KEYCODE_TV_RADIO_SERVICE to "TVRadioService", + AndroidKeyEvent.KEYCODE_TV_SATELLITE to "TVSatellite", + AndroidKeyEvent.KEYCODE_TV_SATELLITE_BS to "TVSatelliteBS", + AndroidKeyEvent.KEYCODE_TV_SATELLITE_CS to "TVSatelliteCS", + AndroidKeyEvent.KEYCODE_TV_SATELLITE_SERVICE to "TVSatelliteToggle", + AndroidKeyEvent.KEYCODE_TV_TERRESTRIAL_ANALOG to "TVTerrestrialAnalog", + AndroidKeyEvent.KEYCODE_TV_TERRESTRIAL_DIGITAL to "TVTerrestrialDigital", + AndroidKeyEvent.KEYCODE_TV_TIMER_PROGRAMMING to "TVTimer", + AndroidKeyEvent.KEYCODE_AVR_INPUT to "AVRInput", + AndroidKeyEvent.KEYCODE_AVR_POWER to "AVRPower", + AndroidKeyEvent.KEYCODE_PROG_RED to "ColorF0Red", + AndroidKeyEvent.KEYCODE_PROG_GREEN to "ColorF1Green", + AndroidKeyEvent.KEYCODE_PROG_YELLOW to "ColorF2Yellow", + AndroidKeyEvent.KEYCODE_PROG_BLUE to "ColorF3Blue", + AndroidKeyEvent.KEYCODE_CAPTIONS to "ClosedCaptionToggle", + AndroidKeyEvent.KEYCODE_DVR to "DVR", + AndroidKeyEvent.KEYCODE_GUIDE to "Guide", + AndroidKeyEvent.KEYCODE_INFO to "Info", + AndroidKeyEvent.KEYCODE_MEDIA_AUDIO_TRACK to "MediaAudioTrack", + AndroidKeyEvent.KEYCODE_LAST_CHANNEL to "MediaLast", + AndroidKeyEvent.KEYCODE_MEDIA_TOP_MENU to "MediaTopMenu", + AndroidKeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD to "MediaSkipBackward", + AndroidKeyEvent.KEYCODE_MEDIA_SKIP_FORWARD to "MediaSkipForward", + AndroidKeyEvent.KEYCODE_MEDIA_STEP_BACKWARD to "MediaStepBackward", + AndroidKeyEvent.KEYCODE_MEDIA_STEP_FORWARD to "MediaStepForward", + AndroidKeyEvent.KEYCODE_NAVIGATE_IN to "NavigateIn", + AndroidKeyEvent.KEYCODE_NAVIGATE_NEXT to "NavigateNext", + AndroidKeyEvent.KEYCODE_NAVIGATE_OUT to "NavigateOut", + AndroidKeyEvent.KEYCODE_NAVIGATE_PREVIOUS to "NavigatePrevious", + AndroidKeyEvent.KEYCODE_PAIRING to "Pairing", + AndroidKeyEvent.KEYCODE_WINDOW to "PinPToggle", + AndroidKeyEvent.KEYCODE_SETTINGS to "Settings", + AndroidKeyEvent.KEYCODE_STB_INPUT to "STBInput", + AndroidKeyEvent.KEYCODE_STB_POWER to "STBPower", + AndroidKeyEvent.KEYCODE_TV_TELETEXT to "Teletext", + AndroidKeyEvent.KEYCODE_TV_ZOOM_MODE to "ZoomToggle", + AndroidKeyEvent.KEYCODE_SOFT_LEFT to "SoftLeft", + AndroidKeyEvent.KEYCODE_SOFT_RIGHT to "SoftRight", + ) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index bb5540b37d24..b9c5a238ae43 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -75,6 +75,7 @@ import com.facebook.react.uimanager.ReactAccessibilityDelegate import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.events.KeyValue import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.LogicalEdge @@ -145,6 +146,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat private var touchDownY = 0f private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private var onKeyPress = false + private var isDeadKeyPending = false private val textAttributes: TextAttributes private var typefaceDirty = false private var fontFamily: String? = null @@ -376,6 +378,34 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } } + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + val character = KeyValue.typedCharacter(event) + if (onKeyPress && !event.isSystem) { + eventDispatcher?.dispatchEvent( + ReactTextInputKeyPressEvent( + UIManagerHelper.getSurfaceId(this), + id, + KeyValue.of(keyCode, composeWithPendingDeadKey(character)), + event.metaState, + ) + ) + } + val handled = super.onKeyDown(keyCode, event) + isDeadKeyPending = + KeyValue.isDeadKey(character) || (isDeadKeyPending && KeyEvent.isModifierKey(keyCode)) + return handled + } + + // QwertyKeyListener leaves a dead key's accent selected and composes it with the next character. + private fun composeWithPendingDeadKey(character: Int): Int { + val text = text + if (!isDeadKeyPending || character == 0 || text == null || selectionEnd - selectionStart != 1) { + return character + } + val composed = KeyEvent.getDeadChar(text[selectionStart].code, character) + return if (composed != 0) composed else character + } + // Consume 'Enter' key events: TextView tries to give focus to the next TextInput, but it can't // since we only allow JS to change focus, which in turn causes TextView to crash. override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { @@ -545,6 +575,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat FLog.e(TAG, "onSelectionChanged[$id]: $selStart $selEnd") } + isDeadKeyPending = false super.onSelectionChanged(selStart, selEnd) if (selectionWatcher != null && hasFocus()) { selectionWatcher?.onSelectionChanged(selStart, selEnd) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.kt index c80c9d58d95b..8959af39cb03 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.kt @@ -7,7 +7,6 @@ package com.facebook.react.views.textinput -import android.view.KeyEvent import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import com.facebook.react.bridge.ReactContext @@ -38,12 +37,6 @@ import com.facebook.react.uimanager.events.EventDispatcher * composing text with [InputConnection.setComposingText] and commits a space character with a * separate call to [InputConnection.setComposingText]. Here we chose to emit the last input of a * batch edit as that tends to be the user input, but it's completely arbitrary. - * - *

Another function of this class is to detect backspaces when the cursor is at the beginning of - * the [android.widget.EditText], i.e no text is deleted. - * - *

N.B. this class is only applicable for soft keyboards behavior. For hardware keyboards - * [android.view.View.onKeyDown] can be overridden to obtain the keycode of the key pressed. */ internal class ReactEditTextInputConnectionWrapper( target: InputConnection, @@ -111,25 +104,6 @@ internal class ReactEditTextInputConnectionWrapper( return super.deleteSurroundingText(beforeLength, afterLength) } - // Called by SwiftKey when cursor at beginning of input when there is a delete - // or when enter is pressed anywhere in the text. Whereas stock Android Keyboard calls - // [InputConnection.deleteSurroundingText] & [InputConnection.commitText] - // in each case, respectively. - override fun sendKeyEvent(event: KeyEvent): Boolean { - if (event.action == KeyEvent.ACTION_DOWN) { - val isNumberKey = event.unicodeChar in 48..57 - when (event.keyCode) { - KeyEvent.KEYCODE_DEL -> dispatchKeyEvent(BACKSPACE_KEY_VALUE) - KeyEvent.KEYCODE_ENTER -> dispatchKeyEvent(ENTER_KEY_VALUE) - else -> - if (isNumberKey) { - dispatchKeyEvent(event.number.toString()) - } - } - } - return super.sendKeyEvent(event) - } - private fun dispatchKeyEventOrEnqueue(inputKey: String) { if (isBatchEdit) { key = inputKey diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.kt index 8e5b182f947e..18944137d12b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.kt @@ -7,17 +7,28 @@ package com.facebook.react.views.textinput +import android.view.KeyEvent import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.events.Event /** Event emitted by EditText native view when key pressed. */ -internal class ReactTextInputKeyPressEvent(surfaceId: Int, viewId: Int, private val key: String) : - Event(surfaceId, viewId) { +internal class ReactTextInputKeyPressEvent( + surfaceId: Int, + viewId: Int, + private val key: String, + private val metaState: Int = 0, +) : Event(surfaceId, viewId) { override fun getEventName(): String = EVENT_NAME protected override fun getEventData(): WritableMap { - return Arguments.createMap().apply { putString("key", key) } + return Arguments.createMap().apply { + putString("key", key) + putBoolean("altKey", (metaState and KeyEvent.META_ALT_ON) != 0) + putBoolean("ctrlKey", (metaState and KeyEvent.META_CTRL_ON) != 0) + putBoolean("metaKey", (metaState and KeyEvent.META_META_ON) != 0) + putBoolean("shiftKey", (metaState and KeyEvent.META_SHIFT_ON) != 0) + } } // We don't want to miss any textinput event, as event data is incremental. diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyDownEventTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyDownEventTest.kt new file mode 100644 index 000000000000..41ee36994feb --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyDownEventTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events + +import android.view.KeyEvent as AndroidKeyEvent +import com.facebook.react.bridge.WritableMap +import com.facebook.testutils.shadows.ShadowArguments +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class]) +class KeyDownEventTest { + + @Suppress("DEPRECATION") + private fun keyOf(keyCode: Int): String? { + val event = KeyDownEvent(1, 2, AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, keyCode, 0)) + val emitter = mock() + val payload = argumentCaptor() + event.dispatchModern(emitter) + verify(emitter).receiveEvent(any(), any(), any(), any(), any(), payload.capture(), any()) + return payload.firstValue.getString("key") + } + + @Test + fun keyThatTypesNoCharacterIsNamedByItsKeyValue() { + assertThat( + listOf( + AndroidKeyEvent.KEYCODE_MOVE_HOME, + AndroidKeyEvent.KEYCODE_PAGE_DOWN, + AndroidKeyEvent.KEYCODE_FORWARD_DEL, + AndroidKeyEvent.KEYCODE_F1, + ) + .map(::keyOf) + ) + .containsExactly("Home", "PageDown", "Delete", "F1") + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyValueTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyValueTest.kt new file mode 100644 index 000000000000..da39660a641d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/events/KeyValueTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events + +import android.view.KeyCharacterMap +import android.view.KeyEvent as AndroidKeyEvent +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class KeyValueTest { + + @Test + fun keyThatTypesACharacterIsThatCharacter() { + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_E, 'é'.code)).isEqualTo("é") + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_SPACE, ' '.code)).isEqualTo(" ") + } + + @Test + fun keyThatTypesAControlCharacterIsNamed() { + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_ENTER, '\n'.code)).isEqualTo("Enter") + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_TAB, '\t'.code)).isEqualTo("Tab") + } + + @Test + fun keyThatTypesNoCharacterIsNamed() { + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_MOVE_END, 0)).isEqualTo("End") + } + + @Test + fun deadKeyIsDead() { + assertThat( + KeyValue.of(AndroidKeyEvent.KEYCODE_GRAVE, KeyCharacterMap.COMBINING_ACCENT or '`'.code) + ) + .isEqualTo("Dead") + } + + @Test + fun keyWithNoNameThatTypesNoCharacterIsUnidentified() { + assertThat(KeyValue.of(AndroidKeyEvent.KEYCODE_BUTTON_A, 0)).isEqualTo("Unidentified") + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt new file mode 100644 index 000000000000..b0e624564a25 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput + +import android.app.Activity +import android.os.Looper +import android.util.DisplayMetrics +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.ReactTestHelper.createTestReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.events.RCTModernEventEmitter +import com.facebook.testutils.shadows.ShadowArguments +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowViewRootImpl + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class, ShadowSoLoader::class]) +class ReactEditTextKeyPressTest { + + private data class KeyPress( + val key: String?, + val heldModifiers: Set, + val textWhenReported: String, + ) + + private lateinit var activity: Activity + private lateinit var view: ReactEditText + private val keyPresses = mutableListOf() + private var nextDownTime = 1_000L + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val reactContext = createTestReactApplicationContext() + val eventDispatcher: EventDispatcher = mock { + on { dispatchEvent(any()) } doAnswer + { invocation -> + val event = invocation.getArgument>(0) + if (event is ReactTextInputKeyPressEvent) { + keyPresses.add(read(event)) + } + } + } + view = ReactEditText(ThemedReactContext(reactContext, activity, null, ID_NULL)) + view.setEventDispatcher(eventDispatcher) + view.setOnKeyPress(true) + activity.setContentView(view) + Shadow.extract(activity.window.decorView.parent) + .callWindowFocusChanged(true) + view.requestFocusFromJS() + shadowOf(Looper.getMainLooper()).idle() + assertThat(view.hasWindowFocus()).isTrue() + assertThat(view.isFocused).isTrue() + } + + @Suppress("DEPRECATION") + private fun read(event: ReactTextInputKeyPressEvent): KeyPress { + val emitter = mock() + val payload = argumentCaptor() + event.dispatchModern(emitter) + verify(emitter).receiveEvent(any(), any(), any(), any(), any(), payload.capture(), any()) + val data: ReadableMap = payload.firstValue + return KeyPress( + key = data.getString("key"), + heldModifiers = MODIFIER_FIELDS.filter { data.hasKey(it) && data.getBoolean(it) }.toSet(), + textWhenReported = view.text.toString(), + ) + } + + private fun keys(): List = keyPresses.map { it.key } + + private fun keyEvent( + action: Int, + keyCode: Int, + downTime: Long, + metaState: Int = 0, + repeatCount: Int = 0, + flags: Int = 0, + ): KeyEvent = + KeyEvent( + downTime, + downTime + repeatCount, + action, + keyCode, + repeatCount, + metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + flags, + InputDevice.SOURCE_KEYBOARD, + ) + + private fun pressHardwareKey(keyCode: Int, metaState: Int = 0, repeats: Int = 0) { + val downTime = nextDownTime++ + val window = activity.window.decorView + for (repeatCount in 0..repeats) { + window.dispatchKeyEvent( + keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, metaState, repeatCount) + ) + } + window.dispatchKeyEvent(keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, metaState)) + } + + // Robolectric's key character map has no dead keys and ignores Alt, so this event carries its + // own. + private class LayoutKeyEvent( + action: Int, + keyCode: Int, + downTime: Long, + metaState: Int, + private val layout: (metaState: Int) -> Int, + ) : + KeyEvent( + downTime, + downTime, + action, + keyCode, + 0, + metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + 0, + InputDevice.SOURCE_KEYBOARD, + ) { + override fun getUnicodeChar(metaState: Int): Int = layout(metaState) + } + + private fun pressHardwareKeyOnLayout(keyCode: Int, metaState: Int, layout: (Int) -> Int) { + val downTime = nextDownTime++ + val window = activity.window.decorView + window.dispatchKeyEvent( + LayoutKeyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, metaState, layout) + ) + window.dispatchKeyEvent( + LayoutKeyEvent(KeyEvent.ACTION_UP, keyCode, downTime, metaState, layout) + ) + } + + private fun openInputConnection(): InputConnection = + checkNotNull(view.onCreateInputConnection(EditorInfo())) + + private fun sendFromInputMethod(inputConnection: InputConnection, keyCode: Int, flags: Int) { + val downTime = nextDownTime++ + inputConnection.sendKeyEvent(keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, flags = flags)) + inputConnection.sendKeyEvent(keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, flags = flags)) + } + + private fun deliverPendingInput() { + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun hardwareKeysAreReportedByTheirKeyValue() { + pressHardwareKey(KeyEvent.KEYCODE_A) + pressHardwareKey(KeyEvent.KEYCODE_7) + pressHardwareKey(KeyEvent.KEYCODE_SPACE) + pressHardwareKey(KeyEvent.KEYCODE_DPAD_LEFT) + pressHardwareKey(KeyEvent.KEYCODE_MOVE_HOME) + pressHardwareKey(KeyEvent.KEYCODE_F1) + pressHardwareKey(KeyEvent.KEYCODE_SHIFT_LEFT) + pressHardwareKey(KeyEvent.KEYCODE_BUTTON_A) + + assertThat(keys()) + .containsExactly("a", "7", " ", "ArrowLeft", "Home", "F1", "Shift", "Unidentified") + } + + @Test + fun hardwareKeyIsReportedWithTheModifiersHeldWithIt() { + pressHardwareKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) + pressHardwareKey(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + + assertThat(keyPresses.map { it.key to it.heldModifiers }) + .containsExactly("A" to setOf("shiftKey"), "z" to setOf("ctrlKey")) + } + + @Test + fun characterTypedAfterADeadKeyIsReportedComposed() { + val acuteOnAltE = { metaState: Int -> + if ((metaState and KeyEvent.META_ALT_ON) != 0) KeyCharacterMap.COMBINING_ACCENT or '´'.code + else 'e'.code + } + + pressHardwareKeyOnLayout(KeyEvent.KEYCODE_E, ALT_HELD, acuteOnAltE) + pressHardwareKeyOnLayout(KeyEvent.KEYCODE_E, 0, acuteOnAltE) + + assertThat(keyPresses.map { it.key to it.heldModifiers }) + .containsExactly("Dead" to setOf("altKey"), "é" to emptySet()) + assertThat(view.text.toString()).isEqualTo("é") + } + + @Test + fun altShortcutOnALayoutWhereAltTypesNothingIsReportedByItsKeysCharacter() { + val nothingOnAlt = { metaState: Int -> + if ((metaState and KeyEvent.META_ALT_ON) != 0) 0 else 'e'.code + } + + pressHardwareKeyOnLayout(KeyEvent.KEYCODE_E, ALT_HELD, nothingOnAlt) + + assertThat(keyPresses.map { it.key to it.heldModifiers }) + .containsExactly("e" to setOf("altKey")) + assertThat(view.text.toString()).isEmpty() + } + + @Test + fun hardwareEnterAndBackspaceUseTheNamesTheInputMethodReports() { + view.setText("ab") + view.setSelection(2) + + pressHardwareKey(KeyEvent.KEYCODE_DEL) + pressHardwareKey(KeyEvent.KEYCODE_ENTER) + + assertThat(keys()).containsExactly("Backspace", "Enter") + } + + @Test + fun systemKeysAreNotReported() { + pressHardwareKey(KeyEvent.KEYCODE_VOLUME_UP) + pressHardwareKey(KeyEvent.KEYCODE_VOLUME_DOWN) + pressHardwareKey(KeyEvent.KEYCODE_BACK) + + assertThat(keyPresses).isEmpty() + } + + @Test + fun keyIsReportedBeforeTheTextItTypesChanges() { + pressHardwareKey(KeyEvent.KEYCODE_A) + + assertThat(keyPresses.map { it.textWhenReported }).containsExactly("") + assertThat(view.text.toString()).isEqualTo("a") + } + + @Test + fun heldKeyIsReportedAtEachRepeat() { + view.setText("abc") + view.setSelection(3) + + pressHardwareKey(KeyEvent.KEYCODE_DEL, repeats = 2) + + assertThat(keys()).containsExactly("Backspace", "Backspace", "Backspace") + assertThat(view.text.toString()).isEmpty() + } + + @Test + fun characterTheInputMethodSendsAsAKeyIsReportedOnce() { + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_A, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() + + assertThat(keys()).containsExactly("a") + assertThat(view.text.toString()).isEqualTo("a") + } + + @Test + fun digitTheNumericKeypadSendsIsReportedOnce() { + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_7, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() + + assertThat(keys()).containsExactly("7") + } + + @Test + fun backspaceTheInputMethodSendsOnAnEmptyFieldIsReportedOnce() { + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_DEL, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() + + assertThat(keys()).containsExactly("Backspace") + } + + @Test + fun hardwareKeyTheInputMethodForwardsIsReportedOnce() { + sendFromInputMethod( + openInputConnection(), + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.FLAG_FROM_SYSTEM, + ) + deliverPendingInput() + + assertThat(keys()).containsExactly("ArrowLeft") + } + + @Test + fun keysTheInputMethodSendsTogetherAreReportedInOrder() { + val inputConnection = openInputConnection() + sendFromInputMethod(inputConnection, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.FLAG_FROM_SYSTEM) + sendFromInputMethod(inputConnection, KeyEvent.KEYCODE_DEL, KeyEvent.FLAG_FROM_SYSTEM) + deliverPendingInput() + + assertThat(keys()).containsExactly("ArrowLeft", "Backspace") + } + + @Test + fun textTheInputMethodCommitsIsReportedOnce() { + openInputConnection().commitText("a", 1) + deliverPendingInput() + + assertThat(keys()).containsExactly("a") + } + + @Test + fun noKeyIsReportedWithoutAnOnKeyPressListener() { + view.setOnKeyPress(false) + + pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_A, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() + + assertThat(keyPresses).isEmpty() + } + + private companion object { + val MODIFIER_FIELDS = listOf("altKey", "ctrlKey", "metaKey", "shiftKey") + const val SOFT_KEYBOARD_FLAGS = KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE + const val ALT_HELD = KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index b352f656de23..2755e9a39f24 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<57f47f589dc7eb46102f17c4570f55c3>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5210,8 +5210,12 @@ declare type TextInputKeyPressEvent = NativeSyntheticEvent declare type TextInputKeyPressEventData = Readonly< Omit & { + altKey?: boolean + ctrlKey?: boolean eventCount: number key: string + metaKey?: boolean + shiftKey?: boolean target?: number } > @@ -6107,7 +6111,7 @@ export { TaskProvider, // 266dedf2 Text, // 841fd00c TextContentType, // 239b3ecc - TextInput, // f4502971 + TextInput, // fb1d5ca8 TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // c04c4bf0 TextInputChangeEvent, // ed7f8b04 @@ -6116,8 +6120,8 @@ export { TextInputFocusEvent, // eec98df4 TextInputIOSProps, // fb3c9327 TextInputInstance, // d442c53f - TextInputKeyPressEvent, // 07548cd0 - TextInputProps, // 0166e0c9 + TextInputKeyPressEvent, // 581b7e88 + TextInputProps, // 364be18e TextInputSelectionChangeEvent, // 131f3be5 TextInputSubmitEditingEvent, // f2ad5e3b TextInstance, // 5d4340ff diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts index b3ed14124246..3dbce76668c7 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts @@ -471,6 +471,10 @@ export type TextInputSelectionChangeEvent = export interface TextInputKeyPressEventData { key: string; eventCount: number; + altKey?: boolean | undefined; + ctrlKey?: boolean | undefined; + metaKey?: boolean | undefined; + shiftKey?: boolean | undefined; } /** @@ -874,7 +878,7 @@ export interface TextInputProps * where keyValue is 'Enter' or 'Backspace' for respective keys and the typed-in character otherwise including ' ' for space. * * Fires before onChange callbacks. - * Note: on Android only the inputs from soft keyboard are handled, not the hardware keyboard inputs. + * On Android, keys pressed on a hardware keyboard are reported as well, each by its KeyboardEvent.key value (such as 'ArrowLeft', 'Shift' or 'F1'), with altKey, ctrlKey, metaKey and shiftKey set for the modifier keys held. */ onKeyPress?: ((e: TextInputKeyPressEvent) => void) | undefined;