From 73c69bd525f15a5584f3c238cf1413daf25e869a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 28 Oct 2026 21:04:36 +0200 Subject: [PATCH] Send Android key events to the tapped view while no view has focus With `enableKeyEvents` on, a key pressed after a tap that neither types a character nor moves focus reached no JS handler: Android leaves no view focused in touch mode, so it hands the key to no view and `JSKeyDispatcher` has no target. The root view now takes such a key from Android's unhandled-key listener and sends it to the view a browser would hold focused: the nearest focusable React view of the window's last tap, or of the view whose focus entering touch mode took away. A scroll keeps that target; a tap on nothing focusable, or any view taking Android's focus, ends it. Fixes #59537 --- .../com/facebook/react/ReactRootView.java | 39 +- .../react/runtime/ReactSurfaceView.kt | 2 +- .../react/uimanager/JSKeyDispatcher.kt | 18 +- .../react/uimanager/TapFocusTracker.kt | 129 ++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../runtime/ReactSurfaceViewKeyEventTest.kt | 657 ++++++++++++++++++ 6 files changed, 841 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TapFocusTracker.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactSurfaceViewKeyEventTest.kt 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 + } +}