@symbiote-native/engine 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/android/CMakeLists.txt +31 -1
  2. package/android/build.gradle +4 -1
  3. package/build/accessibility-info/index.android.js +21 -17
  4. package/build/accessibility-info/index.ios.js +2 -0
  5. package/build/accessibility-info/shared.d.ts +1 -1
  6. package/build/alert/shared.d.ts +1 -1
  7. package/build/alert/shared.js +3 -7
  8. package/build/app-state/index.d.ts +1 -1
  9. package/build/app-state/index.js +35 -27
  10. package/build/back-handler/index.d.ts +7 -2
  11. package/build/back-handler/index.js +19 -10
  12. package/build/events/index.js +10 -1
  13. package/build/fabric-props.js +1 -1
  14. package/build/fabric.d.ts +7 -0
  15. package/build/fabric.js +14 -0
  16. package/build/host-behavior.d.ts +1 -0
  17. package/build/image-loader.d.ts +4 -2
  18. package/build/image-loader.js +14 -25
  19. package/build/image-source-write.d.ts +1 -1
  20. package/build/image-source-write.js +14 -2
  21. package/build/index.d.ts +2 -2
  22. package/build/index.js +6 -6
  23. package/build/invariant.d.ts +1 -0
  24. package/build/invariant.js +10 -0
  25. package/build/keyboard/index.js +11 -32
  26. package/build/linking/index.android.js +5 -3
  27. package/build/linking/shared.d.ts +1 -1
  28. package/build/linking/shared.js +16 -19
  29. package/build/native-events.d.ts +5 -0
  30. package/build/native-events.js +21 -0
  31. package/build/node.js +18 -2
  32. package/build/permissions-android/index.android.d.ts +59 -0
  33. package/build/permissions-android/index.android.js +47 -0
  34. package/build/permissions-android/index.d.ts +1 -115
  35. package/build/permissions-android/index.ios.d.ts +59 -0
  36. package/build/permissions-android/index.ios.js +31 -0
  37. package/build/permissions-android/index.js +3 -184
  38. package/build/permissions-android/shared.d.ts +63 -0
  39. package/build/permissions-android/shared.js +66 -0
  40. package/build/platform/index.android.js +3 -5
  41. package/build/platform/index.ios.js +3 -3
  42. package/build/platform/shared.d.ts +4 -0
  43. package/build/platform/shared.js +9 -0
  44. package/build/platform-color/index.android.d.ts +4 -0
  45. package/build/platform-color/index.android.js +7 -0
  46. package/build/platform-color/index.d.ts +2 -20
  47. package/build/platform-color/index.js +1 -45
  48. package/build/platform-color/shared.d.ts +21 -0
  49. package/build/platform-color/shared.js +41 -0
  50. package/build/settings/index.android.d.ts +6 -0
  51. package/build/settings/index.android.js +21 -0
  52. package/build/settings/index.d.ts +1 -8
  53. package/build/settings/index.ios.d.ts +8 -0
  54. package/build/settings/index.ios.js +122 -0
  55. package/build/settings/index.js +3 -122
  56. package/build/share/index.android.js +9 -32
  57. package/build/share/index.ios.js +14 -15
  58. package/build/share/shared.d.ts +4 -2
  59. package/build/share/shared.js +7 -10
  60. package/build/status-bar/index.android.js +1 -1
  61. package/build/status-bar/index.ios.js +4 -3
  62. package/build/toast-android/index.android.d.ts +10 -0
  63. package/build/toast-android/index.android.js +108 -0
  64. package/build/toast-android/index.d.ts +1 -10
  65. package/build/toast-android/index.ios.d.ts +10 -0
  66. package/build/toast-android/index.ios.js +19 -0
  67. package/build/toast-android/index.js +3 -108
  68. package/cpp/SymbioteFabricProps.cpp +204 -120
  69. package/cpp/SymbioteFabricProps.h +5 -0
  70. package/cpp/SymbioteTree.cpp +20 -6
  71. package/package.json +2 -2
@@ -68,6 +68,7 @@ constexpr const char *kTextComponent = "RCTText";
68
68
  constexpr const char *kAndroidSwitchComponent = "AndroidSwitch";
69
69
  constexpr const char *kSinglelineTextInput = "RCTSinglelineTextInputView";
70
70
  constexpr const char *kMultilineTextInput = "RCTMultilineTextInputView";
71
+ constexpr const char *kAndroidTextInput = "AndroidTextInput";
71
72
 
72
73
  // Colour props must reach Fabric as platform ints. `fromRawValueShared.h` parses a CSS STRING only
73
74
  // when `enableNativeCSSParsing()` is on, and it defaults to FALSE — an unparsed string falls
@@ -108,6 +109,8 @@ const std::unordered_set<std::string> kColorProps = {
108
109
  "trackColorForTrue",
109
110
  "trackColorForFalse",
110
111
  "trackTintColor",
112
+ // ScrollView's Android fill below the content (`ScrollViewNativeComponent.js`: colorAttribute).
113
+ "endFillColor",
111
114
  };
112
115
 
113
116
  // ── COLOUR ───────────────────────────────────────────────────────────────────────────────────────
