diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java
index 10b38febfa6a..f8ec0a3f050e 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java
@@ -66,6 +66,7 @@
import com.facebook.react.uimanager.ReactStage;
import com.facebook.react.uimanager.RootView;
import com.facebook.react.uimanager.RootViewUtil;
+import com.facebook.react.uimanager.TapFocusTracker;
import com.facebook.react.uimanager.UIManagerHelper;
import com.facebook.react.uimanager.common.UIManagerType;
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -105,6 +106,7 @@ public interface ReactRootViewEventListener {
private @Nullable JSTouchDispatcher mJSTouchDispatcher;
private @Nullable JSPointerDispatcher mJSPointerDispatcher;
private @Nullable JSKeyDispatcher mJSKeyDispatcher;
+ private @Nullable TapFocusTracker mTapFocusTracker;
private final ReactAndroidHWInputDeviceHelper mAndroidHWInputDeviceHelper =
new ReactAndroidHWInputDeviceHelper();
private boolean mWasMeasured = false;
@@ -259,6 +261,9 @@ public boolean shouldDispatchJSTouchEvent(MotionEvent ev) {
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
+ if (mTapFocusTracker != null) {
+ mTapFocusTracker.handleMotionEvent(ev);
+ }
if (shouldDispatchJSTouchEvent(ev)) {
dispatchJSTouchEvent(ev);
}
@@ -268,6 +273,9 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
@Override
public boolean onTouchEvent(MotionEvent ev) {
+ if (mTapFocusTracker != null) {
+ mTapFocusTracker.handleMotionEvent(ev);
+ }
if (shouldDispatchJSTouchEvent(ev)) {
dispatchJSTouchEvent(ev);
}
@@ -450,6 +458,30 @@ protected void dispatchJSKeyEvent(KeyEvent ev) {
}
}
+ private void startTrackingKeyEventTargets() {
+ TapFocusTracker tapFocusTracker = mTapFocusTracker;
+ if (tapFocusTracker == null) {
+ if (!ReactNativeFeatureFlags.enableKeyEvents()) {
+ return;
+ }
+ tapFocusTracker = new TapFocusTracker(this);
+ mTapFocusTracker = tapFocusTracker;
+ ViewCompat.addOnUnhandledKeyEventListener(this, this::dispatchUnfocusedJSKeyEvent);
+ }
+ if (isAttachedToWindow()) {
+ tapFocusTracker.observeWindow();
+ }
+ }
+
+ private boolean dispatchUnfocusedJSKeyEvent(View view, KeyEvent keyEvent) {
+ // A root that is its window's top view gets every key through dispatchKeyEvent, and any root
+ // gets here a key that a focused view left unhandled, after dispatchKeyEvent sent it.
+ if (getRootView() != this && getRootView().findFocus() == null) {
+ dispatchJSKeyEvent(keyEvent);
+ }
+ return false;
+ }
+
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
// Override in order to still receive events to onInterceptTouchEvent even when some other
@@ -479,6 +511,7 @@ protected void onAttachedToWindow() {
if (isViewAttachedToReactInstance()) {
removeOnGlobalLayoutListener();
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
+ startTrackingKeyEventTargets();
}
}
@@ -488,6 +521,9 @@ protected void onDetachedFromWindow() {
if (isViewAttachedToReactInstance()) {
removeOnGlobalLayoutListener();
}
+ if (mTapFocusTracker != null) {
+ mTapFocusTracker.stopObservingWindow();
+ }
}
private void removeOnGlobalLayoutListener() {
@@ -706,7 +742,7 @@ public void onAttachedToReactInstance() {
}
if (ReactNativeFeatureFlags.enableKeyEvents()) {
- mJSKeyDispatcher = new JSKeyDispatcher();
+ mJSKeyDispatcher = new JSKeyDispatcher(this);
}
if (mRootViewEventListener != null) {
@@ -820,6 +856,7 @@ private void attachToReactInstanceManager() {
mIsAttachedToInstance = true;
Assertions.assertNotNull(mReactInstanceManager).attachRootView(this);
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
+ startTrackingKeyEventTargets();
} finally {
ReactMarker.logMarker(ReactMarkerConstants.ROOT_VIEW_ATTACH_TO_REACT_INSTANCE_MANAGER_END);
Systrace.endSection(TRACE_TAG_REACT);
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt
index 6ff3c2860575..bdc7adc2625b 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt
@@ -74,7 +74,7 @@ public class ReactSurfaceView(context: Context?, internal val surface: ReactSurf
jsPointerDispatcher = JSPointerDispatcher(this)
}
if (ReactNativeFeatureFlags.enableKeyEvents()) {
- jsKeyDispatcher = JSKeyDispatcher()
+ jsKeyDispatcher = JSKeyDispatcher(this)
}
}
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSKeyDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSKeyDispatcher.kt
index 0c5b586c7b36..34851a2f02f5 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSKeyDispatcher.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSKeyDispatcher.kt
@@ -9,6 +9,7 @@ package com.facebook.react.uimanager
import android.view.KeyEvent as AndroidKeyEvent
import android.view.View
+import android.view.ViewGroup
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.uimanager.events.KeyDownEvent
import com.facebook.react.uimanager.events.KeyUpEvent
@@ -19,9 +20,10 @@ import com.facebook.react.uimanager.events.KeyUpEvent
* phases.
*
* The keydown and keyup events provide a code indicating which key is pressed. The event target is
- * derived from the currently focused Android view.
+ * derived from the currently focused Android view. While no view in the window holds focus, as
+ * after a touch in touch mode, the target is the view [TapFocusTracker] holds focused for it.
*/
-internal class JSKeyDispatcher {
+internal class JSKeyDispatcher(private val rootViewGroup: ViewGroup) {
private var focusedViewTag: Int = View.NO_ID
fun handleKeyEvent(
@@ -29,7 +31,13 @@ internal class JSKeyDispatcher {
eventDispatcher: EventDispatcher,
surfaceId: Int,
) {
- if (focusedViewTag == View.NO_ID) {
+ val targetTag =
+ if (rootViewGroup.rootView.findFocus() != null) {
+ focusedViewTag
+ } else {
+ TapFocusTracker.findFocusedView(rootViewGroup)?.id ?: View.NO_ID
+ }
+ if (targetTag == View.NO_ID) {
return
}
@@ -38,7 +46,7 @@ internal class JSKeyDispatcher {
eventDispatcher.dispatchEvent(
KeyDownEvent(
surfaceId,
- focusedViewTag,
+ targetTag,
keyEvent,
),
)
@@ -47,7 +55,7 @@ internal class JSKeyDispatcher {
eventDispatcher.dispatchEvent(
KeyUpEvent(
surfaceId,
- focusedViewTag,
+ targetTag,
keyEvent,
),
)
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TapFocusTracker.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TapFocusTracker.kt
new file mode 100644
index 000000000000..82fab0464c36
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TapFocusTracker.kt
@@ -0,0 +1,129 @@
+/*
+ * 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
+
+import android.graphics.PointF
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import com.facebook.react.R
+import java.lang.ref.WeakReference
+
+/**
+ * Keeps, for the window of a React root, the view a browser would hold focused where Android holds
+ * none, since Android's touch mode leaves no view focused unless it is focusable in touch mode: the
+ * nearest focusable React view of the last tap on a React root in the window, or of the view whose
+ * focus a touch that was no tap took away. A view that takes Android's focus, or held it when
+ * tapped, holds that focus itself, so the tap's focus ends.
+ *
+ * A tap is a touch with one pointer that never moves past the touch slop, so a scroll keeps the
+ * focus, as it keeps a browser's.
+ */
+internal class TapFocusTracker(private val rootViewGroup: ViewGroup) :
+ ViewTreeObserver.OnGlobalFocusChangeListener, ViewTreeObserver.OnTouchModeChangeListener {
+ private val touchSlop = ViewConfiguration.get(rootViewGroup.context).scaledTouchSlop
+ private var tapStart: PointF? = null
+
+ fun handleMotionEvent(motionEvent: MotionEvent) {
+ when (motionEvent.actionMasked) {
+ MotionEvent.ACTION_DOWN -> tapStart = PointF(motionEvent.x, motionEvent.y)
+ MotionEvent.ACTION_MOVE ->
+ tapStart = tapStart?.takeIf { start -> isWithinTouchSlop(start, motionEvent) }
+ MotionEvent.ACTION_UP -> {
+ if (tapStart != null) {
+ setFocusedView(findTappedView(motionEvent)?.takeUnless { it.isFocused })
+ }
+ tapStart = null
+ }
+ else -> tapStart = null
+ }
+ }
+
+ /** Follows the focus and the touch mode of the root's window, until [stopObservingWindow]. */
+ fun observeWindow() {
+ stopObservingWindow()
+ rootViewGroup.viewTreeObserver.addOnGlobalFocusChangeListener(this)
+ rootViewGroup.viewTreeObserver.addOnTouchModeChangeListener(this)
+ }
+
+ fun stopObservingWindow() {
+ rootViewGroup.viewTreeObserver.removeOnGlobalFocusChangeListener(this)
+ rootViewGroup.viewTreeObserver.removeOnTouchModeChangeListener(this)
+ }
+
+ override fun onGlobalFocusChanged(oldFocus: View?, newFocus: View?) {
+ if (newFocus != null) {
+ setFocusedView(null)
+ }
+ }
+
+ /**
+ * Android calls this before entering touch mode clears the focus of a view not focusable in it.
+ */
+ override fun onTouchModeChanged(isInTouchMode: Boolean) {
+ val focusedView = rootViewGroup.findFocus()
+ if (isInTouchMode && focusedView != null && !focusedView.isFocusableInTouchMode) {
+ setFocusedView(focusedView.findNearestFocusableReactView())
+ }
+ }
+
+ private fun isWithinTouchSlop(start: PointF, motionEvent: MotionEvent): Boolean {
+ val deltaX = motionEvent.x - start.x
+ val deltaY = motionEvent.y - start.y
+ return deltaX * deltaX + deltaY * deltaY <= touchSlop * touchSlop
+ }
+
+ private fun findTappedView(motionEvent: MotionEvent): View? =
+ TouchTargetHelper.findTargetPathAndCoordinatesForTouch(
+ motionEvent.x,
+ motionEvent.y,
+ rootViewGroup,
+ FloatArray(2),
+ )
+ .firstNotNullOfOrNull { target -> target.getView() }
+ ?.findNearestFocusableReactView()
+
+ private fun setFocusedView(view: View?) {
+ rootViewGroup.rootView.setTag(R.id.tap_focused_view, view?.let(::TappedView))
+ }
+
+ private class TappedView(view: View) {
+ private val viewReference = WeakReference(view)
+ private val viewTag = view.id
+
+ /**
+ * The tapped view while it could still hold focus and [rootViewGroup] is the closest React root
+ * above it. A view recycled under another tag since the tap stands for another component.
+ */
+ fun findIn(rootViewGroup: ViewGroup): View? {
+ val view = viewReference.get() ?: return null
+ if (view.id != viewTag || !view.isShown || !view.isFocusableReactView()) {
+ return null
+ }
+ var parent = view.parent
+ while (parent != null && parent !is RootView) {
+ parent = parent.parent
+ }
+ return if (parent === rootViewGroup) view else null
+ }
+ }
+
+ companion object {
+ /** The view the window of [rootViewGroup] holds focused for a browser, if it is inside it. */
+ fun findFocusedView(rootViewGroup: ViewGroup): View? =
+ (rootViewGroup.rootView.getTag(R.id.tap_focused_view) as? TappedView)?.findIn(rootViewGroup)
+ }
+}
+
+/** A view without a positive id is no React view, so no JS event can name it. */
+private fun View.isFocusableReactView(): Boolean = id > 0 && isFocusable && isEnabled
+
+private fun View.findNearestFocusableReactView(): View? =
+ generateSequence(this) { it.parent as? View }.firstOrNull { it.isFocusableReactView() }
diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml
index d03b719892c1..df6f1513b5f2 100644
--- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml
+++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml
@@ -88,4 +88,7 @@
+
+
+
diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactSurfaceViewKeyEventTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactSurfaceViewKeyEventTest.kt
new file mode 100644
index 000000000000..14a6f9681008
--- /dev/null
+++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactSurfaceViewKeyEventTest.kt
@@ -0,0 +1,657 @@
+/*
+ * 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.runtime
+
+import android.os.Looper
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.InputEvent
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.activity.ComponentActivity
+import com.facebook.react.R
+import com.facebook.react.ReactRootView
+import com.facebook.react.bridge.ReactTestHelper.createTestReactApplicationContext
+import com.facebook.react.common.annotations.UnstableReactNativeAPI
+import com.facebook.react.fabric.SurfaceHandlerBinding
+import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
+import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
+import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlagsDefaults
+import com.facebook.react.uimanager.events.Event
+import com.facebook.react.views.view.ReactViewGroup
+import com.facebook.testutils.fakes.FakeEventDispatcher
+import com.facebook.testutils.shadows.ShadowArguments
+import com.facebook.testutils.shadows.ShadowNativeArray
+import com.facebook.testutils.shadows.ShadowNativeLoader
+import com.facebook.testutils.shadows.ShadowNativeMap
+import com.facebook.testutils.shadows.ShadowSoLoader
+import com.facebook.testutils.shadows.ShadowWritableNativeArray
+import com.facebook.testutils.shadows.ShadowWritableNativeMap
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@OptIn(UnstableReactNativeAPI::class)
+@Config(
+ shadows =
+ [
+ ShadowArguments::class,
+ ShadowSoLoader::class,
+ ShadowNativeLoader::class,
+ ShadowNativeArray::class,
+ ShadowNativeMap::class,
+ ShadowWritableNativeArray::class,
+ ShadowWritableNativeMap::class,
+ ],
+)
+@RunWith(RobolectricTestRunner::class)
+class ReactSurfaceViewKeyEventTest {
+
+ private lateinit var eventDispatcher: FakeEventDispatcher
+ private lateinit var activity: KeyRecordingActivity
+
+ @Before
+ fun setUp() {
+ ReactNativeFeatureFlagsForTests.setUp()
+ ReactNativeFeatureFlags.override(
+ object : ReactNativeNewArchitectureFeatureFlagsDefaults() {
+ override fun enableKeyEvents(): Boolean = true
+ },
+ )
+ eventDispatcher = FakeEventDispatcher()
+ activity =
+ Robolectric.buildActivity(KeyRecordingActivity::class.java)
+ .setup()
+ .windowFocusChanged(true)
+ .get()
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsTheTappedFocusableView() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isNull()
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ assertThat(keyEvents().map { it.surfaceId }).containsOnly(surfaceView.surface.surfaceID)
+ assertThat(activity.keyCodesDown).containsExactly(KeyEvent.KEYCODE_ESCAPE)
+ }
+
+ @Test
+ fun testKeyWithAViewFocusedTargetsThatViewOnce() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+ val field = addTextField(surfaceView, FIELD_TAG, top = 2 * VIEW_SIZE)
+
+ tapThroughWindow(button)
+ field.requestFocus()
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isSameAs(field)
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to FIELD_TAG, KEY_UP to FIELD_TAG)
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsTheNearestFocusableAncestorOfTheTap() {
+ val surfaceView = startSurfaceViews(1).single()
+ val container =
+ addLaidOutView(
+ surfaceView,
+ ReactViewGroup(activity).apply { isFocusable = true },
+ CONTAINER_TAG,
+ size = 2 * VIEW_SIZE,
+ )
+ val label = addLaidOutView(container, ReactViewGroup(activity), LABEL_TAG)
+
+ tapThroughWindow(label)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents())
+ .containsExactly(KEY_DOWN to CONTAINER_TAG, KEY_UP to CONTAINER_TAG)
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsTheNearestReactViewOfATapOnANativeView() {
+ val surfaceView = startSurfaceViews(1).single()
+ val container =
+ addLaidOutView(
+ surfaceView,
+ ReactViewGroup(activity).apply { isFocusable = true },
+ CONTAINER_TAG,
+ size = 2 * VIEW_SIZE,
+ )
+ val nativeView =
+ addLaidOutView(
+ container,
+ View(activity).apply {
+ isFocusable = true
+ isClickable = true
+ },
+ View.NO_ID,
+ )
+
+ tapThroughWindow(nativeView)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents())
+ .containsExactly(KEY_DOWN to CONTAINER_TAG, KEY_UP to CONTAINER_TAG)
+ }
+
+ @Test
+ fun testAViewThatLosesFocusToATouchThatIsNoTapIsTheTarget() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+ val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop
+ button.requestFocus()
+ assertThat(button.isFocused).isTrue()
+
+ touchThroughWindow(
+ surfaceView,
+ surfaceView.width - VIEW_SIZE / 2f,
+ MotionEvent.ACTION_DOWN to VIEW_SIZE / 2f,
+ MotionEvent.ACTION_MOVE to VIEW_SIZE / 2f + 2 * touchSlop,
+ MotionEvent.ACTION_UP to VIEW_SIZE / 2f + 2 * touchSlop,
+ )
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isNull()
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ }
+
+ @Test
+ fun testAFieldThatKeepsFocusThroughATouchIsNoTargetOnceItLosesFocus() {
+ val surfaceView = startSurfaceViews(1).single()
+ val field = addTextField(surfaceView, FIELD_TAG)
+ val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop
+ field.requestFocus()
+
+ touchThroughWindow(
+ surfaceView,
+ surfaceView.width - VIEW_SIZE / 2f,
+ MotionEvent.ACTION_DOWN to VIEW_SIZE / 2f,
+ MotionEvent.ACTION_MOVE to VIEW_SIZE / 2f + 2 * touchSlop,
+ MotionEvent.ACTION_UP to VIEW_SIZE / 2f + 2 * touchSlop,
+ )
+ assertThat(field.isFocused).isTrue()
+ field.clearFocus()
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).isEmpty()
+ }
+
+ @Test
+ fun testAViewOutsideTheRootThatTakesFocusEndsTheTapFocus() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+ val nativeField = EditText(activity)
+ (surfaceView.parent as ViewGroup).addView(nativeField)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ tapThroughWindow(button)
+ nativeField.requestFocus()
+ nativeField.clearFocus()
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isNull()
+ assertThat(dispatchedKeyEvents()).isEmpty()
+ }
+
+ @Test
+ fun testARootThatIsItsWindowsTopViewSendsAKeyOnce() {
+ val surfaceView = makeSurfaceView(surfaceId = 1)
+ activity.windowManager.addView(surfaceView, WindowManager.LayoutParams())
+ shadowOf(Looper.getMainLooper()).idle()
+ giveWindowFocus(surfaceView)
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE, surfaceView)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ activity.windowManager.removeView(surfaceView)
+ }
+
+ @Test
+ fun testAViewInANestedRootIsTheTargetOfThatRootAlone() {
+ val outerSurfaceView = startSurfaceViews(1).single()
+ val innerSurfaceView =
+ addLaidOutView(
+ outerSurfaceView,
+ makeSurfaceView(surfaceId = INNER_SURFACE_ID),
+ INNER_SURFACE_ID,
+ size = 2 * VIEW_SIZE,
+ )
+ val button = addFocusableView(innerSurfaceView, SECOND_BUTTON_TAG)
+
+ tapThroughWindow(button)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents())
+ .containsExactly(KEY_DOWN to SECOND_BUTTON_TAG, KEY_UP to SECOND_BUTTON_TAG)
+ assertThat(keyEvents().map { it.surfaceId }).containsOnly(INNER_SURFACE_ID)
+ }
+
+ @Test
+ fun testWithKeyEventsOffATapIsNotTracked() {
+ ReactNativeFeatureFlagsForTests.setUp()
+ ReactNativeFeatureFlags.override(ReactNativeNewArchitectureFeatureFlagsDefaults())
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+
+ assertThat(activity.window.decorView.getTag(R.id.tap_focused_view)).isNull()
+ }
+
+ @Test
+ fun testMakingARootViewReadsNoFeatureFlag() {
+ ReactNativeFeatureFlagsForTests.setUp()
+ ReactRootView(activity)
+
+ ReactNativeFeatureFlags.override(ReactNativeNewArchitectureFeatureFlagsDefaults())
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsNoViewAfterATapOnNothingFocusable() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ tapThroughWindow(surfaceView, x = surfaceView.width - VIEW_SIZE / 2f, y = VIEW_SIZE / 2f)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).isEmpty()
+ assertThat(activity.keyCodesDown).containsExactly(KeyEvent.KEYCODE_ESCAPE)
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsNoViewOnceTheTappedViewCannotTakeFocus() {
+ val surfaceView = startSurfaceViews(1).single()
+ val changes: List<(View) -> Unit> =
+ listOf(
+ { view -> surfaceView.removeView(view) },
+ { view -> view.id = RECYCLED_TAG },
+ { view -> view.visibility = View.INVISIBLE },
+ { view -> view.isFocusable = false },
+ { view -> view.isEnabled = false },
+ )
+
+ for (change in changes) {
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+ tapThroughWindow(button)
+ change(button)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+ surfaceView.removeView(button)
+ }
+
+ assertThat(dispatchedKeyEvents()).isEmpty()
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedTargetsOnlyTheSurfaceViewTappedLast() {
+ val (firstSurfaceView, secondSurfaceView) = startSurfaceViews(2)
+ val firstButton = addFocusableView(firstSurfaceView, BUTTON_TAG)
+ val secondButton = addFocusableView(secondSurfaceView, SECOND_BUTTON_TAG)
+
+ tapThroughWindow(firstButton)
+ tapThroughWindow(
+ secondSurfaceView,
+ x = secondSurfaceView.width - VIEW_SIZE / 4f,
+ y = VIEW_SIZE / 2f,
+ )
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+ tapThroughWindow(secondButton)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents())
+ .containsExactly(KEY_DOWN to SECOND_BUTTON_TAG, KEY_UP to SECOND_BUTTON_TAG)
+ assertThat(keyEvents().map { it.surfaceId }).containsOnly(secondSurfaceView.surface.surfaceID)
+ }
+
+ @Test
+ fun testAStylusTapMakesTheTargetAsAFingerTapDoes() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button, source = InputDevice.SOURCE_STYLUS)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ }
+
+ @Test
+ fun testAScrollKeepsTheTappedViewTheTarget() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+ val listItem = addFocusableView(surfaceView, SECOND_BUTTON_TAG, top = 2 * VIEW_SIZE)
+ val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop
+
+ tapThroughWindow(button)
+ touchThroughWindow(
+ listItem,
+ VIEW_SIZE / 2f,
+ MotionEvent.ACTION_DOWN to VIEW_SIZE / 4f,
+ MotionEvent.ACTION_MOVE to VIEW_SIZE / 4f + 2 * touchSlop,
+ MotionEvent.ACTION_UP to VIEW_SIZE / 4f + 2 * touchSlop,
+ )
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ }
+
+ @Test
+ fun testACancelledTouchKeepsTheTappedViewTheTarget() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ touchThroughWindow(
+ surfaceView,
+ surfaceView.width - VIEW_SIZE / 2f,
+ MotionEvent.ACTION_DOWN to VIEW_SIZE / 2f,
+ MotionEvent.ACTION_CANCEL to VIEW_SIZE / 2f,
+ )
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ }
+
+ @Test
+ fun testATouchWithASecondPointerKeepsTheTappedViewTheTarget() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ touchWithTwoPointersThroughWindow(
+ surfaceView,
+ x = surfaceView.width - VIEW_SIZE / 2f,
+ y = VIEW_SIZE / 2f,
+ )
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ }
+
+ @Test
+ fun testATappedViewThatTakesFocusIsNoTargetOnceItLosesFocus() {
+ val surfaceView = startSurfaceViews(1).single()
+ tapThroughWindow(surfaceView, x = surfaceView.width - VIEW_SIZE / 2f, y = VIEW_SIZE / 2f)
+ val field = addTextField(surfaceView, FIELD_TAG)
+
+ tapThroughWindow(field)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+ field.clearFocus()
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isNull()
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to FIELD_TAG, KEY_UP to FIELD_TAG)
+ }
+
+ @Test
+ fun testATappedViewThatHeldFocusIsNoTargetOnceItLosesFocus() {
+ val surfaceView = startSurfaceViews(1).single()
+ val field = addTextField(surfaceView, FIELD_TAG)
+ field.requestFocus()
+
+ tapThroughWindow(field)
+ field.clearFocus()
+ pressKeyThroughWindow(KeyEvent.KEYCODE_ESCAPE)
+
+ assertThat(activity.window.decorView.findFocus()).isNull()
+ assertThat(dispatchedKeyEvents()).isEmpty()
+ }
+
+ @Test
+ fun testBackReachesTheActivityAndTheTappedView() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ pressKeyThroughWindow(KeyEvent.KEYCODE_BACK)
+
+ assertThat(dispatchedKeyEvents()).containsExactly(KEY_DOWN to BUTTON_TAG, KEY_UP to BUTTON_TAG)
+ assertThat(activity.keyCodesDown).containsExactly(KeyEvent.KEYCODE_BACK)
+ assertThat(activity.keyCodesUp).containsExactly(KeyEvent.KEYCODE_BACK)
+ }
+
+ @Test
+ fun testKeyWithNoViewFocusedSendsModifiersAndRepeatsToTheTappedView() {
+ val surfaceView = startSurfaceViews(1).single()
+ val button = addFocusableView(surfaceView, BUTTON_TAG)
+
+ tapThroughWindow(button)
+ val downTime = SystemClock.uptimeMillis()
+ dispatchThroughWindow(
+ KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0),
+ )
+ for (repeatCount in 0..1) {
+ dispatchThroughWindow(
+ KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE, repeatCount),
+ )
+ }
+
+ assertThat(dispatchedKeyEvents())
+ .containsExactly(KEY_DOWN to BUTTON_TAG, KEY_DOWN to BUTTON_TAG, KEY_DOWN to BUTTON_TAG)
+ }
+
+ private fun startSurfaceViews(count: Int): List {
+ val surfaceViews = List(count) { index -> makeSurfaceView(surfaceId = index + 1) }
+ val content = LinearLayout(activity)
+ for (surfaceView in surfaceViews) {
+ content.addView(
+ surfaceView,
+ LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f),
+ )
+ }
+ activity.setContentView(content)
+ shadowOf(Looper.getMainLooper()).idle()
+ return surfaceViews
+ }
+
+ private fun makeSurfaceView(surfaceId: Int): ReactSurfaceView {
+ val reactContext = createTestReactApplicationContext()
+ val reactHost: ReactHostImpl = mock()
+ whenever(reactHost.eventDispatcher).doReturn(eventDispatcher)
+ whenever(reactHost.currentReactContext).doReturn(reactContext)
+ val surfaceHandler: SurfaceHandlerBinding = mock()
+ whenever(surfaceHandler.surfaceId).doReturn(surfaceId)
+ val surface = ReactSurfaceImpl(surfaceHandler, activity)
+ val surfaceView = ReactSurfaceView(activity, surface)
+ surface.attachView(surfaceView)
+ surface.attach(reactHost)
+ return surfaceView
+ }
+
+ private fun addFocusableView(parent: ViewGroup, tag: Int, top: Int = 0): View =
+ addLaidOutView(parent, ReactViewGroup(parent.context).apply { isFocusable = true }, tag, top)
+
+ private fun addTextField(parent: ViewGroup, tag: Int, top: Int = 0): EditText =
+ addLaidOutView(parent, EditText(parent.context), tag, top)
+
+ private fun addLaidOutView(
+ parent: ViewGroup,
+ view: T,
+ tag: Int,
+ top: Int = 0,
+ size: Int = VIEW_SIZE,
+ ): T {
+ view.id = tag
+ parent.addView(view)
+ val measureSpec = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
+ view.measure(measureSpec, measureSpec)
+ view.layout(0, top, size, top + size)
+ return view
+ }
+
+ private fun tapThroughWindow(
+ view: View,
+ x: Float = view.width / 2f,
+ y: Float = view.height / 2f,
+ source: Int = InputDevice.SOURCE_TOUCHSCREEN,
+ ) {
+ touchThroughWindow(
+ view,
+ x,
+ MotionEvent.ACTION_DOWN to y,
+ MotionEvent.ACTION_UP to y,
+ source = source,
+ )
+ }
+
+ private fun touchThroughWindow(
+ view: View,
+ x: Float,
+ vararg actionsWithY: Pair,
+ source: Int = InputDevice.SOURCE_TOUCHSCREEN,
+ ) {
+ val location = IntArray(2)
+ view.getLocationInWindow(location)
+ val downTime = SystemClock.uptimeMillis()
+ for ((action, y) in actionsWithY) {
+ val event =
+ MotionEvent.obtain(downTime, downTime, action, location[0] + x, location[1] + y, 0)
+ event.source = source
+ dispatchThroughWindow(event, view.rootView)
+ }
+ }
+
+ private fun touchWithTwoPointersThroughWindow(view: View, x: Float, y: Float) {
+ val location = IntArray(2)
+ view.getLocationInWindow(location)
+ val downTime = SystemClock.uptimeMillis()
+ val pointerProperties =
+ Array(2) { pointerId ->
+ MotionEvent.PointerProperties().apply {
+ id = pointerId
+ toolType = MotionEvent.TOOL_TYPE_FINGER
+ }
+ }
+ val pointerCoords =
+ Array(2) { pointerId ->
+ MotionEvent.PointerCoords().apply {
+ this.x = location[0] + x + pointerId * VIEW_SIZE / 4f
+ this.y = location[1] + y
+ }
+ }
+ val secondPointer = 1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT
+ val steps =
+ listOf(
+ MotionEvent.ACTION_DOWN to 1,
+ (MotionEvent.ACTION_POINTER_DOWN or secondPointer) to 2,
+ (MotionEvent.ACTION_POINTER_UP or secondPointer) to 2,
+ MotionEvent.ACTION_UP to 1,
+ )
+ for ((action, pointerCount) in steps) {
+ dispatchThroughWindow(
+ MotionEvent.obtain(
+ downTime,
+ downTime,
+ action,
+ pointerCount,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ InputDevice.SOURCE_TOUCHSCREEN,
+ 0,
+ ),
+ view.rootView,
+ )
+ }
+ }
+
+ private fun pressKeyThroughWindow(
+ keyCode: Int,
+ windowView: View = activity.window.decorView,
+ ) {
+ for (action in intArrayOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
+ dispatchThroughWindow(KeyEvent(action, keyCode), windowView)
+ }
+ }
+
+ private fun giveWindowFocus(windowView: View) {
+ val viewRootImpl = View::class.java.getMethod("getViewRootImpl").invoke(windowView)
+ viewRootImpl.javaClass
+ .getMethod("windowFocusChanged", Boolean::class.javaPrimitiveType)
+ .invoke(viewRootImpl, true)
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ /**
+ * Android enters touch mode on a touch, and offers a key no view took to
+ * `View.OnUnhandledKeyEventListener`s, only from the window's input stages, which `Activity`'s
+ * own dispatch methods skip.
+ */
+ private fun dispatchThroughWindow(
+ event: InputEvent,
+ windowView: View = activity.window.decorView,
+ ) {
+ val viewRootImpl = View::class.java.getMethod("getViewRootImpl").invoke(windowView)
+ viewRootImpl.javaClass
+ .getMethod("dispatchInputEvent", InputEvent::class.java)
+ .invoke(viewRootImpl, event)
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ private fun keyEvents(): List> =
+ eventDispatcher.getRecordedDispatchedEvents().filter {
+ it.getEventName() == KEY_DOWN || it.getEventName() == KEY_UP
+ }
+
+ private fun dispatchedKeyEvents(): List> =
+ keyEvents().map { it.getEventName() to it.viewTag }
+
+ class KeyRecordingActivity : ComponentActivity() {
+ val keyCodesDown: MutableList = mutableListOf()
+ val keyCodesUp: MutableList = mutableListOf()
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ keyCodesDown.add(keyCode)
+ return true
+ }
+
+ override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
+ keyCodesUp.add(keyCode)
+ return true
+ }
+ }
+
+ private companion object {
+ const val KEY_DOWN = "topKeyDown"
+ const val KEY_UP = "topKeyUp"
+ const val BUTTON_TAG = 42
+ const val SECOND_BUTTON_TAG = 43
+ const val FIELD_TAG = 44
+ const val CONTAINER_TAG = 45
+ const val LABEL_TAG = 46
+ const val RECYCLED_TAG = 47
+ const val INNER_SURFACE_ID = 9
+ const val VIEW_SIZE = 100
+ }
+}