Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/react-native/React/Base/RCTConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,74 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace
return RCTGetDefaultColorSpace();
}

// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as
// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as
// a PlatformColor fallback when every semantic name misses. (On the iOS New
// Architecture the fallback is parsed by the shared CSS color parser, which also
// accepts rgb()/rgba() and named colors; this legacy path handles the common hex
// forms.)
static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString)
{
if (![colorString isKindOfClass:[NSString class]]) {
return nil;
}
NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (![hex hasPrefix:@"#"]) {
return nil;
}
hex = [hex substringFromIndex:1];
NSUInteger length = hex.length;
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
if (length == 3 || length == 4) {
NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2];
for (NSUInteger i = 0; i < length; i++) {
unichar c = [hex characterAtIndex:i];
[expanded appendFormat:@"%C%C", c, c];
}
hex = expanded;
length = hex.length;
}
if (length != 6 && length != 8) {
return nil;
}
// Initialized once: the inverted hex-digit set is immutable, so there is no
// need to reallocate it on every fallback parse.
static NSCharacterSet *const nonHex =
[[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) {
return nil;
}
unsigned int value = 0;
if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) {
return nil;
}
CGFloat r = NAN;
CGFloat g = NAN;
CGFloat b = NAN;
CGFloat a = NAN;
if (length == 6) {
r = ((value >> 16) & 0xFF) / 255.0;
g = ((value >> 8) & 0xFF) / 255.0;
b = (value & 0xFF) / 255.0;
a = 1.0;
} else {
r = ((value >> 24) & 0xFF) / 255.0;
g = ((value >> 16) & 0xFF) / 255.0;
b = ((value >> 8) & 0xFF) / 255.0;
a = (value & 0xFF) / 255.0;
}
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}

static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary)
{
id fallback = [dictionary objectForKey:@"fallback"];
if ([fallback isKindOfClass:[NSString class]]) {
return RCTFallbackUIColorFromHexString(fallback);
}
return nil;
}

