diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt index 7de6c64bb861..359061130cc5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ColorPropConverter.kt @@ -23,6 +23,7 @@ public object ColorPropConverter { private fun supportWideGamut(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O private const val JSON_KEY = "resource_paths" + private const val FALLBACK_KEY = "fallback" private const val PREFIX_RESOURCE = "@" private const val PREFIX_ATTR = "?" private const val PACKAGE_DELIMITER = ":" @@ -65,15 +66,18 @@ public object ColorPropConverter { } } + val fallback = resolveFallback(value) + if (fallback != null) { + return fallback + } + val attemptedPaths = (0 until resourcePaths.size()).map { resourcePaths.getString(it) } FLog.w( ReactConstants.TAG, "ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}", ) - throw JSApplicationCausedNativeException( - "ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource.", - ) + return Color.TRANSPARENT } throw JSApplicationCausedNativeException("ColorValue: the value must be a number or Object.") @@ -121,12 +125,21 @@ public object ColorPropConverter { } } + val fallback = resolveFallback(value) + if (supportWideGamut() && fallback != null) { + return Color.valueOf(fallback) + } + val attemptedPaths = (0 until resourcePaths.size()).map { resourcePaths.getString(it) } FLog.w( ReactConstants.TAG, "ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}", ) + if (supportWideGamut()) { + return Color.valueOf(Color.TRANSPARENT) + } + throw JSApplicationCausedNativeException( "ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource.", ) @@ -162,6 +175,14 @@ public object ColorPropConverter { } } + private fun resolveFallback(value: ReadableMap): Int? { + if (!value.hasKey(FALLBACK_KEY)) { + return null + } + val fallback = value.getString(FALLBACK_KEY) ?: return null + return RawColorParser.nativeParse(fallback) + } + @JvmStatic public fun resolveResourcePath(context: Context, resourcePath: String?): Int? { if (resourcePath.isNullOrEmpty()) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/RawColorParser.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/RawColorParser.kt new file mode 100644 index 000000000000..a4a7602345bc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/RawColorParser.kt @@ -0,0 +1,19 @@ +/* + * 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.bridge + +import com.facebook.proguard.annotations.DoNotStrip + +@DoNotStrip +internal object RawColorParser { + init { + ReactNativeJNISoLoader.staticInit() + } + + @DoNotStrip @JvmStatic external fun nativeParse(value: String): Int? +} diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt index ed1dd23fcd35..95ba57d49f30 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt @@ -64,6 +64,7 @@ add_library( MethodInvoker.cpp ModuleRegistryBuilder.cpp OnLoad.cpp + RawColorParser.cpp SafeReleaseJniRef.cpp ScrollEventHelper.cpp TouchesHelper.cpp @@ -83,6 +84,7 @@ target_link_libraries(reactnativejni logger react_cxxreact react_cxxstableapi + react_renderer_css react_renderer_runtimescheduler reactnativejni_common rrc_view diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp index 85160a4fd348..d8d5be2c15c6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad.cpp @@ -11,6 +11,7 @@ #include #include "InspectorNetworkRequestListener.h" +#include "RawColorParser.h" #include "ScrollEventHelper.h" #include "TouchesHelper.h" #include "TransformHelper.h" @@ -36,6 +37,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { FLAGS_minloglevel = 0; #endif InspectorNetworkRequestListener::registerNatives(); + RawColorParser::registerNatives(); ScrollEventHelper::registerNatives(); TouchesHelper::registerNatives(); TransformHelper::registerNatives(); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.cpp new file mode 100644 index 000000000000..18fbbfefd598 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.cpp @@ -0,0 +1,38 @@ +/* + * 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. + */ + +#include "RawColorParser.h" + +#include +#include +#include + +namespace facebook::react { + +namespace { + +jni::local_ref parse( + jni::alias_ref /*unused*/, + std::string value) { + auto cssColor = parseCSSProperty(value); + if (std::holds_alternative(cssColor)) { + auto c = std::get(cssColor); + return jni::JInteger::valueOf( + hostPlatformColorFromRGBA(c.r, c.g, c.b, c.a)); + } + return nullptr; +} + +} // namespace + +void RawColorParser::registerNatives() { + javaClassLocal()->registerNatives({ + makeNativeMethod("nativeParse", parse), + }); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.h b/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.h new file mode 100644 index 000000000000..045dad4cc231 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/RawColorParser.h @@ -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. + */ + +#pragma once + +#include + +namespace facebook::react { + +class RawColorParser : public jni::JavaClass { + public: + static auto constexpr *kJavaDescriptor = "Lcom/facebook/react/bridge/RawColorParser;"; + + static void registerNatives(); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ColorPropConverterTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ColorPropConverterTest.kt index 08a8baac391d..135dc3f7b370 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ColorPropConverterTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ColorPropConverterTest.kt @@ -8,16 +8,21 @@ package com.facebook.react.bridge import android.content.Context +import android.graphics.Color import android.view.ContextThemeWrapper +import com.facebook.testutils.shadows.ShadowRawColorParser +import com.facebook.testutils.shadows.ShadowSoLoader import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config /** Tests for [ColorPropConverter] */ @RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowRawColorParser::class]) class ColorPropConverterTest { private lateinit var context: Context @@ -45,6 +50,58 @@ class ColorPropConverterTest { assertThat(result).isEqualTo(themeColor(android.R.attr.textColorPrimary)) } + @Test + fun unresolvedPathUsesFallback() { + val result = ColorPropConverter.getColor(platformColor("@color/missing", "#ff0000"), context) + + assertThat(result).isEqualTo(Color.RED) + } + + @Test + fun unresolvedPathUsesFallbackOverDefault() { + val result = ColorPropConverter.getColor(platformColor("@color/missing", "#ff0000"), context, 0) + + assertThat(result).isEqualTo(Color.RED) + } + + @Test + fun unresolvedPathUsesTransparentFallback() { + val result = ColorPropConverter.getColor(platformColor("?attr/missing", "transparent"), context) + + assertThat(result).isEqualTo(Color.TRANSPARENT) + } + + @Test + fun resolvedPathIgnoresFallback() { + val result = + ColorPropConverter.getColor(platformColor("?android:attr/colorAccent", "#ff0000"), context) + + assertThat(result).isEqualTo(themeColor(android.R.attr.colorAccent)) + } + + @Test + fun unresolvedPathWithoutFallbackIsTransparent() { + val result = ColorPropConverter.getColor(platformColor("@color/missing"), context) + + assertThat(result).isEqualTo(Color.TRANSPARENT) + } + + @Test + fun unresolvedPathWithInvalidFallbackIsTransparent() { + val result = + ColorPropConverter.getColor(platformColor("@color/missing", "not-a-color"), context) + + assertThat(result).isEqualTo(Color.TRANSPARENT) + } + + private fun platformColor(path: String, fallback: String? = null): ReadableMap { + val map = JavaOnlyMap.of("resource_paths", JavaOnlyArray.of(path)) + if (fallback != null) { + map.putString("fallback", fallback) + } + return map + } + private fun themeColor(attr: Int): Int { val typedArray = context.obtainStyledAttributes(intArrayOf(attr)) val color = typedArray.getColor(0, 0) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowRawColorParser.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowRawColorParser.kt new file mode 100644 index 000000000000..7c67462d7adb --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowRawColorParser.kt @@ -0,0 +1,28 @@ +/* + * 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.testutils.shadows + +import android.graphics.Color +import com.facebook.react.bridge.RawColorParser +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(RawColorParser::class) +class ShadowRawColorParser { + companion object { + /** Stands in for the native CSS color parser with a fixed lookup table. */ + @JvmStatic + @Implementation + fun nativeParse(value: String): Int? = + when (value) { + "#ff0000" -> Color.RED + "transparent" -> Color.TRANSPARENT + else -> null + } + } +} diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 912089515add..9437384356a0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -2128,6 +2128,11 @@ class facebook::react::PullToRefreshViewProps : public facebook::react::HostPlat public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::RawColorParser : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::RawProps { public RawProps(); public RawProps(const facebook::react::RawProps& other) noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 2567b4e72da9..be64aa5e11c0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -3275,6 +3275,11 @@ class facebook::react::PullToRefreshViewProps : public facebook::react::HostPlat public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::RawColorParser : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::RawProps { public RawProps(); public RawProps(const facebook::react::RawProps& other) noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 34b1da7689bb..7abee95bf5aa 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -2127,6 +2127,11 @@ class facebook::react::PullToRefreshViewProps : public facebook::react::HostPlat public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::RawColorParser : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::RawProps { public RawProps(); public RawProps(const facebook::react::RawProps& other) noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 34b1da7689bb..7abee95bf5aa 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -2127,6 +2127,11 @@ class facebook::react::PullToRefreshViewProps : public facebook::react::HostPlat public virtual folly::dynamic getDiffProps(const facebook::react::Props* prevProps) const override; } +class facebook::react::RawColorParser : public facebook::jni::JavaClass { + public static constexpr auto * kJavaDescriptor; + public static void registerNatives(); +} + class facebook::react::RawProps { public RawProps(); public RawProps(const facebook::react::RawProps& other) noexcept;