diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt index f36a1e3e1386..53926406b7f6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -273,7 +273,7 @@ public class ReactModalHostView(context: ThemedReactContext) : } val currentActivity = getCurrentActivity() - val newDialog = ComponentDialog(currentActivity ?: context, theme) + val newDialog = ReactModalDialog(currentActivity ?: context, theme) dialog = newDialog val window = requireNotNull(newDialog.window) window.setFlags( @@ -306,11 +306,14 @@ public class ReactModalHostView(context: ThemedReactContext) : object : DialogInterface.OnKeyListener { override fun onKey(dialog: DialogInterface, keyCode: Int, event: KeyEvent): Boolean { if (event.action == KeyEvent.ACTION_UP) { - // We need to stop the BACK button and ESCAPE key from closing the dialog by default - // so we capture that event and instead inform JS so that it can make the decision as - // to whether or not to allow the back/escape key to close the dialog. If it chooses - // to, it can just set visible to false on the Modal and the Modal will go away - if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE) { + // We need to stop the BACK button from closing the dialog by default so we capture + // that event and instead inform JS so that it can make the decision as to whether or + // not to allow the back key to close the dialog. If it chooses to, it can just set + // visible to false on the Modal and the Modal will go away. + // ESCAPE is routed through ReactModalDialog.dispatchKeyEvent which forwards it to + // onBackPressedDispatcher, so it is intentionally not handled here to avoid + // double-dispatch. + if (keyCode == KeyEvent.KEYCODE_BACK) { handleCloseAction() return true } else { @@ -491,6 +494,25 @@ public class ReactModalHostView(context: ThemedReactContext) : private const val TAG = "ReactModalHost" } + /** + * Subclass of [ComponentDialog] that explicitly routes the hardware ESCAPE key through the + * dialog's [onBackPressedDispatcher], mirroring how the platform routes the BACK key. This is + * required because some Android versions and external keyboards do not deliver KEYCODE_ESCAPE to + * [DialogInterface.OnKeyListener] in a way that allows the modal's onRequestClose callback to be + * invoked, so we intercept it here to guarantee a single, consistent dispatch path. Marked + * `internal` so a same-module Robolectric test can verify the ESC routing contract. + */ + internal class ReactModalDialog(context: Context, themeResId: Int) : + ComponentDialog(context, themeResId) { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_ESCAPE && event.action == KeyEvent.ACTION_UP) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.dispatchKeyEvent(event) + } + } + /** * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all * child information forwarded from [ReactModalHostView] and uses that to create children. It is diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/modal/ReactModalDialogTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/modal/ReactModalDialogTest.kt new file mode 100644 index 000000000000..916a15a6bd3c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/modal/ReactModalDialogTest.kt @@ -0,0 +1,99 @@ +/* + * 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.modal + +import android.app.Activity +import android.view.KeyEvent +import androidx.activity.OnBackPressedCallback +import com.facebook.react.R +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Regression test for issue #56411: pressing the hardware ESCAPE key on Android must invoke the + * `onRequestClose` flow on a Modal. The fix routes KEYCODE_ESCAPE through the dialog's + * [androidx.activity.OnBackPressedDispatcher] from inside [ReactModalHostView.ReactModalDialog], + * which guarantees a single dispatch path consistent with KEYCODE_BACK. + * + * Robolectric cannot exercise device-level key dispatch end-to-end, so this test verifies the + * dispatcher contract: a registered [OnBackPressedCallback] fires when an ESCAPE ACTION_UP key + * event is dispatched to the dialog, and other key events fall through unchanged. + */ +@RunWith(RobolectricTestRunner::class) +class ReactModalDialogTest { + + private lateinit var activity: Activity + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).create().get() + } + + @Test + fun `escape key up dispatches through onBackPressedDispatcher`() { + val dialog = + ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog) + var backPressedCount = 0 + dialog.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + backPressedCount++ + } + } + ) + + val handled = + dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE)) + + assertThat(handled).isTrue() + assertThat(backPressedCount).isEqualTo(1) + } + + @Test + fun `escape key down does not trigger onBackPressedDispatcher`() { + val dialog = + ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog) + var backPressedCount = 0 + dialog.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + backPressedCount++ + } + } + ) + + // Only ACTION_UP should consume; ACTION_DOWN must fall through to super so the platform's + // existing key-dispatch lifecycle (long-press, repeat, etc.) is not disturbed. + dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE)) + + assertThat(backPressedCount).isEqualTo(0) + } + + @Test + fun `non-escape keys are delegated to super dispatchKeyEvent`() { + val dialog = + ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog) + var backPressedCount = 0 + dialog.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + backPressedCount++ + } + } + ) + + // An arbitrary letter key must not be intercepted by the ESC override. + dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_A)) + + assertThat(backPressedCount).isEqualTo(0) + } +}