+ (UIColor *)UIColor:(id)json
{
if (!json) {
Expand Down Expand Up @@ -983,6 +1051,10 @@ + (UIColor *)UIColor:(id)json
}
color = RCTColorFromSemanticColorName(semanticName);
if (color == nil) {
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]);
Expand All @@ -999,6 +1071,10 @@ + (UIColor *)UIColor:(id)json
return color;
}
}
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. None of the names in the array were one of the following values: "
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -2252,7 +2252,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid
public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun findNextFocusableElement (III)Ljava/lang/Integer;
public fun getColor (I[Ljava/lang/String;)I
public fun getColor (I[Ljava/lang/String;)Ljava/lang/Integer;
public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher;
public fun getPerformanceCounters ()Ljava/util/Map;
public fun getRelativeAncestorList (II)[I
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

package com.facebook.react.bridge

import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.Color
Expand All @@ -15,14 +16,17 @@ import android.os.Build
import android.util.TypedValue
import androidx.annotation.ColorLong
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.toColorInt
import com.facebook.common.logging.FLog
import com.facebook.react.common.ReactConstants
import kotlin.math.roundToInt

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 = ":"
Expand Down Expand Up @@ -71,6 +75,17 @@ public object ColorPropConverter {
"ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}",
)

val fallback = parseFallbackColor(value)
if (fallback != null) {
return fallback
}
// A fallback was provided but could not be parsed: degrade to transparent
// instead of crashing, matching iOS and the New Architecture path.
if (value.hasKey(FALLBACK_KEY)) {
FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.")
return 0
}

throw JSApplicationCausedNativeException(
"ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource."
)
Expand Down Expand Up @@ -127,6 +142,22 @@ public object ColorPropConverter {
"ColorValue: Failed to resolve resource paths: ${attemptedPaths.joinToString(", ")}",
)

// Color.valueOf is API 26+, the same requirement as supportWideGamut(), so
// gate the whole fallback block on it: parsed fallbacks return directly, and
// a present-but-unparseable fallback degrades to transparent instead of
// crashing (matching iOS and the New Architecture path). On older API levels
// this method throws and the caller falls back to getColorInteger.
if (supportWideGamut()) {
val fallback = parseFallbackColor(value)
if (fallback != null) {
return Color.valueOf(fallback)
}
if (value.hasKey(FALLBACK_KEY)) {
FLog.w(ReactConstants.TAG, "ColorValue: Unparseable fallback color; using transparent.")
return Color.valueOf(0)
}
}

throw JSApplicationCausedNativeException(
"ColorValue: None of the paths in the `$JSON_KEY` array resolved to a color resource."
)
Expand Down Expand Up @@ -184,6 +215,9 @@ public object ColorPropConverter {
}
}

// getIdentifier is required here: the resource name arrives dynamically from JS
// at runtime, so a compile-time R.id reference is not possible.
@SuppressLint("DiscouragedApi")
private fun resolveResource(context: Context, resourcePath: String): Int {
val pathTokens = resourcePath.split(PACKAGE_DELIMITER)
var packageName = context.packageName
Expand All @@ -203,6 +237,9 @@ public object ColorPropConverter {
return ResourcesCompat.getColor(context.resources, resourceId, context.theme)
}

// getIdentifier is required here: the theme attribute name arrives dynamically
// from JS at runtime, so a compile-time R.attr reference is not possible.
@SuppressLint("DiscouragedApi")
@Throws(Resources.NotFoundException::class)
private fun resolveThemeAttribute(context: Context, resourcePath: String): Int {
val path = resourcePath.replace(ATTR_SEGMENT, "")
Expand Down Expand Up @@ -231,4 +268,105 @@ public object ColorPropConverter {

throw Resources.NotFoundException()
}

// Lazy fallback: a raw color string carried alongside `resource_paths` that is
// parsed only when every resource path fails to resolve, so an unresolved token
// degrades to a developer-provided color instead of crashing.
private fun parseFallbackColor(value: ReadableMap): Int? {
if (!value.hasKey(FALLBACK_KEY)) {
return null
}
val fallback = value.getString(FALLBACK_KEY)
if (fallback.isNullOrEmpty()) {
return null
}
return parseColorString(fallback)
}

private fun parseColorString(raw: String): Int? {
// CSS color syntax is case-insensitive, so normalize before dispatching:
// otherwise `RGB(...)`, `#ABC`, or `RED` would fall through to null here while
// the shared CSS parser on the Fabric path accepts them, diverging by
// platform.
val color = raw.trim().lowercase()
if (color.isEmpty()) {
return null
}
return when {
color.startsWith("rgb(") || color.startsWith("rgba(") -> parseRgbaColor(color)
// Hex is parsed manually so 8-digit hex is read as #RRGGBBAA (alpha last,
// matching CSS and iOS). Color.parseColor would treat 8-digit hex as
// #AARRGGBB (alpha first), disagreeing with the other platforms for the
// same fallback string.
color.startsWith("#") -> parseHexColor(color)
// Named colors (e.g. "red") are resolved natively, but only for purely
// alphabetic input. Gating on that keeps unsupported CSS forms (hsl() /
// hsla()) and other arbitrary strings from reaching toColorInt. An unknown
// name (e.g. a typo) still fails to parse; return null quietly rather than
// logging a warning on every resolve.
color.all { it.isLetter() } ->
try {
color.toColorInt()
} catch (e: IllegalArgumentException) {
null
}
else -> null
}
}

private fun parseHexColor(color: String): Int? {
val hex = color.substring(1)
if (hex.isEmpty() || hex.any { Character.digit(it, 16) < 0 }) {
return null
}
// Parse via substring rather than per-index access: indexing a String
// compiles to charAt, which lint flags as UTF-16-unsafe. It is harmless for
// ASCII hex, but substring keeps the code lint-clean and just as clear.
// nibble() expands one hex digit (0xN -> 0xNN); byte() reads two.
fun nibble(i: Int): Int = hex.substring(i, i + 1).toInt(16) * 17
fun byte(i: Int): Int = hex.substring(i, i + 2).toInt(16)
return when (hex.length) {
// #RGB — expand each nibble.
3 -> Color.argb(255, nibble(0), nibble(1), nibble(2))
// #RGBA — expand each nibble; alpha is the last nibble.
4 -> Color.argb(nibble(3), nibble(0), nibble(1), nibble(2))
6 -> Color.argb(255, byte(0), byte(2), byte(4))
// #RRGGBBAA — alpha is the LAST byte.
8 -> Color.argb(byte(6), byte(0), byte(2), byte(4))
else -> null
}
}

private fun parseRgbaColor(raw: String): Int? {
// `rgb(...)` takes exactly 3 components; `rgba(...)` takes exactly 4. Enforce
// this so lenient inputs (e.g. `rgb(r,g,b,a)` or `rgba(r,g,b)`) are rejected,
// matching the CSS parser used on the iOS Fabric path.
val hasAlphaFn = raw.startsWith("rgba(")
// Require the closing paren: substringBefore(')') would otherwise silently
// accept an unterminated input like `rgb(1,2,3`, disagreeing with the shared
// CSS parser used on the Fabric path.
if (!raw.endsWith(")")) {
return null
}
val inner = raw.substringAfter('(').substringBefore(')')
val parts = inner.split(',').map { it.trim() }
if (parts.size != (if (hasAlphaFn) 4 else 3)) {
return null
}
val r = parts[0].toIntOrNull() ?: return null
val g = parts[1].toIntOrNull() ?: return null
val b = parts[2].toIntOrNull() ?: return null
val a =
if (hasAlphaFn) {
(parts[3].toFloatOrNull() ?: return null).times(255).roundToInt()
} else {
255
}
return Color.argb(
a.coerceIn(0, 255),
r.coerceIn(0, 255),
g.coerceIn(0, 255),
b.coerceIn(0, 255),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -565,12 +565,12 @@ private NativeArray measureLines(
mTextEffectRegistry);
}

public int getColor(int surfaceId, String[] resourcePaths) {
public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) {
ThemedReactContext context =
mMountingManager.getSurfaceManagerEnforced(surfaceId, "getColor").getContext();
// Surface may have been stopped
if (context == null) {
return 0;
return null;
}

for (String resourcePath : resourcePaths) {
Expand All @@ -579,7 +579,10 @@ public int getColor(int surfaceId, String[] resourcePaths) {
return color;
}
}
return 0;
// No resource path resolved: return null (an explicit miss) rather than 0, so
// the native caller can distinguish an unresolved color from one that resolves
// to transparent black (ARGB 0).
return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ inline static void updateNativeDrawableProp(
}
folly::dynamic platformColorMap = folly::dynamic::object();
platformColorMap["resource_paths"] = resourcePaths;
if (nativeDrawableValue.ripple.colorFallback.has_value()) {
platformColorMap["fallback"] =
nativeDrawableValue.ripple.colorFallback.value();
}
nativeDrawableResult["color"] = platformColorMap;
} else {
nativeDrawableResult["color"] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,21 @@ struct NativeDrawable {
struct Ripple {
std::optional<SharedColor> color{};
std::optional<std::vector<std::string>> colorResourcePaths{};
std::optional<std::string> colorFallback{};
std::optional<Float> rippleRadius{};
bool borderless{false};
std::optional<Float> alpha{};

bool operator==(const Ripple &rhs) const
{
return std::tie(this->color, this->colorResourcePaths, this->borderless, this->rippleRadius, this->alpha) ==
std::tie(rhs.color, rhs.colorResourcePaths, rhs.borderless, rhs.rippleRadius, rhs.alpha);
return std::tie(
this->color,
this->colorResourcePaths,
this->colorFallback,
this->borderless,
this->rippleRadius,
this->alpha) ==
std::tie(rhs.color, rhs.colorResourcePaths, rhs.colorFallback, rhs.borderless, rhs.rippleRadius, rhs.alpha);
}
};

Expand Down Expand Up @@ -87,14 +94,25 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu

std::optional<SharedColor> parsedColor{};
std::optional<std::vector<std::string>> parsedColorResourcePaths{};
std::optional<std::string> parsedColorFallback{};
if (color != map.end()) {
if (color->second.hasType<std::unordered_map<std::string, std::vector<std::string>>>()) {
auto colorMap = (std::unordered_map<std::string, std::vector<std::string>>)color->second;
// The color object mixes an array (`resource_paths`) with an optional
// string (`fallback`), so read it as a map of RawValue rather than a map
// of vector<string> (which would assert on the string value).
bool handledAsResourcePaths = false;
if (color->second.hasType<std::unordered_map<std::string, RawValue>>()) {
auto colorMap = (std::unordered_map<std::string, RawValue>)color->second;
auto pathsIt = colorMap.find("resource_paths");
if (pathsIt != colorMap.end()) {
parsedColorResourcePaths = pathsIt->second;
if (pathsIt != colorMap.end() && pathsIt->second.hasType<std::vector<std::string>>()) {
parsedColorResourcePaths = (std::vector<std::string>)pathsIt->second;
auto fallbackIt = colorMap.find("fallback");
if (fallbackIt != colorMap.end() && fallbackIt->second.hasType<std::string>()) {
parsedColorFallback = (std::string)fallbackIt->second;
}
handledAsResourcePaths = true;
}
} else {
}
if (!handledAsResourcePaths) {
SharedColor resolved;
fromRawValue(context, color->second, resolved);
if (resolved) {
Expand All @@ -114,6 +132,7 @@ static inline void fromRawValue(const PropsParserContext &context, const RawValu
NativeDrawable::Ripple{
.color = parsedColor,
.colorResourcePaths = parsedColorResourcePaths,
.colorFallback = parsedColorFallback,
.rippleRadius = rippleRadius != map.end() && rippleRadius->second.hasType<Float>()
? (Float)rippleRadius->second
: std::optional<Float>{},
Expand Down
Loading
Loading