diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index dc9acd738dd..75123abe946 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -15,6 +15,7 @@ import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -25,6 +26,7 @@ import com.facebook.react.bridge.queue.MessageQueueThread; import com.facebook.react.bridge.queue.ReactQueueConfiguration; import com.facebook.react.common.LifecycleState; +import com.facebook.react.interfaces.ExtraWindowEventListener; import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder; import java.lang.ref.WeakReference; import java.util.Collection; @@ -48,6 +50,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); + private final CopyOnWriteArraySet mExtraWindowEventListeners = + new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mWindowFocusEventListeners = new CopyOnWriteArraySet<>(); private final ScrollEndedListeners mScrollEndedListeners = new ScrollEndedListeners(); @@ -246,6 +250,14 @@ public void removeActivityEventListener(ActivityEventListener listener) { mActivityEventListeners.remove(listener); } + public void addExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.add(listener); + } + + public void removeExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.remove(listener); + } + public void addWindowFocusChangeListener(WindowFocusChangeListener listener) { mWindowFocusEventListeners.add(listener); } @@ -356,6 +368,30 @@ public void onActivityResult( } } + @ThreadConfined(UI) + public void onExtraWindowCreate(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowCreate(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + + @ThreadConfined(UI) + public void onExtraWindowDestroy(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowDestroy(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + @ThreadConfined(UI) public void onWindowFocusChange(boolean hasFocus) { UiThreadUtil.assertOnUiThread(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt new file mode 100644 index 00000000000..70cbdcadead --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt @@ -0,0 +1,29 @@ +/* + * 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.interfaces + +import android.view.Window + +/** + * Listener for receiving extra window creation and destruction events. + * + * This allows modules to react to new windows being added or removed, such as Dialog windows + * registered by Modal components. Modules like StatusBarModule can implement this interface to + * apply their configuration to all active windows. + * + * Third-party libraries can both implement this listener and emit window events through + * [ReactContext.onExtraWindowCreate] and [ReactContext.onExtraWindowDestroy]. + */ +public interface ExtraWindowEventListener { + + /** Called when a new [Window] is created (e.g. a Dialog window for a Modal). */ + public fun onExtraWindowCreate(window: Window) + + /** Called when a [Window] is destroyed (e.g. on Dialog window dismiss). */ + public fun onExtraWindowDestroy(window: Window) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt index 58fc5c8527b..06cb79b4be3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt @@ -11,6 +11,7 @@ package com.facebook.react.uimanager import android.app.Activity import android.content.Context +import android.view.Window import com.facebook.react.bridge.Callback import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.JavaScriptContextHolder @@ -22,6 +23,7 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ScrollEndedListeners import com.facebook.react.bridge.UIManager import com.facebook.react.common.annotations.internal.LegacyArchitecture +import com.facebook.react.interfaces.ExtraWindowEventListener import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder /** @@ -67,6 +69,22 @@ public class ThemedReactContext( reactApplicationContext.removeLifecycleEventListener(listener) } + override fun addExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.addExtraWindowEventListener(listener) + } + + override fun removeExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.removeExtraWindowEventListener(listener) + } + + override fun onExtraWindowCreate(window: Window) { + reactApplicationContext.onExtraWindowCreate(window) + } + + override fun onExtraWindowDestroy(window: Window) { + reactApplicationContext.onExtraWindowDestroy(window) + } + override fun hasCurrentActivity(): Boolean = reactApplicationContext.hasCurrentActivity() override fun getCurrentActivity(): Activity? = reactApplicationContext.getCurrentActivity() 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 d634b601db6..f36a1e3e138 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 @@ -197,6 +197,9 @@ public class ReactModalHostView(context: ThemedReactContext) : dialog?.let { nonNullDialog -> if (nonNullDialog.isShowing) { + nonNullDialog.window?.let { window -> + (context as ThemedReactContext).onExtraWindowDestroy(window) + } val dialogContext = ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) if (dialogContext == null || !dialogContext.isFinishing) { @@ -341,6 +344,7 @@ public class ReactModalHostView(context: ThemedReactContext) : newDialog.show() updateSystemAppearance() window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + (context as ThemedReactContext).onExtraWindowCreate(window) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt new file mode 100644 index 00000000000..24b0a376b69 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt @@ -0,0 +1,124 @@ +/* + * 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.interfaces + +import android.view.Window +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.testutils.shadows.ShadowSoLoader +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class]) +class ExtraWindowEventListenerTest { + private lateinit var reactContext: ReactApplicationContext + private lateinit var window: Window + + @Before + fun setUp() { + reactContext = ReactTestHelper.createCatalystContextForTest() + window = mock() + } + + @Test + fun testOnExtraWindowCreateNotifiesListener() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowDestroyNotifiesListener() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testMultipleListenersAreNotified() { + val listener1: ExtraWindowEventListener = mock() + val listener2: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener1) + reactContext.addExtraWindowEventListener(listener2) + reactContext.onExtraWindowCreate(window) + + verify(listener1, times(1)).onExtraWindowCreate(window) + verify(listener2, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testRemovedListenerIsNotNotified() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.removeExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + verify(listener, never()).onExtraWindowCreate(window) + } + + @Test + fun testOnlyRemovedListenerStopsReceivingEvents() { + val listener1: ExtraWindowEventListener = mock() + val listener2: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener1) + reactContext.addExtraWindowEventListener(listener2) + reactContext.removeExtraWindowEventListener(listener1) + reactContext.onExtraWindowDestroy(window) + + verify(listener1, never()).onExtraWindowDestroy(window) + verify(listener2, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testListenerReceivesBothCreateAndDestroyEvents() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + reactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + verify(listener, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testNoListenersDoesNotCrash() { + // Should not throw when no listeners are registered + reactContext.onExtraWindowCreate(window) + reactContext.onExtraWindowDestroy(window) + } + + @Test + fun testDuplicateAddIsIdempotent() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + // CopyOnWriteArraySet deduplicates, so listener should only be called once + verify(listener, times(1)).onExtraWindowCreate(window) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt new file mode 100644 index 00000000000..b0e05900d1b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt @@ -0,0 +1,84 @@ +/* + * 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.view.Window +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.interfaces.ExtraWindowEventListener +import com.facebook.testutils.shadows.ShadowSoLoader +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class]) +class ThemedReactContextTest { + private lateinit var reactApplicationContext: ReactApplicationContext + private lateinit var themedReactContext: ThemedReactContext + private lateinit var window: Window + + @Before + fun setUp() { + reactApplicationContext = ReactTestHelper.createCatalystContextForTest() + themedReactContext = + ThemedReactContext(reactApplicationContext, RuntimeEnvironment.getApplication(), null, -1) + window = mock() + } + + @Test + fun testAddExtraWindowEventListenerDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + themedReactContext.addExtraWindowEventListener(listener) + // Verify the listener was registered on the underlying context by dispatching an event + reactApplicationContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testRemoveExtraWindowEventListenerDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + themedReactContext.addExtraWindowEventListener(listener) + themedReactContext.removeExtraWindowEventListener(listener) + // After removal via ThemedReactContext, the listener should not be notified + reactApplicationContext.onExtraWindowCreate(window) + + verify(listener, times(0)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowCreateDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + reactApplicationContext.addExtraWindowEventListener(listener) + // Dispatching via ThemedReactContext should reach listeners on the underlying context + themedReactContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowDestroyDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + reactApplicationContext.addExtraWindowEventListener(listener) + // Dispatching via ThemedReactContext should reach listeners on the underlying context + themedReactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowDestroy(window) + } +}