Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}