From 8d21ffda604544fae3e25bcbe7e2984dbef841eb Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Wed, 13 Aug 2025 19:37:32 +0200 Subject: [PATCH 1/4] Fix Dimensions window values on Android < 15 # Conflicts: # packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java --- .../ReactAndroid/api/ReactAndroid.api | 3 - .../ReactAndroid/build.gradle.kts | 1 + .../modules/deviceinfo/DeviceInfoModule.kt | 59 ++++++++++++++++++- .../react/uimanager/DisplayMetricsHolder.kt | 47 +-------------- .../react/views/text/TextLayoutManager.kt | 2 +- .../deviceinfo/DeviceInfoModuleTest.kt | 50 ++++++++++++++-- .../uimanager/DisplayMetricsHolderTest.kt | 43 -------------- .../facebook/react/uimanager/PixelUtilTest.kt | 6 -- .../react-native/gradle/libs.versions.toml | 2 + 9 files changed, 105 insertions(+), 108 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6dd9bd20a1e..4303163040e 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3284,13 +3284,10 @@ public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com public final class com/facebook/react/uimanager/DisplayMetricsHolder { public static final field INSTANCE Lcom/facebook/react/uimanager/DisplayMetricsHolder; - public static final fun getDisplayMetricsWritableMap (D)Lcom/facebook/react/bridge/WritableMap; public static final fun getScreenDisplayMetrics ()Landroid/util/DisplayMetrics; - public static final fun getWindowDisplayMetrics ()Landroid/util/DisplayMetrics; public static final fun initDisplayMetrics (Landroid/content/Context;)V public static final fun initDisplayMetricsIfNotInitialized (Landroid/content/Context;)V public static final fun setScreenDisplayMetrics (Landroid/util/DisplayMetrics;)V - public static final fun setWindowDisplayMetrics (Landroid/util/DisplayMetrics;)V } public final class com/facebook/react/uimanager/FloatUtil { 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..d8f04b2a7b0 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,55 @@ 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 + var width = bounds.width() + var height = bounds.height() + + // 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 (!isEdgeToEdgeFeatureFlagOn) { + ViewCompat.getRootWindowInsets(activity.window.decorView)?.let { + val insets = it.getInsets(WindowInsetsCompat.Type.systemBars()) + width -= (insets.left + insets.right) + height -= (insets.top + insets.bottom) + } + } + + windowDisplayMetrics.widthPixels = width + windowDisplayMetrics.heightPixels = height + 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 +111,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..7382c3650ae 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 @@ -14,8 +14,6 @@ import android.util.DisplayMetrics import android.view.WindowManager import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import com.facebook.react.bridge.WritableMap -import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.PixelUtil.pxToDp /** @@ -26,21 +24,8 @@ public object DisplayMetricsHolder { private const val INITIALIZATION_MISSING_MESSAGE = "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or initDisplayMetrics" - @JvmStatic private var windowDisplayMetrics: DisplayMetrics? = null @JvmStatic private var screenDisplayMetrics: DisplayMetrics? = null - /** The metrics of the window associated to the Context used to initialize ReactNative */ - @JvmStatic - public fun getWindowDisplayMetrics(): DisplayMetrics { - checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } - return windowDisplayMetrics as DisplayMetrics - } - - @JvmStatic - public fun setWindowDisplayMetrics(displayMetrics: DisplayMetrics?) { - windowDisplayMetrics = displayMetrics - } - /** Screen metrics returns the metrics of the default screen on the device. */ @JvmStatic public fun getScreenDisplayMetrics(): DisplayMetrics { @@ -62,11 +47,10 @@ public object DisplayMetricsHolder { } @JvmStatic - @SuppressLint("DeprecatedMethod") // for Andriod Lint + @SuppressLint("DeprecatedMethod") // for Android Lint @Suppress("DEPRECATION") // for Kotlin compiler public fun initDisplayMetrics(context: Context) { val displayMetrics = context.resources.displayMetrics - windowDisplayMetrics = displayMetrics val screenDisplayMetrics = DisplayMetrics() screenDisplayMetrics.setTo(displayMetrics) try { @@ -84,35 +68,6 @@ public object DisplayMetricsHolder { DisplayMetricsHolder.screenDisplayMetrics = screenDisplayMetrics } - @JvmStatic - public fun getDisplayMetricsWritableMap(fontScale: Double): WritableMap { - checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } - checkNotNull(screenDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } - - return WritableNativeMap().apply { - putMap( - "windowPhysicalPixels", - getPhysicalPixelsWritableMap(windowDisplayMetrics as DisplayMetrics, fontScale), - ) - putMap( - "screenPhysicalPixels", - getPhysicalPixelsWritableMap(screenDisplayMetrics as DisplayMetrics, fontScale), - ) - } - } - - private fun getPhysicalPixelsWritableMap( - displayMetrics: DisplayMetrics, - fontScale: Double, - ): WritableMap = - WritableNativeMap().apply { - putInt("width", displayMetrics.widthPixels) - putInt("height", displayMetrics.heightPixels) - putDouble("scale", displayMetrics.density.toDouble()) - putDouble("fontScale", fontScale) - putDouble("densityDpi", displayMetrics.densityDpi.toDouble()) - } - internal fun getStatusBarHeightPx(activity: Activity?): Int { val windowInsets = activity?.window?.decorView?.let(ViewCompat::getRootWindowInsets) ?: return 0 return windowInsets diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index f2c88294f65..c1d7a695203 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1333,7 +1333,7 @@ internal object TextLayoutManager { return FontMetricsUtil.getFontMetrics( layout.text, layout, - DisplayMetricsHolder.getWindowDisplayMetrics(), + DisplayMetricsHolder.getScreenDisplayMetrics(), ) } 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/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt index 6b2137c1ba5..e6e0858c45f 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt @@ -17,7 +17,6 @@ import android.view.View import android.view.Window import android.view.WindowInsets import androidx.annotation.RequiresApi -import com.facebook.react.bridge.WritableMap import com.facebook.testutils.shadows.ShadowNativeLoader import com.facebook.testutils.shadows.ShadowNativeMap import com.facebook.testutils.shadows.ShadowReadableNativeMap @@ -55,33 +54,19 @@ class DisplayMetricsHolderTest { fun setUp() { context = RuntimeEnvironment.getApplication() displayMetrics = context.resources.displayMetrics - DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @After fun tearDown() { - DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } - @Test(expected = IllegalStateException::class) - fun getWindowDisplayMetrics_failsIfDisplayMetricsIsNotInitialized() { - DisplayMetricsHolder.getWindowDisplayMetrics() - } - @Test(expected = IllegalStateException::class) fun getScreenDisplayMetrics_failsIfDisplayMetricsIsNotInitialized() { DisplayMetricsHolder.getScreenDisplayMetrics() } - @Test - fun setAndGetWindowDisplayMetrics_returnsSetValue() { - DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) - val result = DisplayMetricsHolder.getWindowDisplayMetrics() - assertThat(result).isEqualTo(displayMetrics) - } - @Test fun setAndGetScreenDisplayMetrics_returnsSetValue() { DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) @@ -92,46 +77,19 @@ class DisplayMetricsHolderTest { @Test fun initDisplayMetrics_setsMetrics() { DisplayMetricsHolder.initDisplayMetrics(context) - assertThat(DisplayMetricsHolder.getWindowDisplayMetrics()).isNotNull() assertThat(DisplayMetricsHolder.getScreenDisplayMetrics()).isNotNull() } @Test fun initDisplayMetricsIfNotInitialized_onlyInitializesOnce() { DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) - val firstWindow = DisplayMetricsHolder.getWindowDisplayMetrics() val firstScreen = DisplayMetricsHolder.getScreenDisplayMetrics() // Should not reinitialize DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) - val secondWindow = DisplayMetricsHolder.getWindowDisplayMetrics() val secondScreen = DisplayMetricsHolder.getScreenDisplayMetrics() - assertThat(secondWindow).isEqualTo(firstWindow) assertThat(secondScreen).isEqualTo(firstScreen) } - @Test(expected = IllegalStateException::class) - fun getDisplayMetricsWritableMap_failsIfNotInitialized() { - DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) - } - - @Test - fun getDisplayMetricsWritableMap_returnsCorrectMap() { - // Use the official initialization method to ensure both metrics are set - DisplayMetricsHolder.initDisplayMetrics(context) - val map: WritableMap = DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) - 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() - } - @Test @RequiresApi(30) fun getEncodedScreenSizeWithoutVerticalInsets_returnsEncodedValue() { @@ -204,7 +162,6 @@ class DisplayMetricsHolderTest { DisplayMetricsHolder.initDisplayMetrics(mockContext) // Metrics should still be set from resource display metrics - assertThat(DisplayMetricsHolder.getWindowDisplayMetrics()).isNotNull() assertThat(DisplayMetricsHolder.getScreenDisplayMetrics()).isNotNull() } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt index 3c3aa3f15d9..46ae613601d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt @@ -37,13 +37,11 @@ class PixelUtilTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() - DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @After fun tearDown() { - DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @@ -57,7 +55,6 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH - DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that toPixelFromSP respects fontScale < 1.0 @@ -80,7 +77,6 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH - DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that toPixelFromSP respects fontScale > 1.0 @@ -103,7 +99,6 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH - DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that maxFontScale limits the scaling @@ -128,7 +123,6 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH - DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that maxFontScale doesn't prevent scaling down 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" } From 8c427b6e6161f5e47d33f3bcde2e5fc8ad7d6310 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Thu, 23 Apr 2026 11:13:02 +0200 Subject: [PATCH 2/4] Rollback DisplayMetricsHolder changes (non breaking period) --- .../ReactAndroid/api/ReactAndroid.api | 3 ++ .../react/uimanager/DisplayMetricsHolder.kt | 47 ++++++++++++++++++- .../react/views/text/TextLayoutManager.kt | 2 +- .../uimanager/DisplayMetricsHolderTest.kt | 43 +++++++++++++++++ .../facebook/react/uimanager/PixelUtilTest.kt | 6 +++ 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 4303163040e..6dd9bd20a1e 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3284,10 +3284,13 @@ public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com public final class com/facebook/react/uimanager/DisplayMetricsHolder { public static final field INSTANCE Lcom/facebook/react/uimanager/DisplayMetricsHolder; + public static final fun getDisplayMetricsWritableMap (D)Lcom/facebook/react/bridge/WritableMap; public static final fun getScreenDisplayMetrics ()Landroid/util/DisplayMetrics; + public static final fun getWindowDisplayMetrics ()Landroid/util/DisplayMetrics; public static final fun initDisplayMetrics (Landroid/content/Context;)V public static final fun initDisplayMetricsIfNotInitialized (Landroid/content/Context;)V public static final fun setScreenDisplayMetrics (Landroid/util/DisplayMetrics;)V + public static final fun setWindowDisplayMetrics (Landroid/util/DisplayMetrics;)V } public final class com/facebook/react/uimanager/FloatUtil { 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 7382c3650ae..b901406e398 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 @@ -14,6 +14,8 @@ import android.util.DisplayMetrics import android.view.WindowManager import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.PixelUtil.pxToDp /** @@ -24,8 +26,21 @@ public object DisplayMetricsHolder { private const val INITIALIZATION_MISSING_MESSAGE = "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or initDisplayMetrics" + @JvmStatic private var windowDisplayMetrics: DisplayMetrics? = null @JvmStatic private var screenDisplayMetrics: DisplayMetrics? = null + /** The metrics of the window associated to the Context used to initialize ReactNative */ + @JvmStatic + public fun getWindowDisplayMetrics(): DisplayMetrics { + checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } + return windowDisplayMetrics as DisplayMetrics + } + + @JvmStatic + public fun setWindowDisplayMetrics(displayMetrics: DisplayMetrics?) { + windowDisplayMetrics = displayMetrics + } + /** Screen metrics returns the metrics of the default screen on the device. */ @JvmStatic public fun getScreenDisplayMetrics(): DisplayMetrics { @@ -47,10 +62,11 @@ public object DisplayMetricsHolder { } @JvmStatic - @SuppressLint("DeprecatedMethod") // for Android Lint + @SuppressLint("DeprecatedMethod") // for Andriod Lint @Suppress("DEPRECATION") // for Kotlin compiler public fun initDisplayMetrics(context: Context) { val displayMetrics = context.resources.displayMetrics + windowDisplayMetrics = displayMetrics val screenDisplayMetrics = DisplayMetrics() screenDisplayMetrics.setTo(displayMetrics) try { @@ -68,6 +84,35 @@ public object DisplayMetricsHolder { DisplayMetricsHolder.screenDisplayMetrics = screenDisplayMetrics } + @JvmStatic + public fun getDisplayMetricsWritableMap(fontScale: Double): WritableMap { + checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } + checkNotNull(screenDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE } + + return WritableNativeMap().apply { + putMap( + "windowPhysicalPixels", + getPhysicalPixelsWritableMap(windowDisplayMetrics as DisplayMetrics, fontScale), + ) + putMap( + "screenPhysicalPixels", + getPhysicalPixelsWritableMap(screenDisplayMetrics as DisplayMetrics, fontScale), + ) + } + } + + private fun getPhysicalPixelsWritableMap( + displayMetrics: DisplayMetrics, + fontScale: Double, + ): WritableMap = + WritableNativeMap().apply { + putInt("width", displayMetrics.widthPixels) + putInt("height", displayMetrics.heightPixels) + putDouble("scale", displayMetrics.density.toDouble()) + putDouble("fontScale", fontScale) + putDouble("densityDpi", displayMetrics.densityDpi.toDouble()) + } + internal fun getStatusBarHeightPx(activity: Activity?): Int { val windowInsets = activity?.window?.decorView?.let(ViewCompat::getRootWindowInsets) ?: return 0 return windowInsets diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index c1d7a695203..f2c88294f65 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1333,7 +1333,7 @@ internal object TextLayoutManager { return FontMetricsUtil.getFontMetrics( layout.text, layout, - DisplayMetricsHolder.getScreenDisplayMetrics(), + DisplayMetricsHolder.getWindowDisplayMetrics(), ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt index e6e0858c45f..6b2137c1ba5 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/DisplayMetricsHolderTest.kt @@ -17,6 +17,7 @@ import android.view.View import android.view.Window import android.view.WindowInsets import androidx.annotation.RequiresApi +import com.facebook.react.bridge.WritableMap import com.facebook.testutils.shadows.ShadowNativeLoader import com.facebook.testutils.shadows.ShadowNativeMap import com.facebook.testutils.shadows.ShadowReadableNativeMap @@ -54,19 +55,33 @@ class DisplayMetricsHolderTest { fun setUp() { context = RuntimeEnvironment.getApplication() displayMetrics = context.resources.displayMetrics + DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @After fun tearDown() { + DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } + @Test(expected = IllegalStateException::class) + fun getWindowDisplayMetrics_failsIfDisplayMetricsIsNotInitialized() { + DisplayMetricsHolder.getWindowDisplayMetrics() + } + @Test(expected = IllegalStateException::class) fun getScreenDisplayMetrics_failsIfDisplayMetricsIsNotInitialized() { DisplayMetricsHolder.getScreenDisplayMetrics() } + @Test + fun setAndGetWindowDisplayMetrics_returnsSetValue() { + DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) + val result = DisplayMetricsHolder.getWindowDisplayMetrics() + assertThat(result).isEqualTo(displayMetrics) + } + @Test fun setAndGetScreenDisplayMetrics_returnsSetValue() { DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) @@ -77,19 +92,46 @@ class DisplayMetricsHolderTest { @Test fun initDisplayMetrics_setsMetrics() { DisplayMetricsHolder.initDisplayMetrics(context) + assertThat(DisplayMetricsHolder.getWindowDisplayMetrics()).isNotNull() assertThat(DisplayMetricsHolder.getScreenDisplayMetrics()).isNotNull() } @Test fun initDisplayMetricsIfNotInitialized_onlyInitializesOnce() { DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) + val firstWindow = DisplayMetricsHolder.getWindowDisplayMetrics() val firstScreen = DisplayMetricsHolder.getScreenDisplayMetrics() // Should not reinitialize DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) + val secondWindow = DisplayMetricsHolder.getWindowDisplayMetrics() val secondScreen = DisplayMetricsHolder.getScreenDisplayMetrics() + assertThat(secondWindow).isEqualTo(firstWindow) assertThat(secondScreen).isEqualTo(firstScreen) } + @Test(expected = IllegalStateException::class) + fun getDisplayMetricsWritableMap_failsIfNotInitialized() { + DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) + } + + @Test + fun getDisplayMetricsWritableMap_returnsCorrectMap() { + // Use the official initialization method to ensure both metrics are set + DisplayMetricsHolder.initDisplayMetrics(context) + val map: WritableMap = DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) + 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() + } + @Test @RequiresApi(30) fun getEncodedScreenSizeWithoutVerticalInsets_returnsEncodedValue() { @@ -162,6 +204,7 @@ class DisplayMetricsHolderTest { DisplayMetricsHolder.initDisplayMetrics(mockContext) // Metrics should still be set from resource display metrics + assertThat(DisplayMetricsHolder.getWindowDisplayMetrics()).isNotNull() assertThat(DisplayMetricsHolder.getScreenDisplayMetrics()).isNotNull() } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt index 46ae613601d..3c3aa3f15d9 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt @@ -37,11 +37,13 @@ class PixelUtilTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() + DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @After fun tearDown() { + DisplayMetricsHolder.setWindowDisplayMetrics(null) DisplayMetricsHolder.setScreenDisplayMetrics(null) } @@ -55,6 +57,7 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH + DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that toPixelFromSP respects fontScale < 1.0 @@ -77,6 +80,7 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH + DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that toPixelFromSP respects fontScale > 1.0 @@ -99,6 +103,7 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH + DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that maxFontScale limits the scaling @@ -123,6 +128,7 @@ class PixelUtilTest { displayMetrics.heightPixels = 1920 displayMetrics.densityDpi = DisplayMetrics.DENSITY_XXHIGH + DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics) DisplayMetricsHolder.setScreenDisplayMetrics(displayMetrics) // Test that maxFontScale doesn't prevent scaling down From 946ca3cb3907daff0e05e68204956a747196b6c4 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Fri, 24 Apr 2026 11:58:46 +0200 Subject: [PATCH 3/4] Add comment --- .../java/com/facebook/react/uimanager/DisplayMetricsHolder.kt | 3 +++ 1 file changed, 3 insertions(+) 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 } From 06b911f44ba0c93483830d470363c60be555ac43 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Sat, 25 Apr 2026 13:34:53 +0200 Subject: [PATCH 4/4] Fix Dimensions window values when root window insets are unavailable --- .../modules/deviceinfo/DeviceInfoModule.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) 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 d8f04b2a7b0..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 @@ -42,21 +42,22 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : val activity = reactApplicationContext.currentActivity ?: return windowDisplayMetrics val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds - var width = bounds.width() - var height = bounds.height() - // 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 (!isEdgeToEdgeFeatureFlagOn) { + 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()) - width -= (insets.left + insets.right) - height -= (insets.top + insets.bottom) + windowDisplayMetrics.widthPixels = bounds.width() - (insets.left + insets.right) + windowDisplayMetrics.heightPixels = bounds.height() - (insets.top + insets.bottom) } } - windowDisplayMetrics.widthPixels = width - windowDisplayMetrics.heightPixels = height return windowDisplayMetrics }