fix(mobile): preserve native keyboard event ownership

This commit is contained in:
Neil
2026-09-05 14:42:43 -07:00
parent 09268054ca
commit a67e555296
12 changed files with 205 additions and 9 deletions
@@ -3,6 +3,11 @@ plugins {
id 'expo-module-gradle-plugin'
}
dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16'
}
group = 'expo.modules.hardwarekeyboardnavigation'
version = '0.0.1'
@@ -14,6 +14,7 @@ class HardwareKeyboardActivityLifecycleListener : ReactActivityLifecycleListener
}
override fun onDestroy(activity: Activity) {
HardwareKeyboardNavigationRegistry.clearCapturedKeys()
val callback = activity.window.callback
if (callback is HardwareKeyboardWindowCallback) {
activity.window.callback = callback.delegate
@@ -16,6 +16,7 @@ object HardwareKeyboardNavigationRegistry {
@Volatile
private var commands: List<HardwareKeyboardCommand> = emptyList()
private val observers = mutableSetOf<WeakReference<(HardwareKeyboardCommand) -> Unit>>()
private val capturedKeys = mutableSetOf<Pair<Int, Int>>()
fun setCommands(next: List<HardwareKeyboardCommand>) {
commands = next
@@ -34,8 +35,24 @@ object HardwareKeyboardNavigationRegistry {
observers.removeAll { it.get() == null }
}
fun dispatch(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) {
fun clearCapturedKeys() {
capturedKeys.clear()
}
fun dispatch(event: KeyEvent, canStartCapture: Boolean = true): Boolean {
val identity = event.deviceId to event.keyCode
if (event.action == KeyEvent.ACTION_UP) {
return capturedKeys.remove(identity)
}
if (event.action != KeyEvent.ACTION_DOWN) {
return false
}
if (event.repeatCount > 0) {
return identity in capturedKeys
}
// A fresh down supersedes an up lost during device/window changes.
capturedKeys.remove(identity)
if (!canStartCapture) {
return false
}
val key = keyToken(event.keyCode) ?: return false
@@ -52,6 +69,7 @@ object HardwareKeyboardNavigationRegistry {
if (currentObservers.isEmpty()) {
return false
}
capturedKeys.add(identity)
currentObservers.forEach { it(command) }
return true
}
@@ -12,6 +12,13 @@ class HardwareKeyboardWindowCallback(
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val text = (window.currentFocus as? TextView)?.editableText
val composing = text != null && BaseInputConnection.getComposingSpanStart(text) >= 0
return (!composing && HardwareKeyboardNavigationRegistry.dispatch(event)) || delegate.dispatchKeyEvent(event)
return HardwareKeyboardNavigationRegistry.dispatch(event, canStartCapture = !composing) || delegate.dispatchKeyEvent(event)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (!hasFocus) {
HardwareKeyboardNavigationRegistry.clearCapturedKeys()
}
delegate.onWindowFocusChanged(hasFocus)
}
}
@@ -0,0 +1,61 @@
package expo.modules.hardwarekeyboardnavigation
import android.view.KeyEvent
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class HardwareKeyboardNavigationOwnershipTest {
private var sends = 0
private val observer: (HardwareKeyboardCommand) -> Unit = { sends++ }
private val reference = HardwareKeyboardNavigationRegistry.addObserver(observer)
init {
HardwareKeyboardNavigationRegistry.setCommands(listOf(
HardwareKeyboardCommand("tab.previousRecent", "Tab", true, false, false, false)
))
}
private fun key(action: Int, repeat: Int = 0, device: Int = 1) = KeyEvent(
0, 0, action, KeyEvent.KEYCODE_TAB, repeat, KeyEvent.META_CTRL_ON, device, 0
)
@After fun cleanup() {
HardwareKeyboardNavigationRegistry.removeObserver(reference)
HardwareKeyboardNavigationRegistry.setCommands(emptyList())
HardwareKeyboardNavigationRegistry.clearCapturedKeys()
}
@Test fun ownedRepeatsAndReleaseNeverReachTerminalAfterNavigation() {
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN)))
HardwareKeyboardNavigationRegistry.setCommands(emptyList())
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN, 1)))
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_UP)))
assertFalse(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_UP)))
assertEquals(1, sends)
}
@Test fun compositionDoesNotStartCaptureButStillFinishesOwnedKeys() {
assertFalse(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN), false))
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN)))
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN, 1), false))
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_UP), false))
assertEquals(1, sends)
}
@Test fun deviceOwnershipAndWindowCleanupDoNotSwallowUnrelatedKeys() {
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN)))
assertFalse(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_UP, device = 2)))
HardwareKeyboardNavigationRegistry.clearCapturedKeys()
assertFalse(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_UP)))
assertTrue(HardwareKeyboardNavigationRegistry.dispatch(key(KeyEvent.ACTION_DOWN)))
assertEquals(2, sends)
}
}
@@ -20,14 +20,21 @@ private struct HardwareKeyboardCommandIdentity: Hashable {
}
@MainActor
private final class HardwareKeyboardCommandRegistry {
static let shared = HardwareKeyboardCommandRegistry()
public final class HardwareKeyboardCommandRegistry {
public static let shared = HardwareKeyboardCommandRegistry()
private weak var controller: UIViewController?
private var commandPayloads: [HardwareKeyboardCommandIdentity: [String: String]] = [:]
private var installedCommands: [UIKeyCommand] = []
var handler: (([String: String]) -> Void)?
public func owns(_ command: UIKeyCommand, in window: UIWindow?) -> Bool {
guard let window, controller?.viewIfLoaded?.window === window,
let identity = commandIdentity(input: command.input, modifierFlags: command.modifierFlags)
else { return false }
return commandPayloads[identity] != nil
}
func install(_ records: [HardwareKeyboardCommandRecord]) {
guard let controller = rootViewController() else { return }
if self.controller !== controller {
@@ -3,6 +3,11 @@ plugins {
id 'expo-module-gradle-plugin'
}
dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16'
}
group = 'expo.modules.hardwarekeyboard'
version = '0.0.1'
@@ -2,6 +2,7 @@ package expo.modules.hardwarekeyboard
import android.content.Context
import android.view.KeyEvent
import android.view.KeyCharacterMap
import android.view.inputmethod.BaseInputConnection
import android.widget.EditText
import expo.modules.kotlin.AppContext
@@ -48,6 +49,9 @@ class HardwareKeyboardCaptureView(context: Context, appContext: AppContext) :
val shift = (meta and KeyEvent.META_SHIFT_ON) != 0
val repeat = event.repeatCount > 0
if (captureMode == "submit") {
if (!isPhysicalKeyboardEvent(event)) {
return super.dispatchKeyEvent(event)
}
val enter = event.keyCode == KeyEvent.KEYCODE_ENTER || event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
if (!enter || ctrl || alt || shift) {
return super.dispatchKeyEvent(event)
@@ -161,3 +165,7 @@ class HardwareKeyboardCaptureView(context: Context, appContext: AppContext) :
key.startsWith("F")
}
}
internal fun isPhysicalKeyboardEvent(event: KeyEvent): Boolean =
event.flags and KeyEvent.FLAG_SOFT_KEYBOARD == 0 &&
event.deviceId != KeyCharacterMap.VIRTUAL_KEYBOARD && event.device?.isVirtual != true
@@ -0,0 +1,31 @@
package expo.modules.hardwarekeyboard
import android.view.KeyCharacterMap
import android.view.KeyEvent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class HardwareKeyboardSubmitEventTest {
private fun enter(deviceId: Int, flags: Int = 0) = KeyEvent(
0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, 0, 0, deviceId, 0, flags
)
@Test fun softwareReturnIsNotHardwareSubmit() {
assertFalse(isPhysicalKeyboardEvent(enter(1, KeyEvent.FLAG_SOFT_KEYBOARD)))
assertFalse(isPhysicalKeyboardEvent(enter(KeyCharacterMap.VIRTUAL_KEYBOARD)))
assertFalse(isPhysicalKeyboardEvent(enter(
KeyCharacterMap.VIRTUAL_KEYBOARD,
KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE
)))
}
@Test fun physicalReturnRemainsEligible() {
assertTrue(isPhysicalKeyboardEvent(enter(1)))
}
}
@@ -16,5 +16,6 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'ExpoHardwareKeyboardNavigation'
s.source_files = '**/*.{h,m,mm,swift}'
end
@@ -1,4 +1,5 @@
import ExpoModulesCore
import ExpoHardwareKeyboardNavigation
import UIKit
// Captured keys stay in the focused field's responder chain.
@@ -41,11 +42,17 @@ public class HardwareKeyboardCaptureView: ExpoView {
command.wantsPriorityOverSystemBehavior = true
return [command]
}
return Self.terminalKeyCommands
return Self.terminalKeyCommands.filter {
!HardwareKeyboardCommandRegistry.shared.owns($0, in: window)
}
}
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(handleKeyCommand(_:)) {
if captureMode == "terminal", let command = sender as? UIKeyCommand,
HardwareKeyboardCommandRegistry.shared.owns(command, in: window) {
return false
}
guard enabled, let input = focusedTextInput(in: self) else { return false }
return input.markedTextRange == nil
}
@@ -53,6 +60,9 @@ public class HardwareKeyboardCaptureView: ExpoView {
}
@objc func handleKeyCommand(_ sender: UIKeyCommand) {
if captureMode == "terminal", HardwareKeyboardCommandRegistry.shared.owns(sender, in: window) {
return
}
guard enabled, let textInput = focusedTextInput(in: self), textInput.markedTextRange == nil else {
return
}
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const nativeRuntime = vi.hoisted(() => ({
setCommands: vi.fn()
setCommands: vi.fn(),
policy: 'terminal-first' as 'terminal-first' | 'orca-first',
preferencesChanged: () => {}
}))
vi.mock('@orca/expo-hardware-keyboard-navigation', () => ({
@@ -14,10 +16,13 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('./mobile-hardware-keyboard-preferences', () => ({
getMobileHardwareKeyboardPreferences: () => ({
loaded: true,
terminalShortcutPolicy: 'terminal-first'
terminalShortcutPolicy: nativeRuntime.policy
}),
loadMobileHardwareKeyboardPreferences: () => Promise.resolve(),
subscribeMobileHardwareKeyboardPreferences: () => () => undefined
subscribeMobileHardwareKeyboardPreferences: (listener: () => void) => {
nativeRuntime.preferencesChanged = listener
return () => undefined
}
}))
import { registerMobileHardwareKeyboardScope } from './mobile-hardware-keyboard-registry'
@@ -25,6 +30,7 @@ import { registerMobileHardwareKeyboardScope } from './mobile-hardware-keyboard-
describe('mobile hardware keyboard registry', () => {
beforeEach(() => {
nativeRuntime.setCommands.mockClear()
nativeRuntime.policy = 'terminal-first'
})
it('lets the newest scope suppress an overlapping action', () => {
@@ -50,4 +56,40 @@ describe('mobile hardware keyboard registry', () => {
unregisterTerminal()
unregisterApp()
})
it('publishes and releases terminal-overlapping chords when the policy changes', () => {
nativeRuntime.policy = 'orca-first'
const unregister = registerMobileHardwareKeyboardScope({
actionIds: [
'tab.previousRecent',
'tab.nextTerminal',
'tab.previousTerminal',
'tab.nextAllTypes'
],
context: 'terminal',
handler: vi.fn()
})
const registered = () => nativeRuntime.setCommands.mock.calls.at(-1)?.[0] ?? []
expect(registered()).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'Tab', control: true, shift: false }),
expect.objectContaining({ key: 'PageDown', control: true, shift: false }),
expect.objectContaining({ key: 'PageUp', control: true, shift: false })
])
)
nativeRuntime.policy = 'terminal-first'
nativeRuntime.preferencesChanged()
expect(registered().map((command: { actionId: string }) => command.actionId)).toEqual([
'tab.previousRecent',
'tab.nextTerminal',
'tab.previousTerminal'
])
nativeRuntime.policy = 'orca-first'
nativeRuntime.preferencesChanged()
expect(registered()).toEqual(
expect.arrayContaining([expect.objectContaining({ actionId: 'tab.nextAllTypes' })])
)
unregister()
expect(registered()).toEqual([])
})
})