From f2a22e3fac2003fa0a796724a165b7a827d08758 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 20:38:20 +0300 Subject: [PATCH 1/5] Report hardware keyboard keys in TextInput onKeyPress on Android onKeyPress on Android is driven by ReactEditTextInputConnectionWrapper, which sees only what the input method sends through the InputConnection. A hardware keyboard's keys never pass through it: they reach the EditText through dispatchKeyEvent and the KeyListener, so an arrow, Home/End, a separator typed on a paired keyboard, or any other hardware key produced no onKeyPress at all. ReactEditText.onKeyDown now reports those keys: a key that types a character as that character, and a key that types none by its web KeyboardEvent.key value (ArrowLeft, Home, PageDown, Escape, ...). Enter and Backspace keep the names the soft keyboard path reports, a modifier on its own or a Ctrl/Meta chord is not reported, and a key event the input method sent through the InputConnection is left to the wrapper, which already reports it, so no key is reported twice. --- .../AndroidTextInputNativeComponent.js | 3 + .../Components/TextInput/TextInput.flow.js | 3 + .../react/views/textinput/HardwareKeyPress.kt | 57 ++++++ .../react/views/textinput/ReactEditText.kt | 28 +++ .../ReactEditTextInputConnectionWrapper.kt | 5 +- .../ReactEditTextHardwareKeyPressTest.kt | 184 ++++++++++++++++++ .../Components/TextInput/TextInput.d.ts | 2 +- 7 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index fd8fd9919d6d..a68c00c0359c 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -437,6 +437,9 @@ 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, and a + * key that types no character by its `KeyboardEvent.key` value, such as + * `'ArrowLeft'` or `'Home'`. * Fires before `onChange` callbacks. */ onKeyPress?: ?BubblingEventHandler>, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 91bc7901343d..2d22c9d4c5e9 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -850,6 +850,9 @@ 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, and a + * key that types no character by its `KeyboardEvent.key` value, such as + * `'ArrowLeft'` or `'Home'`. * Fires before `onChange` callbacks. */ onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt new file mode 100644 index 000000000000..b6235fd94050 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt @@ -0,0 +1,57 @@ +/* + * 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.view.KeyEvent + +/** + * Names a hardware key press the way `onKeyPress` names the keys a soft keyboard types: the key's + * `KeyboardEvent.key` value on the web for a key that types no character, and the character + * otherwise. + */ +internal object HardwareKeyPress { + + private val namedKeys: Map = + mapOf( + KeyEvent.KEYCODE_DEL to ReactEditTextInputConnectionWrapper.BACKSPACE_KEY_VALUE, + KeyEvent.KEYCODE_ENTER to ReactEditTextInputConnectionWrapper.ENTER_KEY_VALUE, + KeyEvent.KEYCODE_NUMPAD_ENTER to ReactEditTextInputConnectionWrapper.ENTER_KEY_VALUE, + KeyEvent.KEYCODE_FORWARD_DEL to "Delete", + KeyEvent.KEYCODE_TAB to "Tab", + KeyEvent.KEYCODE_ESCAPE to "Escape", + KeyEvent.KEYCODE_DPAD_UP to "ArrowUp", + KeyEvent.KEYCODE_DPAD_DOWN to "ArrowDown", + KeyEvent.KEYCODE_DPAD_LEFT to "ArrowLeft", + KeyEvent.KEYCODE_DPAD_RIGHT to "ArrowRight", + KeyEvent.KEYCODE_MOVE_HOME to "Home", + KeyEvent.KEYCODE_MOVE_END to "End", + KeyEvent.KEYCODE_PAGE_UP to "PageUp", + KeyEvent.KEYCODE_PAGE_DOWN to "PageDown", + KeyEvent.KEYCODE_INSERT to "Insert", + ) + + /** + * The key to report for [event], or `null` for a key with no name that types no character — a + * modifier on its own, or a chord such as Ctrl+Z. + */ + fun keyName(event: KeyEvent): String? { + namedKeys[event.keyCode]?.let { + return it + } + if (event.isCtrlPressed || event.isMetaPressed) { + return null + } + // A dead key sets KeyCharacterMap.COMBINING_ACCENT, the sign bit, so it never reads positive. + val character = event.unicodeChar + return if (character > 0 && !Character.isISOControl(character)) { + String(Character.toChars(character)) + } else { + null + } + } +} 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 754f10026624..dce1a2c5abbf 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 @@ -133,6 +133,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat private var keyListener: InternalKeyListener? = null private var detectScrollMovement = false private var onKeyPress = false + private var lastKeyDownFromInputConnection: KeyEvent? = null private val textAttributes: TextAttributes private var typefaceDirty = false private var fontFamily: String? = null @@ -339,6 +340,33 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return super.onTouchEvent(ev) } + // A key the input method sends through the input connection reaches this view too, after + // ReactEditTextInputConnectionWrapper has reported it, so only the keys it never saw are + // reported here: the ones a hardware keyboard sends. + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (onKeyPress && !isKeyDownFromInputConnection(event)) { + HardwareKeyPress.keyName(event)?.let { key -> + eventDispatcher?.dispatchEvent( + ReactTextInputKeyPressEvent(UIManagerHelper.getSurfaceId(this), id, key) + ) + } + } + return super.onKeyDown(keyCode, event) + } + + internal fun noteKeyEventFromInputConnection(event: KeyEvent) { + if (event.action == KeyEvent.ACTION_DOWN) { + lastKeyDownFromInputConnection = event + } + } + + // The view receives a copy of the event the input method sent, so it is matched by the key and + // the time it went down. + private fun isKeyDownFromInputConnection(event: KeyEvent): Boolean { + val sent = lastKeyDownFromInputConnection ?: return false + return sent.keyCode == event.keyCode && sent.downTime == event.downTime + } + // 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 { 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..09c163b6dbd6 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 @@ -42,8 +42,8 @@ import com.facebook.react.uimanager.events.EventDispatcher *

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. + *

N.B. this class is only applicable for soft keyboards behavior. A hardware keyboard's keys + * never pass through the [InputConnection]; [ReactEditText.onKeyDown] reports them. */ internal class ReactEditTextInputConnectionWrapper( target: InputConnection, @@ -116,6 +116,7 @@ internal class ReactEditTextInputConnectionWrapper( // [InputConnection.deleteSurroundingText] & [InputConnection.commitText] // in each case, respectively. override fun sendKeyEvent(event: KeyEvent): Boolean { + editText.noteKeyEventFromInputConnection(event) if (event.action == KeyEvent.ACTION_DOWN) { val isNumberKey = event.unicodeChar in 48..57 when (event.keyCode) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt new file mode 100644 index 000000000000..657fb248ef76 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt @@ -0,0 +1,184 @@ +/* + * 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. + */ + +// TODO T207169925: Migrate CatalystInstance to Reacthost and remove the Suppress("DEPRECATION") +// annotation +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.textinput + +import android.util.DisplayMetrics +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +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 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.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class]) +class ReactEditTextHardwareKeyPressTest { + + private lateinit var view: ReactEditText + private val reportedKeys = mutableListOf() + private var nextDownTime = 1_000L + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + val themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + val eventDispatcher: EventDispatcher = mock { + on { dispatchEvent(any()) } doAnswer + { invocation -> + val event = invocation.getArgument>(0) + if (event is ReactTextInputKeyPressEvent) { + reportedKeys.add(readKey(event)) + } + } + } + view = ReactEditText(themedContext) + view.setEventDispatcher(eventDispatcher) + view.setOnKeyPress(true) + } + + private fun readKey(event: ReactTextInputKeyPressEvent): String? { + 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") + } + + private fun hardwareKeyDown(keyCode: Int, metaState: Int = 0): KeyEvent { + val downTime = nextDownTime++ + return KeyEvent( + downTime, + downTime, + KeyEvent.ACTION_DOWN, + keyCode, + 0, + metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + ) + } + + private fun pressHardwareKey(keyCode: Int, metaState: Int = 0) { + view.dispatchKeyEvent(hardwareKeyDown(keyCode, metaState)) + } + + @Test + fun hardwareNavigationKeysAreReportedByTheirWebKeyNames() { + pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) + pressHardwareKey(KeyEvent.KEYCODE_DPAD_DOWN) + pressHardwareKey(KeyEvent.KEYCODE_DPAD_LEFT) + pressHardwareKey(KeyEvent.KEYCODE_DPAD_RIGHT) + pressHardwareKey(KeyEvent.KEYCODE_MOVE_HOME) + pressHardwareKey(KeyEvent.KEYCODE_MOVE_END) + pressHardwareKey(KeyEvent.KEYCODE_PAGE_UP) + pressHardwareKey(KeyEvent.KEYCODE_PAGE_DOWN) + pressHardwareKey(KeyEvent.KEYCODE_TAB) + pressHardwareKey(KeyEvent.KEYCODE_ESCAPE) + pressHardwareKey(KeyEvent.KEYCODE_FORWARD_DEL) + + assertThat(reportedKeys) + .containsExactly( + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", + "Tab", + "Escape", + "Delete", + ) + } + + @Test + fun hardwareEnterAndBackspaceUseTheNamesTheSoftKeyboardReports() { + pressHardwareKey(KeyEvent.KEYCODE_ENTER) + pressHardwareKey(KeyEvent.KEYCODE_DEL) + + assertThat(reportedKeys).containsExactly("Enter", "Backspace") + } + + @Test + fun hardwareCharacterKeyIsReportedAsTheCharacterItTypes() { + pressHardwareKey(KeyEvent.KEYCODE_A) + pressHardwareKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) + pressHardwareKey(KeyEvent.KEYCODE_7) + pressHardwareKey(KeyEvent.KEYCODE_SPACE) + + assertThat(reportedKeys).containsExactly("a", "A", "7", " ") + } + + @Test + fun keyThatTypesNoCharacterAndHasNoNameIsNotReported() { + pressHardwareKey(KeyEvent.KEYCODE_SHIFT_LEFT) + pressHardwareKey(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + + assertThat(reportedKeys).isEmpty() + } + + @Test + fun keyEventTheInputMethodSendsIsReportedOnceByTheInputConnection() { + val inputConnection = checkNotNull(view.onCreateInputConnection(EditorInfo())) + val softBackspace = + KeyEvent( + nextDownTime, + nextDownTime, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_DEL, + 0, + 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, + ) + + inputConnection.sendKeyEvent(softBackspace) + view.dispatchKeyEvent(KeyEvent(softBackspace)) + + assertThat(reportedKeys).containsExactly("Backspace") + } + + @Test + fun noKeyIsReportedWithoutAnOnKeyPressListener() { + view.setOnKeyPress(false) + + pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) + pressHardwareKey(KeyEvent.KEYCODE_A) + + assertThat(reportedKeys).isEmpty() + } +} 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..9ad57620ef38 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 @@ -874,7 +874,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, and a key that types no character by its KeyboardEvent.key value, such as 'ArrowLeft' or 'Home'. */ onKeyPress?: ((e: TextInputKeyPressEvent) => void) | undefined; From 764e46e0a3a7c378c85b22d78eb32f66ebd63f8f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 21:30:06 +0300 Subject: [PATCH 2/5] Report TextInput key events from onKeyDown on Android An input method that handles a hardware keyboard forwards its keys through InputConnection.sendKeyEvent: Gboard commits the letters as text and sends arrows, Home, End and Backspace as key events. So every key event is reported where it reaches the view, ReactEditText.onKeyDown, whether a hardware keyboard or an input method sent it, and ReactEditTextInputConnectionWrapper reports only the keys an input method types as text. No key is reported twice, and keys an input method sends together are reported in the order the view receives them. --- .../{HardwareKeyPress.kt => KeyPressKey.kt} | 9 +- .../react/views/textinput/ReactEditText.kt | 23 +--- .../ReactEditTextInputConnectionWrapper.kt | 30 +---- ...ssTest.kt => ReactEditTextKeyPressTest.kt} | 106 +++++++++++++----- 4 files changed, 90 insertions(+), 78 deletions(-) rename packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/{HardwareKeyPress.kt => KeyPressKey.kt} (86%) rename packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/{ReactEditTextHardwareKeyPressTest.kt => ReactEditTextKeyPressTest.kt} (66%) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.kt similarity index 86% rename from packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt rename to packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.kt index b6235fd94050..5e90151dde5c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/HardwareKeyPress.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.kt @@ -10,11 +10,10 @@ package com.facebook.react.views.textinput import android.view.KeyEvent /** - * Names a hardware key press the way `onKeyPress` names the keys a soft keyboard types: the key's - * `KeyboardEvent.key` value on the web for a key that types no character, and the character - * otherwise. + * Names a key event the way `onKeyPress` names a key: by the key's `KeyboardEvent.key` value on the + * web for a key that types no character, and by the character otherwise. */ -internal object HardwareKeyPress { +internal object KeyPressKey { private val namedKeys: Map = mapOf( @@ -39,7 +38,7 @@ internal object HardwareKeyPress { * The key to report for [event], or `null` for a key with no name that types no character — a * modifier on its own, or a chord such as Ctrl+Z. */ - fun keyName(event: KeyEvent): String? { + fun from(event: KeyEvent): String? { namedKeys[event.keyCode]?.let { return it } 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 dce1a2c5abbf..f0d23dca071a 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 @@ -133,7 +133,6 @@ public open class ReactEditText public constructor(context: Context) : AppCompat private var keyListener: InternalKeyListener? = null private var detectScrollMovement = false private var onKeyPress = false - private var lastKeyDownFromInputConnection: KeyEvent? = null private val textAttributes: TextAttributes private var typefaceDirty = false private var fontFamily: String? = null @@ -340,12 +339,11 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return super.onTouchEvent(ev) } - // A key the input method sends through the input connection reaches this view too, after - // ReactEditTextInputConnectionWrapper has reported it, so only the keys it never saw are - // reported here: the ones a hardware keyboard sends. + // Every key event reaches the view here: a hardware keyboard's, and one an input method sends + // through the input connection, including the hardware keys an input method forwards. override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (onKeyPress && !isKeyDownFromInputConnection(event)) { - HardwareKeyPress.keyName(event)?.let { key -> + if (onKeyPress) { + KeyPressKey.from(event)?.let { key -> eventDispatcher?.dispatchEvent( ReactTextInputKeyPressEvent(UIManagerHelper.getSurfaceId(this), id, key) ) @@ -354,19 +352,6 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return super.onKeyDown(keyCode, event) } - internal fun noteKeyEventFromInputConnection(event: KeyEvent) { - if (event.action == KeyEvent.ACTION_DOWN) { - lastKeyDownFromInputConnection = event - } - } - - // The view receives a copy of the event the input method sent, so it is matched by the key and - // the time it went down. - private fun isKeyDownFromInputConnection(event: KeyEvent): Boolean { - val sent = lastKeyDownFromInputConnection ?: return false - return sent.keyCode == event.keyCode && sent.downTime == event.downTime - } - // 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 { 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 09c163b6dbd6..976db7d3b00b 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 @@ -39,11 +38,10 @@ import com.facebook.react.uimanager.events.EventDispatcher * 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. A hardware keyboard's keys - * never pass through the [InputConnection]; [ReactEditText.onKeyDown] reports them. + *

N.B. this class reports only the keys an input method types as text. A key event reaches + * [ReactEditText.onKeyDown], which reports it, whether a hardware keyboard sends it or an input + * method does through [InputConnection.sendKeyEvent], as one does for a backspace with nothing to + * delete or for a hardware keyboard's key it forwards. */ internal class ReactEditTextInputConnectionWrapper( target: InputConnection, @@ -111,26 +109,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 { - editText.noteKeyEventFromInputConnection(event) - 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/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt similarity index 66% rename from packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt rename to packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt index 657fb248ef76..ced7d3760320 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextHardwareKeyPressTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextKeyPressTest.kt @@ -12,9 +12,11 @@ package com.facebook.react.views.textinput 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.BridgeReactContext import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance @@ -41,7 +43,7 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(shadows = [ShadowArguments::class]) -class ReactEditTextHardwareKeyPressTest { +class ReactEditTextKeyPressTest { private lateinit var view: ReactEditText private val reportedKeys = mutableListOf() @@ -76,24 +78,53 @@ class ReactEditTextHardwareKeyPressTest { return payload.firstValue.getString("key") } - private fun hardwareKeyDown(keyCode: Int, metaState: Int = 0): KeyEvent { + private fun keyEvent( + action: Int, + keyCode: Int, + downTime: Long, + metaState: Int = 0, + flags: Int = 0, + ): KeyEvent = + KeyEvent( + downTime, + downTime, + action, + keyCode, + 0, + metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + flags, + InputDevice.SOURCE_KEYBOARD, + ) + + private fun pressHardwareKey(keyCode: Int, metaState: Int = 0) { val downTime = nextDownTime++ - return KeyEvent( - downTime, - downTime, - KeyEvent.ACTION_DOWN, - keyCode, - 0, - metaState, - KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + view.dispatchKeyEvent(keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, metaState)) + view.dispatchKeyEvent(keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, metaState)) + } + + private fun keyPressFromInputMethod(keyCode: Int, flags: Int): List { + val downTime = nextDownTime++ + return listOf( + keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, flags = flags), + keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, flags = flags), ) } - private fun pressHardwareKey(keyCode: Int, metaState: Int = 0) { - view.dispatchKeyEvent(hardwareKeyDown(keyCode, metaState)) + // The input connection posts each event to the view's window, which hands the view a copy + // without FLAG_FROM_SYSTEM once the input method's call has returned. A view outside a window + // receives nothing, so the test hands it those copies. + private fun sendFromInputMethod(inputConnection: InputConnection, events: List) { + events.forEach { inputConnection.sendKeyEvent(it) } + events.forEach { + view.dispatchKeyEvent(KeyEvent.changeFlags(it, it.flags and KeyEvent.FLAG_FROM_SYSTEM.inv())) + } } + private fun openInputConnection(): InputConnection = + checkNotNull(view.onCreateInputConnection(EditorInfo())) + @Test fun hardwareNavigationKeysAreReportedByTheirWebKeyNames() { pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) @@ -151,27 +182,46 @@ class ReactEditTextHardwareKeyPressTest { } @Test - fun keyEventTheInputMethodSendsIsReportedOnceByTheInputConnection() { - val inputConnection = checkNotNull(view.onCreateInputConnection(EditorInfo())) - val softBackspace = - KeyEvent( - nextDownTime, - nextDownTime, - KeyEvent.ACTION_DOWN, + fun backspaceTheInputMethodSendsIsReportedOnce() { + sendFromInputMethod( + openInputConnection(), + keyPressFromInputMethod( KeyEvent.KEYCODE_DEL, - 0, - 0, - KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, - ) - - inputConnection.sendKeyEvent(softBackspace) - view.dispatchKeyEvent(KeyEvent(softBackspace)) + ), + ) assertThat(reportedKeys).containsExactly("Backspace") } + @Test + fun hardwareKeyTheInputMethodForwardsIsReported() { + sendFromInputMethod( + openInputConnection(), + keyPressFromInputMethod(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.FLAG_FROM_SYSTEM), + ) + + assertThat(reportedKeys).containsExactly("ArrowLeft") + } + + @Test + fun keysTheInputMethodSendsTogetherAreReportedInTheOrderTheViewReceivesThem() { + sendFromInputMethod( + openInputConnection(), + keyPressFromInputMethod(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.FLAG_FROM_SYSTEM) + + keyPressFromInputMethod(KeyEvent.KEYCODE_DEL, KeyEvent.FLAG_FROM_SYSTEM), + ) + + assertThat(reportedKeys).containsExactly("ArrowLeft", "Backspace") + } + + @Test + fun textTheInputMethodCommitsIsReportedByTheInputConnection() { + openInputConnection().commitText("a", 1) + + assertThat(reportedKeys).containsExactly("a") + } + @Test fun noKeyIsReportedWithoutAnOnKeyPressListener() { view.setOnKeyPress(false) From d0b95a1f9bedc58af19aa2279c55a2b3475ad272 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 21:31:52 +0300 Subject: [PATCH 3/5] Say how onKeyPress names a key that types no character --- .../TextInput/AndroidTextInputNativeComponent.js | 4 ++-- .../Libraries/Components/TextInput/TextInput.flow.js | 4 ++-- .../com/facebook/react/views/textinput/ReactEditText.kt | 4 ++-- .../textinput/ReactEditTextInputConnectionWrapper.kt | 8 ++++---- .../Libraries/Components/TextInput/TextInput.d.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index a68c00c0359c..14e77566f1c8 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -438,8 +438,8 @@ export type AndroidTextInputNativeProps = Readonly<{ * 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, and a - * key that types no character by its `KeyboardEvent.key` value, such as - * `'ArrowLeft'` or `'Home'`. + * key that types no character is reported by its `KeyboardEvent.key` value, + * such as `'ArrowLeft'` or `'Home'`. * Fires before `onChange` callbacks. */ onKeyPress?: ?BubblingEventHandler>, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 2d22c9d4c5e9..02820e32ec9a 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -851,8 +851,8 @@ type TextInputBaseProps = Readonly<{ * 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, and a - * key that types no character by its `KeyboardEvent.key` value, such as - * `'ArrowLeft'` or `'Home'`. + * key that types no character is reported by its `KeyboardEvent.key` value, + * such as `'ArrowLeft'` or `'Home'`. * Fires before `onChange` callbacks. */ onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown, 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 f0d23dca071a..6c72c0affe86 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 @@ -339,8 +339,8 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return super.onTouchEvent(ev) } - // Every key event reaches the view here: a hardware keyboard's, and one an input method sends - // through the input connection, including the hardware keys an input method forwards. + // Every key event reaches the view here, whether a hardware keyboard sent it or an input method + // did through the input connection, as an input method does with the hardware keys it handles. override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (onKeyPress) { KeyPressKey.from(event)?.let { key -> 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 976db7d3b00b..48001c231461 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 @@ -38,10 +38,10 @@ import com.facebook.react.uimanager.events.EventDispatcher * 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. * - *

N.B. this class reports only the keys an input method types as text. A key event reaches - * [ReactEditText.onKeyDown], which reports it, whether a hardware keyboard sends it or an input - * method does through [InputConnection.sendKeyEvent], as one does for a backspace with nothing to - * delete or for a hardware keyboard's key it forwards. + *

N.B. this class reports only the keys an input method types as text. A key event, whether a + * hardware keyboard sends it or an input method does through [InputConnection.sendKeyEvent] (a + * backspace with nothing to delete, or a hardware keyboard's key it handles), is reported by + * [ReactEditText.onKeyDown]. */ internal class ReactEditTextInputConnectionWrapper( target: InputConnection, 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 9ad57620ef38..b43c7b3cd5b1 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 @@ -874,7 +874,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. - * On Android, keys pressed on a hardware keyboard are reported as well, and a key that types no character by its KeyboardEvent.key value, such as 'ArrowLeft' or 'Home'. + * On Android, keys pressed on a hardware keyboard are reported as well, and a key that types no character is reported by its KeyboardEvent.key value, such as 'ArrowLeft' or 'Home'. */ onKeyPress?: ((e: TextInputKeyPressEvent) => void) | undefined; From fdb1a526526dd37150398afc2ff548ef57147084 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 23:45:31 +0300 Subject: [PATCH 4/5] Report every hardware keyboard key in Android TextInput onKeyPress onKeyPress names each key by its KeyboardEvent.key value, from the key table the View key events use, and carries altKey, ctrlKey, metaKey and shiftKey. 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. 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. The tests deliver an input method's key events through the window, as the framework delivers them to a focused view. --- .../AndroidTextInputNativeComponent.js | 18 +- .../Components/TextInput/TextInput.flow.js | 11 +- .../react/uimanager/events/KeyEvent.kt | 35 +-- .../react/uimanager/events/KeyValue.kt | 207 ++++++++++++++++ .../react/views/textinput/KeyPressKey.kt | 56 ----- .../react/views/textinput/ReactEditText.kt | 36 ++- .../ReactEditTextInputConnectionWrapper.kt | 5 - .../textinput/ReactTextInputKeyPressEvent.kt | 17 +- .../uimanager/events/KeyDownEventTest.kt | 50 ++++ .../react/uimanager/events/KeyValueTest.kt | 49 ++++ .../textinput/ReactEditTextKeyPressTest.kt | 234 +++++++++++------- packages/react-native/ReactNativeApi.d.ts | 12 +- .../Components/TextInput/TextInput.d.ts | 6 +- 13 files changed, 527 insertions(+), 209 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.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 diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index 14e77566f1c8..eb542d05423a 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -437,12 +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, and a - * key that types no character is reported by its `KeyboardEvent.key` value, - * such as `'ArrowLeft'` or `'Home'`. + * 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 02820e32ec9a..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,9 +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, and a - * key that types no character is reported by its `KeyboardEvent.key` value, - * such as `'ArrowLeft'` or `'Home'`. + * 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..618faef1920c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyValue.kt @@ -0,0 +1,207 @@ +/* + * 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, because the key value of a shortcut + * such as Ctrl+Z is the character of its key. + */ + fun typedCharacter(event: AndroidKeyEvent): Int = + event.getUnicodeChar(event.metaState and SHORTCUT_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 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/KeyPressKey.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.kt deleted file mode 100644 index 5e90151dde5c..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/KeyPressKey.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.view.KeyEvent - -/** - * Names a key event the way `onKeyPress` names a key: by the key's `KeyboardEvent.key` value on the - * web for a key that types no character, and by the character otherwise. - */ -internal object KeyPressKey { - - private val namedKeys: Map = - mapOf( - KeyEvent.KEYCODE_DEL to ReactEditTextInputConnectionWrapper.BACKSPACE_KEY_VALUE, - KeyEvent.KEYCODE_ENTER to ReactEditTextInputConnectionWrapper.ENTER_KEY_VALUE, - KeyEvent.KEYCODE_NUMPAD_ENTER to ReactEditTextInputConnectionWrapper.ENTER_KEY_VALUE, - KeyEvent.KEYCODE_FORWARD_DEL to "Delete", - KeyEvent.KEYCODE_TAB to "Tab", - KeyEvent.KEYCODE_ESCAPE to "Escape", - KeyEvent.KEYCODE_DPAD_UP to "ArrowUp", - KeyEvent.KEYCODE_DPAD_DOWN to "ArrowDown", - KeyEvent.KEYCODE_DPAD_LEFT to "ArrowLeft", - KeyEvent.KEYCODE_DPAD_RIGHT to "ArrowRight", - KeyEvent.KEYCODE_MOVE_HOME to "Home", - KeyEvent.KEYCODE_MOVE_END to "End", - KeyEvent.KEYCODE_PAGE_UP to "PageUp", - KeyEvent.KEYCODE_PAGE_DOWN to "PageDown", - KeyEvent.KEYCODE_INSERT to "Insert", - ) - - /** - * The key to report for [event], or `null` for a key with no name that types no character — a - * modifier on its own, or a chord such as Ctrl+Z. - */ - fun from(event: KeyEvent): String? { - namedKeys[event.keyCode]?.let { - return it - } - if (event.isCtrlPressed || event.isMetaPressed) { - return null - } - // A dead key sets KeyCharacterMap.COMBINING_ACCENT, the sign bit, so it never reads positive. - val character = event.unicodeChar - return if (character > 0 && !Character.isISOControl(character)) { - String(Character.toChars(character)) - } else { - null - } - } -} 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 6c72c0affe86..b65ba9d027bc 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 @@ -67,6 +67,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 @@ -133,6 +134,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat private var keyListener: InternalKeyListener? = null private var detectScrollMovement = false private var onKeyPress = false + private var isDeadKeyPending = false private val textAttributes: TextAttributes private var typefaceDirty = false private var fontFamily: String? = null @@ -339,17 +341,32 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return super.onTouchEvent(ev) } - // Every key event reaches the view here, whether a hardware keyboard sent it or an input method - // did through the input connection, as an input method does with the hardware keys it handles. override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (onKeyPress) { - KeyPressKey.from(event)?.let { key -> - eventDispatcher?.dispatchEvent( - ReactTextInputKeyPressEvent(UIManagerHelper.getSurfaceId(this), id, key) - ) - } + 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 } - return super.onKeyDown(keyCode, event) + 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 @@ -516,6 +533,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 48001c231461..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 @@ -37,11 +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. - * - *

N.B. this class reports only the keys an input method types as text. A key event, whether a - * hardware keyboard sends it or an input method does through [InputConnection.sendKeyEvent] (a - * backspace with nothing to delete, or a hardware keyboard's key it handles), is reported by - * [ReactEditText.onKeyDown]. */ internal class ReactEditTextInputConnectionWrapper( target: InputConnection, 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 356e2de89306..30a9bad29a12 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 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 index ced7d3760320..8f3854855289 100644 --- 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 @@ -11,6 +11,8 @@ 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 @@ -20,6 +22,7 @@ import android.view.inputmethod.InputConnection import androidx.core.content.res.ResourcesCompat.ID_NULL import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +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 @@ -37,60 +40,85 @@ 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.RuntimeEnvironment +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]) 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 reportedKeys = mutableListOf() + private val keyPresses = mutableListOf() private var nextDownTime = 1_000L @Before - fun setup() { + fun setUp() { ReactNativeFeatureFlagsForTests.setUp() DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) - val context = BridgeReactContext(RuntimeEnvironment.getApplication()) - context.initializeWithInstance(createMockCatalystInstance()) - val themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val reactContext = BridgeReactContext(activity) + reactContext.initializeWithInstance(createMockCatalystInstance()) val eventDispatcher: EventDispatcher = mock { on { dispatchEvent(any()) } doAnswer { invocation -> val event = invocation.getArgument>(0) if (event is ReactTextInputKeyPressEvent) { - reportedKeys.add(readKey(event)) + keyPresses.add(read(event)) } } } - view = ReactEditText(themedContext) + 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() } - private fun readKey(event: ReactTextInputKeyPressEvent): String? { + 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()) - return payload.firstValue.getString("key") + 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, + downTime + repeatCount, action, keyCode, - 0, + repeatCount, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, @@ -98,128 +126,146 @@ class ReactEditTextKeyPressTest { InputDevice.SOURCE_KEYBOARD, ) - private fun pressHardwareKey(keyCode: Int, metaState: Int = 0) { + private fun pressHardwareKey(keyCode: Int, metaState: Int = 0, repeats: Int = 0) { val downTime = nextDownTime++ - view.dispatchKeyEvent(keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, metaState)) - view.dispatchKeyEvent(keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, metaState)) + 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)) } - private fun keyPressFromInputMethod(keyCode: Int, flags: Int): List { + private fun openInputConnection(): InputConnection = + checkNotNull(view.onCreateInputConnection(EditorInfo())) + + private fun sendFromInputMethod(inputConnection: InputConnection, keyCode: Int, flags: Int) { val downTime = nextDownTime++ - return listOf( - keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, flags = flags), - keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, flags = flags), - ) + inputConnection.sendKeyEvent(keyEvent(KeyEvent.ACTION_DOWN, keyCode, downTime, flags = flags)) + inputConnection.sendKeyEvent(keyEvent(KeyEvent.ACTION_UP, keyCode, downTime, flags = flags)) } - // The input connection posts each event to the view's window, which hands the view a copy - // without FLAG_FROM_SYSTEM once the input method's call has returned. A view outside a window - // receives nothing, so the test hands it those copies. - private fun sendFromInputMethod(inputConnection: InputConnection, events: List) { - events.forEach { inputConnection.sendKeyEvent(it) } - events.forEach { - view.dispatchKeyEvent(KeyEvent.changeFlags(it, it.flags and KeyEvent.FLAG_FROM_SYSTEM.inv())) - } + private fun deliverPendingInput() { + shadowOf(Looper.getMainLooper()).idle() } - private fun openInputConnection(): InputConnection = - checkNotNull(view.onCreateInputConnection(EditorInfo())) - @Test - fun hardwareNavigationKeysAreReportedByTheirWebKeyNames() { - pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) - pressHardwareKey(KeyEvent.KEYCODE_DPAD_DOWN) + fun hardwareKeysAreReportedByTheirKeyValue() { + pressHardwareKey(KeyEvent.KEYCODE_A) + pressHardwareKey(KeyEvent.KEYCODE_7) + pressHardwareKey(KeyEvent.KEYCODE_SPACE) pressHardwareKey(KeyEvent.KEYCODE_DPAD_LEFT) - pressHardwareKey(KeyEvent.KEYCODE_DPAD_RIGHT) pressHardwareKey(KeyEvent.KEYCODE_MOVE_HOME) - pressHardwareKey(KeyEvent.KEYCODE_MOVE_END) - pressHardwareKey(KeyEvent.KEYCODE_PAGE_UP) - pressHardwareKey(KeyEvent.KEYCODE_PAGE_DOWN) - pressHardwareKey(KeyEvent.KEYCODE_TAB) - pressHardwareKey(KeyEvent.KEYCODE_ESCAPE) - pressHardwareKey(KeyEvent.KEYCODE_FORWARD_DEL) - - assertThat(reportedKeys) - .containsExactly( - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "Home", - "End", - "PageUp", - "PageDown", - "Tab", - "Escape", - "Delete", - ) + 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 hardwareEnterAndBackspaceUseTheNamesTheSoftKeyboardReports() { - pressHardwareKey(KeyEvent.KEYCODE_ENTER) + 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 hardwareEnterAndBackspaceUseTheNamesTheInputMethodReports() { + view.setText("ab") + view.setSelection(2) + pressHardwareKey(KeyEvent.KEYCODE_DEL) + pressHardwareKey(KeyEvent.KEYCODE_ENTER) - assertThat(reportedKeys).containsExactly("Enter", "Backspace") + assertThat(keys()).containsExactly("Backspace", "Enter") } @Test - fun hardwareCharacterKeyIsReportedAsTheCharacterItTypes() { + 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) - pressHardwareKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON) - pressHardwareKey(KeyEvent.KEYCODE_7) - pressHardwareKey(KeyEvent.KEYCODE_SPACE) - assertThat(reportedKeys).containsExactly("a", "A", "7", " ") + assertThat(keyPresses.map { it.textWhenReported }).containsExactly("") + assertThat(view.text.toString()).isEqualTo("a") } @Test - fun keyThatTypesNoCharacterAndHasNoNameIsNotReported() { - pressHardwareKey(KeyEvent.KEYCODE_SHIFT_LEFT) - pressHardwareKey(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + fun heldKeyIsReportedAtEachRepeat() { + view.setText("abc") + view.setSelection(3) + + pressHardwareKey(KeyEvent.KEYCODE_DEL, repeats = 2) - assertThat(reportedKeys).isEmpty() + assertThat(keys()).containsExactly("Backspace", "Backspace", "Backspace") + assertThat(view.text.toString()).isEmpty() } @Test - fun backspaceTheInputMethodSendsIsReportedOnce() { - sendFromInputMethod( - openInputConnection(), - keyPressFromInputMethod( - KeyEvent.KEYCODE_DEL, - KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, - ), - ) + fun characterTheInputMethodSendsAsAKeyIsReportedOnce() { + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_A, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() - assertThat(reportedKeys).containsExactly("Backspace") + assertThat(keys()).containsExactly("a") + assertThat(view.text.toString()).isEqualTo("a") } @Test - fun hardwareKeyTheInputMethodForwardsIsReported() { - sendFromInputMethod( - openInputConnection(), - keyPressFromInputMethod(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.FLAG_FROM_SYSTEM), - ) + 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(reportedKeys).containsExactly("ArrowLeft") + assertThat(keys()).containsExactly("Backspace") } @Test - fun keysTheInputMethodSendsTogetherAreReportedInTheOrderTheViewReceivesThem() { + fun hardwareKeyTheInputMethodForwardsIsReportedOnce() { sendFromInputMethod( openInputConnection(), - keyPressFromInputMethod(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.FLAG_FROM_SYSTEM) + - keyPressFromInputMethod(KeyEvent.KEYCODE_DEL, KeyEvent.FLAG_FROM_SYSTEM), + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.FLAG_FROM_SYSTEM, ) + deliverPendingInput() - assertThat(reportedKeys).containsExactly("ArrowLeft", "Backspace") + assertThat(keys()).containsExactly("ArrowLeft") } @Test - fun textTheInputMethodCommitsIsReportedByTheInputConnection() { + 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(reportedKeys).containsExactly("a") + assertThat(keys()).containsExactly("a") } @Test @@ -227,8 +273,14 @@ class ReactEditTextKeyPressTest { view.setOnKeyPress(false) pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) - pressHardwareKey(KeyEvent.KEYCODE_A) + sendFromInputMethod(openInputConnection(), KeyEvent.KEYCODE_A, SOFT_KEYBOARD_FLAGS) + deliverPendingInput() + + assertThat(keyPresses).isEmpty() + } - assertThat(reportedKeys).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 } } diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..d1d9e2ae5ae2 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<> + * @generated SignedSource<<5f564dbad822f48e5e5968aeea2bc82b>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5132,8 +5132,12 @@ declare type TextInputKeyPressEvent = NativeSyntheticEvent declare type TextInputKeyPressEventData = Readonly< Omit & { + altKey?: boolean + ctrlKey?: boolean eventCount: number key: string + metaKey?: boolean + shiftKey?: boolean target?: number } > @@ -6022,7 +6026,7 @@ export { TaskProvider, // 266dedf2 Text, // 6f8269c1 TextContentType, // 239b3ecc - TextInput, // cf6467dc + TextInput, // a2db6742 TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b4c7a58c TextInputChangeEvent, // 92363e10 @@ -6031,8 +6035,8 @@ export { TextInputFocusEvent, // ef604040 TextInputIOSProps, // fb3c9327 TextInputInstance, // 53fe7b73 - TextInputKeyPressEvent, // cbbc322a - TextInputProps, // bb087c92 + TextInputKeyPressEvent, // 849d7872 + TextInputProps, // d3f74596 TextInputSelectionChangeEvent, // 4a8f10c2 TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 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 b43c7b3cd5b1..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. - * On Android, keys pressed on a hardware keyboard are reported as well, and a key that types no character is reported by its KeyboardEvent.key value, such as 'ArrowLeft' or 'Home'. + * 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; From 8276603de0c154f152b0045a529f30c9c7b050b5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 00:12:40 +0300 Subject: [PATCH 5/5] Name an Alt shortcut by its key when Alt types nothing On a layout where Alt+E types no character, the key value is "e" with altKey set, as on the web, rather than Unidentified. The tests type on a layout of their own for this and for a dead key, which Robolectric's key character map cannot express. --- .../react/uimanager/events/KeyValue.kt | 16 +++-- .../textinput/ReactEditTextKeyPressTest.kt | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) 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 index 618faef1920c..636c39011d28 100644 --- 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 @@ -28,16 +28,24 @@ internal object KeyValue { } /** - * The character [event] types with Control and Meta released, because the key value of a shortcut - * such as Ctrl+Z is the character of its key. + * 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 = - event.getUnicodeChar(event.metaState and SHORTCUT_MODIFIERS.inv()) + 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" 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 index 8f3854855289..d321da671352 100644 --- 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 @@ -137,6 +137,41 @@ class ReactEditTextKeyPressTest { 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())) @@ -174,6 +209,34 @@ class ReactEditTextKeyPressTest { .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") @@ -282,5 +345,6 @@ class ReactEditTextKeyPressTest { 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 } }