diff --git a/packages/react-native/ReactAndroid/build.gradle.kts b/packages/react-native/ReactAndroid/build.gradle.kts index 4dc36a26734..99e05410f8c 100644 --- a/packages/react-native/ReactAndroid/build.gradle.kts +++ b/packages/react-native/ReactAndroid/build.gradle.kts @@ -699,6 +699,7 @@ dependencies { api(libs.androidx.autofill) api(libs.androidx.swiperefreshlayout) api(libs.androidx.tracing) + api(libs.androidx.window) api(libs.fbjni) api(libs.fresco) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt index 9c3a0a625d3..d6bb10bac3e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt @@ -7,14 +7,20 @@ package com.facebook.react.modules.deviceinfo +import android.util.DisplayMetrics +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.window.layout.WindowMetricsCalculator import com.facebook.fbreact.specs.NativeDeviceInfoSpec import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactNoCrashSoftException import com.facebook.react.bridge.ReactSoftExceptionLogger import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.DisplayMetricsHolder.getDisplayMetricsWritableMap +import com.facebook.react.uimanager.DisplayMetricsHolder.getScreenDisplayMetrics import com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized import com.facebook.react.views.view.isEdgeToEdgeFeatureFlagOn @@ -30,8 +36,56 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : reactContext.addLifecycleEventListener(this) } + private fun getWindowDisplayMetrics(): DisplayMetrics { + val windowDisplayMetrics = DisplayMetrics() + windowDisplayMetrics.setTo(reactApplicationContext.resources.displayMetrics) + + val activity = reactApplicationContext.currentActivity ?: return windowDisplayMetrics + val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds + + if (isEdgeToEdgeFeatureFlagOn) { + windowDisplayMetrics.widthPixels = bounds.width() + windowDisplayMetrics.heightPixels = bounds.height() + } else { + // WindowMetrics bounds include system bars. When edge-to-edge is not enabled, we subtract + // them so that window dimensions reflect the usable content area. If insets aren't yet + // available (e.g. before the first layout pass), fall back to resources.displayMetrics, + // which already excludes system bars in non-edge-to-edge mode. + ViewCompat.getRootWindowInsets(activity.window.decorView)?.let { + val insets = it.getInsets(WindowInsetsCompat.Type.systemBars()) + windowDisplayMetrics.widthPixels = bounds.width() - (insets.left + insets.right) + windowDisplayMetrics.heightPixels = bounds.height() - (insets.top + insets.bottom) + } + } + + return windowDisplayMetrics + } + + fun getDisplayMetricsWritableMap(): WritableMap = + WritableNativeMap().apply { + putMap( + "windowPhysicalPixels", + getPhysicalPixelsWritableMap(getWindowDisplayMetrics()), + ) + putMap( + "screenPhysicalPixels", + getPhysicalPixelsWritableMap(getScreenDisplayMetrics()), + ) + } + + private fun getPhysicalPixelsWritableMap( + displayMetrics: DisplayMetrics, + ): WritableMap = + WritableNativeMap().apply { + putInt("width", displayMetrics.widthPixels) + putInt("height", displayMetrics.heightPixels) + putDouble("scale", displayMetrics.density.toDouble()) + putDouble("fontScale", fontScale.toDouble()) + putDouble("densityDpi", displayMetrics.densityDpi.toDouble()) + } + public override fun getTypedExportedConstants(): Map { - val displayMetrics = getDisplayMetricsWritableMap(fontScale.toDouble()) + val displayMetrics = getDisplayMetricsWritableMap() // Cache the initial dimensions for later comparison in emitUpdateDimensionsEvent previousDisplayMetrics = displayMetrics.copy() @@ -58,7 +112,7 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : reactApplicationContext.let { context -> if (context.hasActiveReactInstance()) { // Don't emit an event to JS if the dimensions haven't changed - val displayMetrics = getDisplayMetricsWritableMap(fontScale.toDouble()) + val displayMetrics = getDisplayMetricsWritableMap() if (previousDisplayMetrics == null) { previousDisplayMetrics = displayMetrics.copy() } else if (displayMetrics != previousDisplayMetrics) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.kt index b901406e398..29c9429ac77 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.kt @@ -29,6 +29,7 @@ public object DisplayMetricsHolder { @JvmStatic private var windowDisplayMetrics: DisplayMetrics? = null @JvmStatic private var screenDisplayMetrics: DisplayMetrics? = null + // TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60) /** The metrics of the window associated to the Context used to initialize ReactNative */ @JvmStatic public fun getWindowDisplayMetrics(): DisplayMetrics { @@ -36,6 +37,7 @@ public object DisplayMetricsHolder { return windowDisplayMetrics as DisplayMetrics } + // TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60) @JvmStatic public fun setWindowDisplayMetrics(displayMetrics: DisplayMetrics?) { windowDisplayMetrics = displayMetrics @@ -84,6 +86,7 @@ public object DisplayMetricsHolder { DisplayMetricsHolder.screenDisplayMetrics = screenDisplayMetrics } + // TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60) @JvmStatic public fun getDisplayMetricsWritableMap(fontScale: Double): WritableMap { checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.kt index 684858be3b9..79d7f7056ec 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.kt @@ -9,6 +9,7 @@ package com.facebook.react.modules.deviceinfo +import android.util.DisplayMetrics import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactContext @@ -16,8 +17,13 @@ import com.facebook.react.bridge.ReactTestHelper import com.facebook.react.bridge.WritableMap import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowNativeMap +import com.facebook.testutils.shadows.ShadowReadableNativeMap +import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.testutils.shadows.ShadowWritableNativeMap import junit.framework.TestCase -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test @@ -26,13 +32,26 @@ import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers import org.mockito.MockedStatic import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config( + shadows = + [ + ShadowSoLoader::class, + ShadowNativeLoader::class, + ShadowNativeMap::class, + ShadowWritableNativeMap::class, + ShadowReadableNativeMap::class, + ] +) class DeviceInfoModuleTest : TestCase() { private lateinit var deviceInfoModule: DeviceInfoModule @@ -55,7 +74,7 @@ class DeviceInfoModuleTest : TestCase() { reactContext = spy(BridgeReactContext(RuntimeEnvironment.getApplication())) val catalystInstanceMock = ReactTestHelper.createMockCatalystInstance() reactContext.initializeWithInstance(catalystInstanceMock) - deviceInfoModule = DeviceInfoModule(reactContext) + deviceInfoModule = spy(DeviceInfoModule(reactContext)) } @After @@ -110,10 +129,29 @@ class DeviceInfoModuleTest : TestCase() { ) } - private fun givenDisplayMetricsHolderContains(fakeDisplayMetrics: WritableMap?) { + @Test + fun getDisplayMetricsWritableMap_returnsCorrectMap() { displayMetricsHolder - .`when` { DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) } - .thenAnswer { fakeDisplayMetrics } + .`when` { DisplayMetricsHolder.getScreenDisplayMetrics() } + .thenAnswer { reactContext.resources.displayMetrics } + + // Use the official initialization method to ensure both metrics are set + val map: WritableMap = deviceInfoModule.getDisplayMetricsWritableMap() + assertThat(map.hasKey("windowPhysicalPixels")).isTrue() + assertThat(map.hasKey("screenPhysicalPixels")).isTrue() + val windowMap = map.getMap("windowPhysicalPixels") + val screenMap = map.getMap("screenPhysicalPixels") + checkNotNull(windowMap) + checkNotNull(screenMap) + assertThat(windowMap.hasKey("width")).isTrue() + assertThat(windowMap.hasKey("height")).isTrue() + assertThat(windowMap.hasKey("scale")).isTrue() + assertThat(windowMap.hasKey("fontScale")).isTrue() + assertThat(windowMap.hasKey("densityDpi")).isTrue() + } + + private fun givenDisplayMetricsHolderContains(fakeDisplayMetrics: WritableMap?) { + doReturn(fakeDisplayMetrics).whenever(deviceInfoModule).getDisplayMetricsWritableMap() } companion object { @@ -126,7 +164,7 @@ class DeviceInfoModuleTest : TestCase() { verify(context, times(expectedEventList.size)) ?.emitDeviceEvent(ArgumentMatchers.eq("didUpdateDimensions"), captor.capture()) val actualEvents = captor.allValues - Assertions.assertThat(actualEvents).isEqualTo(expectedEventList) + assertThat(actualEvents).isEqualTo(expectedEventList) } } } diff --git a/packages/react-native/gradle/libs.versions.toml b/packages/react-native/gradle/libs.versions.toml index c04bdb8a79a..80a8d669995 100644 --- a/packages/react-native/gradle/libs.versions.toml +++ b/packages/react-native/gradle/libs.versions.toml @@ -16,6 +16,7 @@ androidx-swiperefreshlayout = "1.1.0" androidx-test = "1.5.0" androidx-test-junit = "1.2.1" androidx-tracing = "1.1.0" +androidx-window = "1.5.1" assertj = "3.21.0" binary-compatibility-validator = "0.13.2" download = "5.4.0" @@ -64,6 +65,7 @@ androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx- androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-tracing = { module = "androidx.tracing:tracing", version.ref = "androidx-tracing" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } fbjni = { module = "com.facebook.fbjni:fbjni", version.ref = "fbjni" } fresco = { module = "com.facebook.fresco:fresco", version.ref = "fresco" }