From b0e881637302b69bdb8fd3a703405035b99f26e1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 19:31:13 +0300 Subject: [PATCH] Stop non-finite elevation and scale values from crashing Android 9 and later From API 28, View.setElevation, setScaleX and setScaleY pass their value through sanitizeFloatPropertyValue, which throws IllegalArgumentException for a NaN or an infinite value in an app targeting API 28 or later. BaseViewManager.setTransformProperty already reads the values it hands those setters through BaseViewManager.sanitizeFloatPropertyValue for this throw, but BaseViewManager.setElevation, the deprecated scaleX and scaleY setters, and ReactDrawerLayoutManager.setElevation, which hands DrawerLayout.setDrawerElevation its value without super, did not. Move the helper to com.facebook.react.uimanager.internal.FloatPropertySanitizer, which the drawer can reach and the public API leaves out, and read those values through it, the elevations after the dp-to-pixel conversion, so an infinite elevation is not multiplied back to infinity on a density above 1 and a finite one that overflows in pixels is caught too. A NaN takes 0 and an infinity the largest finite value of its sign: the framework's own values for an app that does not opt into the throw. --- .../react/uimanager/BaseViewManager.java | 31 ++------ .../internal/FloatPropertySanitizer.kt | 21 ++++++ .../views/drawer/ReactDrawerLayoutManager.kt | 3 +- .../react/uimanager/BaseViewManagerTest.kt | 73 +++++++++++++++++++ .../drawer/ReactDrawerLayoutManagerTest.kt | 70 ++++++++++++++++++ 5 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index f4c8744f5ba4..b2e8fec78339 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -7,6 +7,8 @@ package com.facebook.react.uimanager; +import static com.facebook.react.uimanager.internal.FloatPropertySanitizer.sanitizeFloatPropertyValue; + import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; @@ -298,7 +300,7 @@ public void setOpacity(@NonNull T view, float opacity) { @ReactProp(name = ViewProps.ELEVATION) public void setElevation(@NonNull T view, float elevation) { - float px = PixelUtil.toPixelFromDIP(elevation); + float px = sanitizeFloatPropertyValue(PixelUtil.toPixelFromDIP(elevation)); boolean wasElevated = view.getElevation() > 0f; ViewCompat.setElevation(view, px); if (wasElevated != (px > 0f)) { @@ -553,13 +555,13 @@ public void setRotation(@NonNull T view, float rotation) { @Deprecated @ReactProp(name = ViewProps.SCALE_X, defaultFloat = 1f) public void setScaleX(@NonNull T view, float scaleX) { - view.setScaleX(scaleX); + view.setScaleX(sanitizeFloatPropertyValue(scaleX)); } @Deprecated @ReactProp(name = ViewProps.SCALE_Y, defaultFloat = 1f) public void setScaleY(@NonNull T view, float scaleY) { - view.setScaleY(scaleY); + view.setScaleY(sanitizeFloatPropertyValue(scaleY)); } @Deprecated @@ -719,29 +721,6 @@ private static void clearSkewAnimationMatrixIfActive(@NonNull T view.setTag(R.id.skew_animation_matrix, null); } - /** - * Prior to Android P things like setScaleX() allowed passing float values that were bogus such as - * Float.NaN. If the app is targeting Android P or later then passing these values will result in - * an exception being thrown. Since JS might still send Float.NaN, we want to keep the code - * backward compatible and continue using the fallback value if an invalid float is passed. - */ - private static float sanitizeFloatPropertyValue(float value) { - if (value >= -Float.MAX_VALUE && value <= Float.MAX_VALUE) { - return value; - } - if (value < -Float.MAX_VALUE || value == Float.NEGATIVE_INFINITY) { - return -Float.MAX_VALUE; - } - if (value > Float.MAX_VALUE || value == Float.POSITIVE_INFINITY) { - return Float.MAX_VALUE; - } - if (Float.isNaN(value)) { - return 0; - } - // Shouldn't be possible to reach this point. - throw new IllegalStateException("Invalid float property value: " + value); - } - protected void updateViewAccessibility(@NonNull T view) { ReactAccessibilityDelegate.setDelegate( view, view.isFocusable(), view.getImportantForAccessibility()); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt new file mode 100644 index 000000000000..5a6828729e59 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt @@ -0,0 +1,21 @@ +/* + * 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.internal + +/** + * Prior to Android P things like setScaleX() allowed passing float values that were bogus such as + * Float.NaN. If the app is targeting Android P or later then passing these values will result in an + * exception being thrown. Since JS might still send Float.NaN, we want to keep the code backward + * compatible and continue using the fallback value if an invalid float is passed. + */ +public object FloatPropertySanitizer { + + @JvmStatic + public fun sanitizeFloatPropertyValue(value: Float): Float = + if (value.isNaN()) 0f else value.coerceIn(-Float.MAX_VALUE, Float.MAX_VALUE) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt index 87c679d3b544..b795078302c1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt @@ -25,6 +25,7 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.internal.FloatPropertySanitizer import com.facebook.react.viewmanagers.AndroidDrawerLayoutManagerDelegate import com.facebook.react.viewmanagers.AndroidDrawerLayoutManagerInterface import com.facebook.react.views.drawer.events.DrawerClosedEvent @@ -142,7 +143,7 @@ public class ReactDrawerLayoutManager : public override fun setStatusBarBackgroundColor(view: ReactDrawerLayout, value: Int?): Unit = Unit public override fun setElevation(view: ReactDrawerLayout, elevation: Float) { - view.drawerElevation = elevation.dpToPx() + view.drawerElevation = FloatPropertySanitizer.sanitizeFloatPropertyValue(elevation.dpToPx()) } public override fun needsCustomLayoutForChildren(): Boolean { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 00d1ea51ea47..8e089c9d3dec 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -9,6 +9,7 @@ package com.facebook.react.uimanager +import android.util.DisplayMetrics import android.view.View import android.view.View.OnFocusChangeListener import android.view.accessibility.AccessibilityNodeInfo @@ -321,6 +322,78 @@ class BaseViewManagerTest { Assertions.assertThat(recycled.getTag(R.id.original_focusability)).isNull() } + @Test + fun testNaNElevationLeavesTheViewUnelevated() { + val unelevated = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.NaN)) + + Assertions.assertThat(unelevated.elevation).isEqualTo(0f) + } + + @Test + fun testInfiniteElevationTakesTheLargestFiniteElevation() { + val raised = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.POSITIVE_INFINITY)) + + Assertions.assertThat(raised.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNegativeInfiniteElevationTakesTheLowestFiniteElevation() { + val lowered = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.NEGATIVE_INFINITY)) + + Assertions.assertThat(lowered.elevation).isEqualTo(-Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatTakesTheLargestFiniteElevation() { + val raised = createView(JavaOnlyMap.of(ViewProps.ELEVATION, 1e39)) + + Assertions.assertThat(raised.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatInPixelsTakesTheLargestFiniteElevation() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 3f }) + + viewManager.setElevation(view, Float.MAX_VALUE) + + Assertions.assertThat(view.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNaNScaleTakesTheFrameworksFallback() { + val scaled = + createView(JavaOnlyMap.of(ViewProps.SCALE_X, Double.NaN, ViewProps.SCALE_Y, Double.NaN)) + + Assertions.assertThat(scaled.scaleX).isEqualTo(0f) + Assertions.assertThat(scaled.scaleY).isEqualTo(0f) + } + + @Test + fun testInfiniteScaleTakesTheLargestFiniteScale() { + val scaled = + createView( + JavaOnlyMap.of( + ViewProps.SCALE_X, + Double.POSITIVE_INFINITY, + ViewProps.SCALE_Y, + Double.POSITIVE_INFINITY, + ) + ) + + Assertions.assertThat(scaled.scaleX).isEqualTo(Float.MAX_VALUE) + Assertions.assertThat(scaled.scaleY).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNaNElevationAfterAFiniteOneLeavesTheViewUnelevated() { + viewManager.setElevation(view, 4f) + Assertions.assertThat(view.elevation).isEqualTo(PixelUtil.toPixelFromDIP(4f)) + + viewManager.setElevation(view, Float.NaN) + + Assertions.assertThat(view.elevation).isEqualTo(0f) + } + private fun recycleView( previousProps: JavaOnlyMap, beforeDrop: (ReactViewGroup) -> Unit = {}, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt new file mode 100644 index 000000000000..c014d1a05f9b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt @@ -0,0 +1,70 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.drawer + +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.drawerlayout.widget.DrawerLayout +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactDrawerLayoutManagerTest { + private lateinit var drawerLayout: ReactDrawerLayout + + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + drawerLayout = ReactDrawerLayout(ThemedReactContext(context, context, null, -1)) + drawerLayout.addView( + View(drawerLayout.context), + DrawerLayout.LayoutParams(100, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START), + ) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun testNaNElevationLeavesTheDrawerUnelevated() { + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.NaN) + + assertThat(drawerLayout.drawerElevation).isEqualTo(0f) + } + + @Test + fun testInfiniteElevationTakesTheLargestFiniteElevation() { + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.POSITIVE_INFINITY) + + assertThat(drawerLayout.drawerElevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatInPixelsTakesTheLargestFiniteElevation() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 3f }) + + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.MAX_VALUE) + + assertThat(drawerLayout.drawerElevation).isEqualTo(Float.MAX_VALUE) + } +}