@@ -180,16 +183,36 @@ std::optional<uint32_t> parseCssColor(const std::string &text) {
180
183
  * style uses, this runs per colour key per node per commit, and it is twenty lines.
181
184
  */
182
185
  dynamic processColorValue(const dynamic &value) {
186
+ if (value.isObject()) {
187
+ // `DynamicColorIOS`: `PlatformColorParser.mm` reads each branch as an already-processed int,
188
+ // so RN's `processColorObject` processes them in JS. A string left here paints nothing.
189
+ const auto tuple = value.find("dynamic");
190
+ if (tuple == value.items().end() || !tuple->second.isObject()) return value;
191
+ dynamic branches = dynamic::object();
192
+ for (const auto &[branch, color] : tuple->second.items())
193
+ branches[branch] = processColorValue(color);
194
+ dynamic out = value;
195
+ out["dynamic"] = std::move(branches);
196
+ return out;
197
+ }
183
198
  if (!value.isString()) return value;
184
199
  const std::string &text = value.getString();
185
200
  const std::optional<uint32_t> parsed = (!text.empty() && text[0] == '#')
186
201
  ? parseHexColor(std::string_view(text).substr(1))
187
202
  : parseCssColor(text);
188
203
  if (!parsed.has_value()) return value;
204
+ #ifdef ANDROID
205
+ // SIGNED on Android, as RN's `processColor` does (`normalizedColor | 0x0`): the props also reach
206
+ // Java ViewManagers as a Double, and Kotlin's `toInt()` SATURATES an unsigned 0xff...... to
207
+ // 0x7fffffff — every opaque background became translucent white while C++-read text colours
208
+ // stayed right.
209
+ return dynamic(static_cast<int64_t>(static_cast<int32_t>(*parsed)));
210
+ #else
189
211
  // Widened rather than reinterpreted: RN's `processColor` yields an UNSIGNED 32-bit ARGB, and
190
212
  // `fromRawValueShared` reads it back as `(int64_t)value` before shifting. A signed narrowing here
191
213
  // would make every colour with alpha 0x80 or above negative.
192
214
  return dynamic(static_cast<int64_t>(*parsed));
215
+ #endif
193
216
  }
194
217
 
195
218
  dynamic processValue(const std::string &key, const dynamic &value) {
@@ -530,6 +553,7 @@ const std::unordered_map<std::string, std::string> &enterKeyHintToReturnKeyType(
530
553
  return *table;
531
554
  }
532
555
 
556
+ #ifdef ANDROID
533
557
  /** RN's W3C autocomplete -> Android `autoComplete` map, TextInput.js:828. */
534
558
  const std::unordered_map<std::string, std::string> &autoCompleteWebToAndroid() {
535
559
  static const auto *table = new std::unordered_map<std::string, std::string>{
@@ -566,7 +590,7 @@ const std::unordered_map<std::string, std::string> &autoCompleteWebToAndroid() {
566
590
  };
567
591
  return *table;
568
592
  }
569
-
593
+ #else
570
594
  /** RN's W3C autocomplete -> iOS `textContentType` map, TextInput.js:862. */
571
595
  const std::unordered_map<std::string, std::string> &autoCompleteWebToTextContentType() {
572
596
  static const auto *table = new std::unordered_map<std::string, std::string>{
@@ -609,6 +633,7 @@ const std::unordered_map<std::string, std::string> &autoCompleteWebToTextContent
609
633
  };
610
634
  return *table;
611
635
  }
636
+ #endif
612
637
 
613
638
  /** The string a key holds, or null when it is absent or is not a string. */
614
639
  const std::string *stringAt(const dynamic &props, const char *key) {
@@ -632,6 +657,31 @@ std::optional<bool> boolAt(const dynamic &props, const char *key) {
632
657
  return found->getBool();
633
658
  }
634
659
 
660
+ /** Any value but `null` / absent — RN's `!= null`. */
661
+ const dynamic *presentAt(const dynamic &props, const char *key) {
662
+ const dynamic *found = props.get_ptr(key);
663
+ if (found == nullptr || found->isNull()) return nullptr;
664
+ return found;
665
+ }
666
+
667
+ /**
668
+ * A colour an app authored, in ANY ColorValue shape RN accepts: a string, a number, or an opaque
669
+ * PlatformColor / DynamicColorIOS object. Reading only strings dropped every PlatformColor silently.
670
+ */
671
+ const dynamic *colorAt(const dynamic &props, const char *key) {
672
+ return presentAt(props, key);
673
+ }
674
+
675
+ /** `colorAt`, then JS truthiness, for RN's `if (color)` sites: `''`, `0` and `false` are absent. */
676
+ const dynamic *truthyColorAt(const dynamic &props, const char *key) {
677
+ const dynamic *found = colorAt(props, key);
678
+ if (found == nullptr) return nullptr;
679
+ if (found->isBool() && !found->getBool()) return nullptr;
680
+ if (found->isString() && found->getString().empty()) return nullptr;
681
+ if (found->isNumber() && found->asDouble() == 0) return nullptr;
682
+ return found;
683
+ }
684
+
635
685
  /** A safe lookup: the mapped token, or null when the map has no entry. The caller owns the fallback. */
636
686
  const std::string *mappedToken(
637
687
  const std::unordered_map<std::string, std::string> &table,
@@ -671,8 +721,11 @@ std::string foldSubmitBehavior(
671
721
  dynamic foldTextInputAliases(const dynamic &props, bool isMultiline) {
672
722
  dynamic out = props;
673
723
 
724
+ // THE WEB SPELLING WINS (TextInput.js:919-937): `inputMode ? map[inputMode] : keyboardType`, and
725
+ // the same for `enterKeyHint` and `readOnly`. An unmapped token resolves to nothing, as RN's map
726
+ // lookup does, rather than falling back to the native prop.
674
727
  const std::string *inputMode = stringAt(props, "inputMode");
675
- if (inputMode != nullptr && out.get_ptr("keyboardType") == nullptr) {
728
+ if (inputMode != nullptr && !inputMode->empty()) {
676
729
  // `search` is the ONE token RN resolves per platform (TextInput.js:815-825): iOS has a dedicated
677
730
  // search keyboard whose return key is a magnifier, every other host falls back to the default.
678
731
  if (*inputMode == "search") {
@@ -684,21 +737,41 @@ dynamic foldTextInputAliases(const dynamic &props, bool isMultiline) {
684
737
  } else {
685
738
  const std::string *mapped = mappedToken(inputModeToKeyboardType(), *inputMode);
686
739
  if (mapped != nullptr) out["keyboardType"] = *mapped;
740
+ else out.erase("keyboardType");
687
741
  }
688
742
  }
689
743
 
690
744
  const std::string *enterKeyHint = stringAt(props, "enterKeyHint");
691
- if (enterKeyHint != nullptr && out.get_ptr("returnKeyType") == nullptr) {
745
+ if (enterKeyHint != nullptr && !enterKeyHint->empty()) {
692
746
  const std::string *mapped = mappedToken(enterKeyHintToReturnKeyType(), *enterKeyHint);
693
747
  if (mapped != nullptr) out["returnKeyType"] = *mapped;
748
+ else out.erase("returnKeyType");
694
749
  }
695
750
 
696
751
  // The web spelling is the NEGATION of the native one. Getting it backwards makes every read-only
697
752
  // field editable, silently.
698
753
  const std::optional<bool> readOnly = boolAt(props, "readOnly");
699
- if (readOnly.has_value() && out.get_ptr("editable") == nullptr) {
700
- out["editable"] = !*readOnly;
754
+ if (readOnly.has_value()) out["editable"] = !*readOnly;
755
+
756
+ // TextInput.js:708-711 — `rows ?? numberOfLines`, and `focusable` resolved and ALWAYS sent:
757
+ // `tabIndex !== undefined ? !tabIndex : focusable !== false`.
758
+ const dynamic *rows = presentAt(props, "rows");
759
+ if (rows != nullptr) out["numberOfLines"] = *rows;
760
+ const dynamic *tabIndex = props.get_ptr("tabIndex");
761
+ if (tabIndex != nullptr) {
762
+ out["focusable"] = tabIndex->isNumber() ? tabIndex->asDouble() == 0 : tabIndex->isNull();
763
+ } else {
764
+ out["focusable"] = boolAt(props, "focusable") != std::optional<bool>(false);
701
765
  }
766
+ // TextInput.js:904 — `allowFontScaling = true`.
767
+ if (props.get_ptr("allowFontScaling") == nullptr) out["allowFontScaling"] = true;
768
+
769
+ #ifdef ANDROID
770
+ // TextInput.js:728,734 — Android-only defaults: `autoCapitalize || 'sentences'`, `placeholder ?? ''`.
771
+ const std::string *capitalize = stringAt(props, "autoCapitalize");
772
+ if (capitalize == nullptr || capitalize->empty()) out["autoCapitalize"] = "sentences";
773
+ if (presentAt(props, "placeholder") == nullptr) out["placeholder"] = "";
774
+ #endif
702
775
 
703
776
  out["submitBehavior"] = foldSubmitBehavior(
704
777
  stringAt(props, "submitBehavior"), boolAt(props, "blurOnSubmit"), isMultiline);
@@ -713,24 +786,26 @@ dynamic foldTextInputAliases(const dynamic &props, bool isMultiline) {
713
786
  }
714
787
  }
715
788
 
716
- // RN resolves BOTH native props from the one W3C token (TextInput.js:938). Android reads
717
- // `autoComplete` and iOS reads `textContentType`; each is inert on the other platform, so emitting
718
- // both is safe and is what keeps this fold platform-agnostic. A token with no Android entry falls
719
- // back to ITSELF (RN's `?? autoComplete`); one with no iOS entry leaves `textContentType` unset.
789
+ // TextInput.js:938-954, split per platform: Android sends the mapped token as `autoComplete` (a
790
+ // token with no entry falls back to ITSELF, `?? autoComplete`) and derives no `textContentType`;
791
+ // iOS sends NO `autoComplete` and derives `textContentType` when none is authored.
720
792
  const std::string *autoComplete = stringAt(props, "autoComplete");
793
+ #ifdef ANDROID
721
794
  if (autoComplete != nullptr) {
722
795
  const std::string *android = mappedToken(autoCompleteWebToAndroid(), *autoComplete);
723
796
  out["autoComplete"] = android != nullptr ? *android : *autoComplete;
724
- if (out.get_ptr("textContentType") == nullptr) {
725
- const std::string *ios = mappedToken(autoCompleteWebToTextContentType(), *autoComplete);
726
- if (ios != nullptr) out["textContentType"] = *ios;
727
- }
728
797
  }
729
-
730
- // `inputMode: 'none'` is how the web spells "focusable but no keyboard".
731
- if (inputMode != nullptr && out.get_ptr("showSoftInputOnFocus") == nullptr) {
732
- out["showSoftInputOnFocus"] = *inputMode != "none";
798
+ #else
799
+ out.erase("autoComplete");
800
+ if (autoComplete != nullptr && presentAt(props, "textContentType") == nullptr) {
801
+ const std::string *ios = mappedToken(autoCompleteWebToTextContentType(), *autoComplete);
802
+ if (ios != nullptr) out["textContentType"] = *ios;
733
803
  }
804
+ #endif
805
+
806
+ // `inputMode: 'none'` is how the web spells "focusable but no keyboard" — and it wins
807
+ // (`inputMode == null ? showSoftInputOnFocus : inputMode !== 'none'`).
808
+ if (inputMode != nullptr) out["showSoftInputOnFocus"] = *inputMode != "none";
734
809
 
735
810
  // ANDROID ONLY, and that is F-76 rather than tidiness: iOS's `RCTSinglelineTextInputView`
736
811
  // ViewConfig does not declare `underlineColorAndroid` at all, so RN's own
@@ -749,6 +824,8 @@ dynamic foldTextInputAliases(const dynamic &props, bool isMultiline) {
749
824
  out.erase("enterKeyHint");
750
825
  out.erase("readOnly");
751
826
  out.erase("blurOnSubmit");
827
+ out.erase("rows");
828
+ out.erase("tabIndex");
752
829
  return out;
753
830
  }
754
831
 
@@ -1096,9 +1173,21 @@ dynamic foldRefreshWrapperProps(const dynamic &props, const IFirstChild &child)
1096
1173
  * not know throws nothing, logs nothing and paints nothing, so the strip is only ever visible in a
1097
1174
  * payload test.
1098
1175
  */
1099
- dynamic foldScrollViewProps(const dynamic &props, bool isHorizontal, bool isWrapped) {
1176
+ dynamic foldScrollViewProps(
1177
+ const dynamic &props,
1178
+ bool isHorizontal,
1179
+ bool isWrapped,
1180
+ bool hasMomentumListener) {
1100
1181
  dynamic out = props;
1101
1182
 
1183
+ // `ScrollView.js:1797-1808`. Android's ReactScrollView emits momentum events only with
1184
+ // `sendMomentumEvents` on; sticky headers need every scroll frame; snap edges default on.
1185
+ out["sendMomentumEvents"] = hasMomentumListener;
1186
+ const dynamic *sticky = props.get_ptr("stickyHeaderIndices");
1187
+ if (sticky != nullptr && sticky->isArray() && !sticky->empty()) out["scrollEventThrottle"] = 1;
1188
+ out["snapToStart"] = boolAt(props, "snapToStart") != std::optional<bool>(false);
1189
+ out["snapToEnd"] = boolAt(props, "snapToEnd") != std::optional<bool>(false);
1190
+
1102
1191
  const dynamic *authored = props.get_ptr("style");
1103
1192
  // WRAPPED IS A DIFFERENT COMPOSITION, not an extra one: on Android a RefreshControl becomes this
1104
1193
  // node's PARENT and takes the LAYOUT half of the app's style with it, so composing `[base,
@@ -1112,7 +1201,8 @@ dynamic foldScrollViewProps(const dynamic &props, bool isHorizontal, bool isWrap
1112
1201
  ? splitScrollViewStyle(isHorizontal, authored).inner
1113
1202
  : composeUnder(scrollViewBaseStyle(isHorizontal), authored);
1114
1203
 
1115
- out["nestedScrollEnabled"] = boolAt(props, "nestedScrollEnabled").value_or(true);
1204
+ // `ScrollView.js:1862` — defaulted only under the refresh wrap; elsewhere the authored value alone.
1205
+ if (isWrapped) out["nestedScrollEnabled"] = boolAt(props, "nestedScrollEnabled").value_or(true);
1116
1206
 
1117
1207
  const std::optional<bool> authoredAxis = boolAt(props, "horizontal");
1118
1208
  if (authoredAxis.has_value() && *authoredAxis != isHorizontal) {
@@ -1235,6 +1325,20 @@ dynamic foldScrollContentProps(
1235
1325
  out["style"] = std::move(composed);
1236
1326
  }
1237
1327
  if (preserves) out["collapsableChildren"] = false;
1328
+
1329
+ // `ScrollView.js:1740-1745` — the scroller's clipping flag, forced off on Android while any header
1330
+ // sticks (clipping breaks sticky headers there).
1331
+ const dynamic *clipped = ownerProps != nullptr ? presentAt(*ownerProps, "removeClippedSubviews") : nullptr;
1332
+ #ifdef ANDROID
1333
+ const dynamic *sticky = ownerProps != nullptr ? ownerProps->get_ptr("stickyHeaderIndices") : nullptr;
1334
+ if (sticky != nullptr && sticky->isArray() && !sticky->empty()) {
1335
+ out["removeClippedSubviews"] = false;
1336
+ } else if (clipped != nullptr) {
1337
+ out["removeClippedSubviews"] = *clipped;
1338
+ }
1339
+ #else
1340
+ if (clipped != nullptr) out["removeClippedSubviews"] = *clipped;
1341
+ #endif
1238
1342
  return out;
1239
1343
  }
1240
1344
 
@@ -1479,8 +1583,8 @@ dynamic foldActivityIndicatorSpinnerProps(
1479
1583
  out["animating"] = boolAt(props, "animating").value_or(true);
1480
1584
  out["hidesWhenStopped"] = boolAt(props, "hidesWhenStopped").value_or(true);
1481
1585
 
1482
- const dynamic *color = props.get_ptr("color");
1483
- if (color == nullptr || !color->isString()) {
1586
+ // Any ColorValue the app wrote stands (RN defaults only an absent one).
1587
+ if (colorAt(props, "color") == nullptr) {
1484
1588
  if (isAndroidProgressBar) out.erase("color");
1485
1589
  else out["color"] = kSpinnerIosDefaultColor;
1486
1590
  }
@@ -1519,38 +1623,6 @@ dynamic foldActivityIndicatorSpinnerProps(
1519
1623
  * tint, the Android view style) read it off `propsOf(node)`, which this cannot reach. That
1520
1624
  * separation is what makes the strip safe here and unsafe one layer up.
1521
1625
  */
1522
- /**
1523
- * Button's TITLE, as it is rendered: uppercase on Android, verbatim everywhere else
1524
- * (`Button.js:352-353`).
1525
- *
1526
- * `#ifdef ANDROID` rather than a view-name branch, and for the reason `decelerationRate`'s constants
1527
- * take one: there is no name to read. A raw text commits as `RCTRawText` on both platforms — unlike
1528
- * `Switch`/`AndroidSwitch`, where the two platforms genuinely are two Fabric components and the wire
1529
- * already says which. So the Android arm is unreachable headless, like `android_ripple`'s, and that
1530
- * is a recorded gap rather than a hidden one.
1531
- *
1532
- * ASCII-only, deliberately, and it is upstream's own behaviour rather than a shortcut: RN calls
1533
- * JavaScript's `String.prototype.toUpperCase`, which is full Unicode, so a Cyrillic or Greek label
1534
- * uppercases there and would not here. Left ASCII because the alternative is dragging ICU into the
1535
- * engine for a label that is uppercased only on Android, and a wrong-case label is a cosmetic
1536
- * difference on one platform rather than a broken control. Recorded so it is a decision.
1537
- */
1538
- dynamic foldButtonLabel(const dynamic &props) {
1539
- #ifdef ANDROID
1540
- const dynamic *text = props.get_ptr("text");
1541
- if (text == nullptr || !text->isString()) return props;
1542
- std::string upper = text->asString();
1543
- for (char &character : upper)
1544
- character = static_cast<char>(
1545
- std::toupper(static_cast<unsigned char>(character)));
1546
- dynamic out = props;
1547
- out["text"] = std::move(upper);
1548
- return out;
1549
- #else
1550
- return props;
1551
- #endif
1552
- }
1553
-
1554
1626
  /**
1555
1627
  * Button's own `disabled`, which is three questions where a plain touchable asks one:
1556
1628
  * `props.disabled ?? aria-disabled ?? accessibilityState.disabled` (`Button.js:331,337`).
@@ -1631,8 +1703,8 @@ dynamic foldButtonLabelStyle(
1631
1703
  #else
1632
1704
  style["color"] = kIosButtonBlue;
1633
1705
  style["fontSize"] = kIosButtonFontSize;
1634
- const dynamic *color = owner->get_ptr("color");
1635
- if (color != nullptr && color->isString()) style["color"] = *color;
1706
+ const dynamic *color = truthyColorAt(*owner, "color");
1707
+ if (color != nullptr) style["color"] = *color;
1636
1708
  #endif
1637
1709
 
1638
1710
  const std::optional<bool> disabled = buttonDisabled(*owner);
@@ -1655,9 +1727,6 @@ dynamic foldButtonLabelStyle(
1655
1727
  }
1656
1728
 
1657
1729
  #ifdef ANDROID
1658
- // `TouchableNativeFeedback.js` via `Platform.Version >= 23` — foreground ripples need API 23.
1659
- constexpr int kAndroidForegroundMinVersion = 23;
1660
-
1661
1730
  // `Button.js:394-437`, one constant per literal so a value cannot drift silently.
1662
1731
  constexpr int kAndroidButtonElevation = 4;
1663
1732
  constexpr int kAndroidDisabledElevation = 0;
@@ -1665,26 +1734,6 @@ constexpr int kAndroidButtonBorderRadius = 2;
1665
1734
  constexpr const char *kAndroidButtonBlue = "#2196F3";
1666
1735
  constexpr const char *kAndroidDisabledBackground = "#dfdfdf";
1667
1736
 
1668
- /**
1669
- * The running device's API level, and the one line of the Android branch a HOST build cannot have.
1670
- *
1671
- * `android_get_device_api_level` is the NDK's, so it exists when `__ANDROID__` is defined — which the
1672
- * real toolchain sets and the test host's `-DANDROID` does not. That split is the point rather than
1673
- * a workaround: the rule's LOGIC becomes testable in the Android arm of the test host
1674
- * (`tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`) while the query itself stays the device's.
1675
- *
1676
- * The host answers with the minimum RN supports, so the arm exercises the branch an app on a modern
1677
- * device takes. The other branch is reachable only on a device old enough to need it, which is where
1678
- * it always was.
1679
- */
1680
- int androidApiLevel() {
1681
- #ifdef __ANDROID__
1682
- return android_get_device_api_level();
1683
- #else
1684
- return kAndroidForegroundMinVersion;
1685
- #endif
1686
- }
1687
-
1688
1737
  // RN's `TouchableNativeFeedback.SelectableBackground()` with no ripple radius, which is what its
1689
1738
  // body falls back to when the app passes no `background` (`:343-348`), and what Button's own view
1690
1739
  // gets because TNF clones onto it (`:339`).
@@ -1740,8 +1789,8 @@ dynamic foldButtonProps(
1740
1789
  dynamic style = dynamic::object();
1741
1790
  style["elevation"] = kAndroidButtonElevation;
1742
1791
  style["borderRadius"] = kAndroidButtonBorderRadius;
1743
- const std::string *color = stringAt(authored, "color");
1744
- style["backgroundColor"] = color != nullptr ? *color : kAndroidButtonBlue;
1792
+ const dynamic *color = truthyColorAt(authored, "color");
1793
+ style["backgroundColor"] = color != nullptr ? *color : dynamic(kAndroidButtonBlue);
1745
1794
  if (buttonDisabled(authored).value_or(false)) {
1746
1795
  style["elevation"] = kAndroidDisabledElevation;
1747
1796
  style["backgroundColor"] = kAndroidDisabledBackground;
@@ -1808,24 +1857,27 @@ const std::array<const char *, 12> kPressableMachineKeys = {
1808
1857
  * onto its OWN View (`Pressable.js:251`), which is why one node carries it and no inner view is
1809
1858
  * needed.
1810
1859
  *
1811
- * The colour stays a STRING, as the JS this replaces left it: `nativeBackgroundAndroid` is a nested
1812
- * object and the payload's colour processing is keyed on top-level names, so converting here would be
1813
- * a change to the rule rather than a move of it. Android resolves the string; `null` is its
1814
- * documented "no tint".
1860
+ * The colour is converted HERE, as RN's `useAndroidRippleForView.js:58` does with `processColor`:
1861
+ * the dict is nested, so the top-level colour pass never sees it, and `ReactDrawableHelper.getColor`
1862
+ * reads it with `getInt` — a string there is a native type error, not a resolved colour. `null` is
1863
+ * the documented "no tint". A PlatformColor object passes through, as `processColor` leaves it.
1815
1864
  *
1816
- * STILL MISSING, and it always was: RN also dispatches `Commands.hotspotUpdate(x, y)` on
1817
- * pressIn/pressMove and `Commands.setPressed` on pressIn/pressOut, which is what makes the ripple
1818
- * originate at the touch point. Neither the old wrapper nor the behavior ever sent them.
1865
+ * `:57` — no ripple at all unless color, borderless or radius is set.
1819
1866
  */
1820
1867
  #ifdef ANDROID
1821
1868
  void applyAndroidRipple(dynamic &out, const dynamic &config) {
1869
+ const dynamic *color = colorAt(config, "color");
1870
+ const dynamic *borderless = presentAt(config, "borderless");
1871
+ const dynamic *radius = presentAt(config, "radius");
1872
+ if (color == nullptr && borderless == nullptr && radius == nullptr) return;
1873
+
1822
1874
  dynamic background = dynamic::object();
1823
1875
  background["type"] = "RippleAndroid";
1824
- const std::string *color = stringAt(config, "color");
1825
- background["color"] = color != nullptr ? dynamic(*color) : dynamic(nullptr);
1876
+ background["color"] = color != nullptr ? processColorValue(*color) : dynamic(nullptr);
1826
1877
  background["borderless"] = boolAt(config, "borderless").value_or(false);
1827
- const dynamic *radius = config.get_ptr("radius");
1828
- if (radius != nullptr && radius->isNumber()) background["rippleRadius"] = *radius;
1878
+ if (radius != nullptr) background["rippleRadius"] = *radius;
1879
+ const dynamic *alpha = presentAt(config, "alpha");
1880
+ background["alpha"] = alpha != nullptr ? *alpha : dynamic(nullptr);
1829
1881
 
1830
1882
  out[boolAt(config, "foreground").value_or(false) ? "nativeForegroundAndroid"
1831
1883
  : "nativeBackgroundAndroid"] =
@@ -2042,12 +2094,13 @@ dynamic foldCloneOntoChild(
2042
2094
  dynamic background = authored != nullptr && authored->isObject()
2043
2095
  ? *authored
2044
2096
  : selectableItemBackground();
2045
- // `canUseNativeForeground()` — RN's own guard, and `Platform.Version` on Android IS the API
2046
- // level, so the JS check and this one read the same number.
2047
- out[boolAt(source, "useForeground").value_or(false) &&
2048
- androidApiLevel() >= kAndroidForegroundMinVersion
2049
- ? "nativeForegroundAndroid"
2050
- : "nativeBackgroundAndroid"] = std::move(background);
2097
+ // `TouchableNativeFeedback.Ripple(color)` leaves the colour a string; RN's factory runs it
2098
+ // through `processColor`, and Java reads it with `getInt` (see `applyAndroidRipple`).
2099
+ if (dynamic *rippleColor = background.get_ptr("color")) *rippleColor = processColorValue(*rippleColor);
2100
+ // `:402-405` — `canUseNativeForeground()` is `Platform.OS === 'android'`, true on this branch.
2101
+ out[boolAt(source, "useForeground").value_or(false) ? "nativeForegroundAndroid"
2102
+ : "nativeBackgroundAndroid"] =
2103
+ std::move(background);
2051
2104
  }
2052
2105
  #endif
2053
2106
  return out;
@@ -2195,6 +2248,17 @@ dynamic resolveImageSources(const dynamic &props) {
2195
2248
  dynamic foldImageProps(const dynamic &props, bool ariaHiddenIsTrue) {
2196
2249
  dynamic out = props;
2197
2250
  out["source"] = resolveImageSources(props);
2251
+ #ifdef ANDROID
2252
+ // `ReactImageView.setSource` ignores headers inside a source; only the top-level `headers` prop
2253
+ // reaches the request (`Image.android.js` lifts `source_[0].headers`). RN lifts them only when
2254
+ // `source_` is an array; a single object source's own headers were already dropped at write time
2255
+ // (`image-source-write.ts`), the one place its authored shape is still visible.
2256
+ const dynamic &sources = out["source"];
2257
+ if (sources.isArray() && !sources.empty() && sources.at(0).isObject()) {
2258
+ const dynamic *headers = sources.at(0).get_ptr("headers");
2259
+ if (headers != nullptr && headers->isObject()) out["headers"] = *headers;
2260
+ }
2261
+ #endif
2198
2262
 
2199
2263
  // `ImageProps.js:195,202` — the size aliases are STYLE, not props, and an explicit style key wins.
2200
2264
  // RN spells it `{width, height}, ...style`, so they go UNDER.
@@ -2259,7 +2323,16 @@ dynamic foldImageProps(const dynamic &props, bool ariaHiddenIsTrue) {
2259
2323
  const dynamic *defaultSource = props.get_ptr("defaultSource");
2260
2324
  if (defaultSource != nullptr && defaultSource->isArray() &&
2261
2325
  defaultSource->size() > 0) {
2326
+ #ifdef ANDROID
2327
+ // `Image.android.js` sends `defaultSource_.uri`: `ReactImageManager.setDefaultSource` takes a
2328
+ // `String?`, and a map there is a native type error.
2329
+ const dynamic &first = defaultSource->at(0);
2330
+ const std::string *uri = first.isObject() ? stringAt(first, "uri") : nullptr;
2331
+ if (uri != nullptr) out["defaultSource"] = *uri;
2332
+ else out.erase("defaultSource");
2333
+ #else
2262
2334
  out["defaultSource"] = defaultSource->at(0);
2335
+ #endif
2263
2336
  }
2264
2337
 
2265
2338
  for (const char *key : kImageConsumedKeys) out.erase(key);
@@ -2291,11 +2364,11 @@ dynamic foldSwitchProps(const dynamic &props, bool isAndroidSwitch) {
2291
2364
  const bool isOn = boolAt(props, "value").value_or(false);
2292
2365
 
2293
2366
  const dynamic *trackColor = props.get_ptr("trackColor");
2294
- const std::string *trackFalse = nullptr;
2295
- const std::string *trackTrue = nullptr;
2367
+ const dynamic *trackFalse = nullptr;
2368
+ const dynamic *trackTrue = nullptr;
2296
2369
  if (trackColor != nullptr && trackColor->isObject()) {
2297
- trackFalse = stringAt(*trackColor, "false");
2298
- trackTrue = stringAt(*trackColor, "true");
2370
+ trackFalse = colorAt(*trackColor, "false");
2371
+ trackTrue = colorAt(*trackColor, "true");
2299
2372
  }
2300
2373
 
2301
2374
  const std::optional<bool> disabled = boolAt(props, "disabled");
@@ -2338,7 +2411,7 @@ dynamic foldSwitchProps(const dynamic &props, bool isAndroidSwitch) {
2338
2411
 
2339
2412
  if (trackFalse != nullptr) out["trackColorForFalse"] = *trackFalse;
2340
2413
  if (trackTrue != nullptr) out["trackColorForTrue"] = *trackTrue;
2341
- const std::string *tint = isOn ? trackTrue : trackFalse;
2414
+ const dynamic *tint = isOn ? trackTrue : trackFalse;
2342
2415
  if (tint != nullptr) out["trackTintColor"] = *tint;
2343
2416
  // `:230` destructures the iOS colour names out of what reaches this view. They are keys it does
2344
2417
  // not declare.
@@ -2359,7 +2432,7 @@ dynamic foldSwitchProps(const dynamic &props, bool isAndroidSwitch) {
2359
2432
  // `StyleSheet.compose` exactly: `alignSelf` UNDER the app's style (an app that writes
2360
2433
  // `alignSelf: 'stretch'` still wins), the pill OVER it.
2361
2434
  const dynamic *authoredStyle = props.get_ptr("style");
2362
- const std::string *iosBackground = stringAt(props, "ios_backgroundColor");
2435
+ const dynamic *iosBackground = colorAt(props, "ios_backgroundColor");
2363
2436
  dynamic composed = dynamic::array();
2364
2437
  dynamic intrinsic = dynamic::object();
2365
2438
  // `:267` — a stock iOS switch keeps its intrinsic width instead of stretching to its
@@ -2383,7 +2456,7 @@ dynamic foldSwitchProps(const dynamic &props, bool isAndroidSwitch) {
2383
2456
  out["accessibilityRole"] = "switch";
2384
2457
  }
2385
2458
 
2386
- const std::string *thumbColor = stringAt(props, "thumbColor");
2459
+ const dynamic *thumbColor = colorAt(props, "thumbColor");
2387
2460
  if (thumbColor != nullptr) out["thumbTintColor"] = *thumbColor;
2388
2461
 
2389
2462
  // None of the three authored names is a native prop, and leaving one in the payload is how a
@@ -2409,15 +2482,9 @@ dynamic fabricProps(
2409
2482
  dynamic out = dynamic::object();
2410
2483
  const dynamic *text = props.get_ptr("text");
2411
2484
  if (text != nullptr) out["text"] = *text;
2412
- // THE ONE TAG RULE ON THIS PATH, and a raw text is a stranger place for one than it looks. It
2413
- // has no props an app can write — the object above is the whole payload — but its CONTENT can
2414
- // still be the platform's decision rather than the app's, which is exactly what Button's title
2415
- // is: rendered uppercase on Android and verbatim everywhere else (`Button.js:352-353`). That is
2416
- // a user-agent choice about a control, so it belongs here and not in the app's string.
2417
- //
2418
- // Guarded on the tag rather than applied to every raw text, obviously — and the tag reaches a
2419
- // raw text at all because `createRawText` now takes one, for this.
2420
- if (tagName == "button-label") out = foldButtonLabel(out);
2485
+ // Button's Android uppercase is NOT here: RN uses JavaScript's full-Unicode `toUpperCase`, which
2486
+ // this host (no ICU) cannot match, so it runs in JS at the slot redirect
2487
+ // (`behaviors/button.ts`, `slotValueFor`).
2421
2488
  return out;
2422
2489
  }
2423
2490
 
@@ -2485,7 +2552,8 @@ dynamic fabricProps(
2485
2552
  tagResolved = foldScrollViewProps(
2486
2553
  *bag,
2487
2554
  tagName == "horizontal-scroll-view",
2488
- owner.tagName != nullptr && std::strcmp(owner.tagName, "refresh-control") == 0);
2555
+ owner.tagName != nullptr && std::strcmp(owner.tagName, "refresh-control") == 0,
2556
+ self.hasMomentumListener);
2489
2557
  bag = &tagResolved;
2490
2558
  } else if (tagName == "refresh-control") {
2491
2559
  tagResolved = foldRefreshWrapperProps(*bag, firstChild);
@@ -2559,8 +2627,12 @@ dynamic fabricProps(
2559
2627
  }
2560
2628
 
2561
2629
  dynamic valueFolded;
2562
- const bool isTextInput =
2563
- component == kSinglelineTextInput || component == kMultilineTextInput;
2630
+ // Android commits BOTH text-input tags as one `AndroidTextInput`, so there the TAG says which one
2631
+ // it is; the iOS names say it themselves.
2632
+ const bool isTextInput = component == kSinglelineTextInput ||
2633
+ component == kMultilineTextInput || component == kAndroidTextInput;
2634
+ const bool isMultilineTextInput = component == kMultilineTextInput ||
2635
+ (component == kAndroidTextInput && tagName == "text-input-multiline");
2564
2636
  if (isTextInput &&
2565
2637
  (bag->get_ptr("value") != nullptr || bag->get_ptr("defaultValue") != nullptr)) {
2566
2638
  valueFolded = foldTextInputValue(*bag);
@@ -2572,7 +2644,7 @@ dynamic fabricProps(
2572
2644
  // which is what makes this a rule rather than a mapping.
2573
2645
  dynamic aliasResolved;
2574
2646
  if (isTextInput) {
2575
- aliasResolved = foldTextInputAliases(*bag, component == kMultilineTextInput);
2647
+ aliasResolved = foldTextInputAliases(*bag, isMultilineTextInput);
2576
2648
  // `TextInput.js:583` — `props.accessible !== false`, handed to both the singleline and the
2577
2649
  // multiline view (`:703`, `:772`). The same shape as the Text rule below, and found the same
2578
2650
  // way: diffing one bench row's committed payload against React Native's own.
@@ -2602,10 +2674,22 @@ dynamic fabricProps(
2602
2674
  const bool optedOut =
2603
2675
  scaling != nullptr && scaling->isBool() && scaling->getBool() == false;
2604
2676
  textDefaulted["allowFontScaling"] = !optedOut;
2605
- // `Text.js:145` — `accessible !== false` on iOS, the same "only a literal false opts out" shape
2606
- // as the two above. Android resolves it off the press handlers instead, which is a behavior's
2607
- // job and not this rule's.
2677
+ // `Text.js:145-150` — `accessible !== false` on iOS; on Android an unset value follows the
2678
+ // press handlers (`onPress != null || onLongPress != null`), whose presence is a listener bit.
2679
+ #ifdef ANDROID
2680
+ textDefaulted["accessible"] =
2681
+ boolAt(textDefaulted, "accessible").value_or(self.hasPressOrLongPressListener);
2682
+ #else
2608
2683
  textDefaulted["accessible"] = boolAt(textDefaulted, "accessible").value_or(true);
2684
+ #endif
2685
+ // `Text.js:152-163` — a pressable, enabled text with no role of its own is announced as a link.
2686
+ const bool isPressable =
2687
+ (self.hasPressOrLongPressListener || self.hasStartShouldSetResponder) &&
2688
+ buttonDisabled(props) != std::optional<bool>(true);
2689
+ if (isPressable && presentAt(textDefaulted, "accessibilityRole") == nullptr &&
2690
+ presentAt(props, "role") == nullptr) {
2691
+ textDefaulted["accessibilityRole"] = "link";
2692
+ }
2609
2693
  // `Text.js:547` puts this in the component's own default STYLE, and its comment says why:
2610
2694
  // "native components have historically acted like overflow: hidden ... to let client
2611
2695
  // differentiate with overflow: 'visible'". Written at the TOP LEVEL rather than into the style
@@ -208,6 +208,11 @@ struct ISelf {
208
208
  bool hasPressListener = false;
209
209
  bool hasAnyPressListener = false;
210
210
  bool underlayShown = false;
211
+ // ScrollView's `sendMomentumEvents`: whether the app wired momentumScrollBegin or -End.
212
+ bool hasMomentumListener = false;
213
+ // Text's pressability (`Text.js:145-163`): onPress or onLongPress, and onStartShouldSetResponder.
214
+ bool hasPressOrLongPressListener = false;
215
+ bool hasStartShouldSetResponder = false;
211
216
  };
212
217
 
213
218
  folly::dynamic fabricProps(