@scalebun/react-native 1.13.0 → 2.0.1

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 (115) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
  8. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  9. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
  10. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  11. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
  12. package/dist/scalebun.full.js +964 -533
  13. package/dist/scalebun.slim.js +962 -531
  14. package/ios/Ota/OtaProtocol.swift +242 -0
  15. package/ios/Ota/OtaSlotManager.swift +26 -0
  16. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  17. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  18. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  19. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  20. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  21. package/ios/ReplaySdk.swift +65 -2
  22. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  23. package/lib/commonjs/core/config/schema.js +19 -12
  24. package/lib/commonjs/core/constants/version.js +1 -1
  25. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  26. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  27. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  28. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  29. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  30. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  31. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  32. package/lib/commonjs/features/journey/uiState.js +81 -1
  33. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  34. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  35. package/lib/commonjs/features/network/thirdParty.js +90 -0
  36. package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
  37. package/lib/commonjs/features/session/SessionManager.js +63 -0
  38. package/lib/commonjs/public/ScaleBunFacade.js +23 -57
  39. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  40. package/lib/module/core/config/schema.js +19 -12
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  43. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  44. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  45. package/lib/module/features/journey/interactionProtocol.js +128 -8
  46. package/lib/module/features/journey/scrollContext.js +135 -0
  47. package/lib/module/features/journey/targetGeometry.js +155 -0
  48. package/lib/module/features/journey/touchTarget.js +151 -24
  49. package/lib/module/features/journey/uiState.js +78 -1
  50. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  51. package/lib/module/features/network/NetworkFeature.js +38 -4
  52. package/lib/module/features/network/thirdParty.js +83 -0
  53. package/lib/module/features/ota/OtaOrchestrator.js +175 -80
  54. package/lib/module/features/session/SessionManager.js +63 -0
  55. package/lib/module/public/ScaleBunFacade.js +23 -57
  56. package/lib/typescript/core/config/schema.d.ts +2 -3
  57. package/lib/typescript/core/constants/version.d.ts +1 -1
  58. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  59. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  60. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  61. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  62. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  63. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  64. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  65. package/lib/typescript/features/network/index.d.ts +18 -0
  66. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  67. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
  68. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  69. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  70. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  71. package/lib/typescript/public/types.d.ts +10 -0
  72. package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
  73. package/package.json +3 -13
  74. package/scalebun-react-native.podspec +4 -0
  75. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  76. package/src/core/config/schema.ts +21 -9
  77. package/src/core/constants/version.ts +1 -1
  78. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  79. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  80. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  81. package/src/features/journey/interactionProtocol.ts +188 -10
  82. package/src/features/journey/scrollContext.ts +137 -0
  83. package/src/features/journey/targetGeometry.ts +176 -0
  84. package/src/features/journey/touchTarget.ts +237 -26
  85. package/src/features/journey/uiState.ts +80 -1
  86. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  87. package/src/features/network/NetworkFeature.ts +41 -4
  88. package/src/features/network/index.ts +18 -0
  89. package/src/features/network/thirdParty.ts +92 -0
  90. package/src/features/ota/OtaOrchestrator.ts +190 -90
  91. package/src/features/ota/OtaTypes.ts +23 -1
  92. package/src/features/session/SessionManager.ts +113 -0
  93. package/src/public/ScaleBunFacade.ts +23 -69
  94. package/src/public/types.ts +10 -0
  95. package/src/specs/NativeScaleBunOta.ts +35 -20
  96. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  97. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  98. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  99. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  100. package/lib/commonjs/features/ota/signature.js +0 -175
  101. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  102. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  103. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  104. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  105. package/lib/module/features/ota/signature.js +0 -169
  106. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  107. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  108. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  109. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  110. package/lib/typescript/features/ota/signature.d.ts +0 -81
  111. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  112. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  113. package/src/features/ota/crypto/loadSha512.ts +0 -35
  114. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  115. package/src/features/ota/signature.ts +0 -206
@@ -9,6 +9,8 @@ exports.useScaleBunScreen = useScaleBunScreen;
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _reactNative = require("react-native");
11
11
  var _touchTarget = require("./touchTarget");
12
+ var _targetGeometry = require("./targetGeometry");
13
+ var _scrollContext = require("./scrollContext");
12
14
  var _device = require("../../core/context/device");
13
15
  var _interactionProtocol = require("./interactionProtocol");
14
16
  var _jsxRuntime = require("react/jsx-runtime");
@@ -50,6 +52,15 @@ function getBootstrap() {
50
52
 
51
53
  const JourneyContext = /*#__PURE__*/(0, _react.createContext)(null);
52
54
 
55
+ /**
56
+ * Floor on how often a UI-state change may request a screenshot.
57
+ *
58
+ * One frame per second is far finer than any state a person perceives changing, and far coarser
59
+ * than a render loop — which is the case that matters, because a host may legitimately declare
60
+ * state from inside render and an oscillating dimension would otherwise capture continuously.
61
+ */
62
+ const UI_STATE_CAPTURE_MIN_INTERVAL_MS = 1000;
63
+
53
64
  // ─── ScaleBunDebugRoot ───────────────────────────────────────────────────────────
54
65
 
55
66
  function ScaleBunDebugRoot({
@@ -259,6 +270,54 @@ function ScaleBunDebugRoot({
259
270
  };
260
271
  }, [navigationRef]);
261
272
 
273
+ /**
274
+ * RN-11 — CAPTURE A FRAME WHEN THE UI CHANGES SHAPE.
275
+ *
276
+ * A declared state used to have no picture of its own. Nothing captured on `setUiState`, so a
277
+ * state's visual existed only if a screenshot happened to be taken for another reason — a
278
+ * navigation, a timer, or the interaction capture that fires AFTER the touch and therefore
279
+ * photographs the outcome. A drawer could be recorded as open with no frame showing it open,
280
+ * or with the frame of the screen that replaced it.
281
+ *
282
+ * The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER; none of them means
283
+ * "the UI changed shape". This routes through the MANUAL path, which is the only one reachable
284
+ * from JS. A dedicated native reason would let the backend tell a state capture from a
285
+ * developer-requested one, and that is a native change on both platforms — worth doing, not
286
+ * doable from here.
287
+ *
288
+ * RATE LIMITED, and the limit is not politeness. A screenshot is expensive, and while
289
+ * `uiState` already collapses redundant declarations, a genuinely oscillating dimension (a
290
+ * spinner toggling, a live-updating badge) would otherwise capture continuously. One frame per
291
+ * second is far finer than any state a person can perceive changing and far coarser than a
292
+ * render loop.
293
+ *
294
+ * Requests only; never awaits and never throws. If the session is not recording,
295
+ * `captureManualFrame` declines and the state simply has no frame — which is the honest
296
+ * outcome and is exactly what the analytics-only lane already reports elsewhere.
297
+ */
298
+ (0, _react.useEffect)(() => {
299
+ let lastCaptureTs = 0;
300
+ const {
301
+ onUiStateChange
302
+ } = require('./uiState');
303
+ const unsubscribe = onUiStateChange(() => {
304
+ const now = Date.now();
305
+ if (now - lastCaptureTs < UI_STATE_CAPTURE_MIN_INTERVAL_MS) return;
306
+ lastCaptureTs = now;
307
+ try {
308
+ const {
309
+ SessionManager
310
+ } = require('../session/SessionManager');
311
+ const sm = SessionManager.getExistingInstance();
312
+ if (sm && typeof sm.captureManualFrame === 'function') {
313
+ /* Fire and forget: the tap that follows must not wait on a screenshot. */
314
+ void sm.captureManualFrame();
315
+ }
316
+ } catch {/* no-throw */}
317
+ });
318
+ return unsubscribe;
319
+ }, []);
320
+
262
321
  // ─── Native Interaction Listener ────────────────────────────────────
263
322
  // Receives high-confidence ScaleBunNativeInteraction events from the native tracker
264
323
  // and forwards them to SessionManager as USER_ACTION events.
@@ -279,7 +338,27 @@ function ScaleBunDebugRoot({
279
338
  lastNativeTouchTsRef.current = Date.now();
280
339
  nativeTrackingConfirmedRef.current = true;
281
340
  const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
282
- const start = (0, _interactionProtocol.nearestInteractionStart)(interactionStartsRef.current, nativeOccurredAt);
341
+ /**
342
+ * RN-6 — correlate, then CONSUME. See interactionProtocol.ts for why
343
+ * the join is on time at all, and why a refusal is the important part.
344
+ *
345
+ * Pruning first is part of the correctness, not tidying: a start older
346
+ * than the tolerance can never be paired, but while it sits in the list
347
+ * it is still counted by the ambiguity test, so a stale entry can turn a
348
+ * clear pairing into a refusal — and the list only grows.
349
+ */
350
+ (0, _interactionProtocol.pruneInteractionStarts)(interactionStartsRef.current, nativeOccurredAt);
351
+ const correlation = (0, _interactionProtocol.correlateInteraction)(interactionStartsRef.current, nativeOccurredAt);
352
+ const start = correlation.start;
353
+ /**
354
+ * ONE-TO-ONE. Without removing the matched start, a second native event
355
+ * arriving within the window claims the SAME js evidence, so one tap's
356
+ * target is stamped onto another tap that never had one — a duplicate
357
+ * identity that reads as two genuine interactions on the same control.
358
+ */
359
+ if (correlation.index >= 0) {
360
+ interactionStartsRef.current.splice(correlation.index, 1);
361
+ }
283
362
 
284
363
  // Cancel only this physical touch's fallback. A single timer used to cancel a
285
364
  // previous rapid tap and silently lose it on devices without native tracking.
@@ -311,11 +390,62 @@ function ScaleBunDebugRoot({
311
390
  interactionId: event.interactionId || start?.interactionId || (0, _interactionProtocol.generateInteractionId)(),
312
391
  interactionProtocol: event.interactionProtocol || _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
313
392
  occurredAt: nativeOccurredAt,
393
+ /**
394
+ * RN-6 — THESE FIVE FIELDS ARE THE ONLY ONES THE JOIN CARRIES, AND
395
+ * THEREFORE THE ONLY ONES A BAD JOIN CAN FALSIFY.
396
+ *
397
+ * Coordinates below come from native and are right regardless. So a
398
+ * mispairing does not produce visibly broken data: it produces heat
399
+ * in the correct position attributed to the wrong control, in the
400
+ * wrong UI state, on the wrong screen. Every field is individually
401
+ * well-formed and nothing downstream can tell.
402
+ *
403
+ * When the correlation is not 'exact' they are left UNDEFINED rather
404
+ * than filled from a best guess. An interaction with no target is
405
+ * honest and visibly incomplete — the accounting lane can count it as
406
+ * unplaceable and say why. An interaction with the WRONG target is
407
+ * indistinguishable from a correct one and silently corrupts every
408
+ * aggregate it enters. `null` is not `0`, and a guess is not a
409
+ * measurement.
410
+ */
314
411
  ui: start?.ui,
315
412
  stateStatus: start?.stateStatus ?? 'not_captured',
316
413
  target: start?.target,
317
414
  targetId: start?.targetId,
415
+ targetSource: start?.targetSource,
318
416
  screenName: start?.screenName,
417
+ screenSource: start?.screenSource ?? 'none',
418
+ targetRect: start?.targetRect,
419
+ /**
420
+ * RN-15 — WHERE IN THE CONTROL, computed where both halves exist: the
421
+ * point comes from the native event and the rect was measured at
422
+ * finger-down, in the same capture base. Pairing them here rather than
423
+ * downstream stops anything later reaching for a different rect.
424
+ */
425
+ ...(() => {
426
+ const lp = (0, _targetGeometry.localPoint)(start?.targetRect ?? null, event.normalizedX, event.normalizedY);
427
+ return lp ? {
428
+ localU: lp.u,
429
+ localV: lp.v
430
+ } : {};
431
+ })(),
432
+ scrollX: start?.scrollX,
433
+ scrollY: start?.scrollY,
434
+ scrollSource: start?.scrollSource ?? 'none',
435
+ itemKey: start?.itemKey,
436
+ itemIndex: start?.itemIndex,
437
+ /**
438
+ * The join's own verdict, shipped with the row so it is auditable
439
+ * rather than a claim made in a comment. 'ambiguous' and 'none' are
440
+ * deliberately distinguished: 'none' means the JS handler never fired
441
+ * (a real capture gap on this platform), 'ambiguous' means it fired
442
+ * more than once too close together to attribute. Those have
443
+ * different fixes, and collapsing them would hide which one is
444
+ * actually happening in production.
445
+ */
446
+ correlation: correlation.outcome,
447
+ correlationDeltaMs: correlation.deltaMs,
448
+ correlationRunnerUpDeltaMs: correlation.runnerUpDeltaMs,
319
449
  emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
320
450
  x: event.rawX,
321
451
  y: event.rawY,
@@ -398,6 +528,16 @@ function ScaleBunDebugRoot({
398
528
  const target = (0, _touchTarget.resolveTouchTarget)(e);
399
529
  let ui;
400
530
  let screenName;
531
+ /**
532
+ * RN-8 — WHERE the screen name came from, carried with the interaction.
533
+ *
534
+ * Production shows 13.8% of Android interactions with a screen and 0.5% on iOS, and
535
+ * those figures cannot distinguish three unrelated causes: nothing was instrumented,
536
+ * a manual beacon was instrumented but never reached interactions, or auto-detection
537
+ * ran and failed. Each has a different owner. Recording the source turns one
538
+ * unactionable percentage into three answerable ones.
539
+ */
540
+ let screenSource = 'none';
401
541
  try {
402
542
  const {
403
543
  uiStateSignature
@@ -408,7 +548,12 @@ function ScaleBunDebugRoot({
408
548
  const {
409
549
  AutoScreenDetector
410
550
  } = require('../navigation/AutoScreenDetector');
411
- screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
551
+ const detector = AutoScreenDetector.getInstance();
552
+ screenName = detector.getCurrentScreen() || undefined;
553
+ /* Read from the SAME call site as the name, so the two can never describe
554
+ different moments — the detector is polled and its answer can change between
555
+ two reads a few milliseconds apart. */
556
+ screenSource = screenName ? detector.getCurrentScreenSource() : 'none';
412
557
  } catch {/* no-throw */}
413
558
  const start = {
414
559
  interactionId: (0, _interactionProtocol.generateInteractionId)(),
@@ -417,9 +562,93 @@ function ScaleBunDebugRoot({
417
562
  y: touch.pageY,
418
563
  locationX: touch.locationX,
419
564
  locationY: touch.locationY,
565
+ /**
566
+ * RN-1 + RN-2 — the identity that is persisted, and it is now code-derived.
567
+ *
568
+ * `describeTouchTarget` returns `testID` -> structural path -> component name,
569
+ * and never a caption or an accessibilityLabel. Before RN-2 it preferred free
570
+ * text, so this line was writing user data ("Pay EGP 1,442", an order row
571
+ * labelled with an email address, a contact's name) into the analytics store —
572
+ * and, because `target` is an aggregation key, splitting one button into one
573
+ * bucket per person at the same time.
574
+ *
575
+ * `targetId` used to be `target?.testID` alone, which is `undefined` in the
576
+ * overwhelming majority of apps — no testIDs authored — so the field the
577
+ * backend keys on was empty almost always. It now falls back to the structural
578
+ * path, with `targetSource` recording WHICH it got.
579
+ */
420
580
  target: (0, _touchTarget.describeTouchTarget)(target),
421
- targetId: target?.testID,
581
+ targetId: (0, _touchTarget.targetIdentity)(target).id,
582
+ /**
583
+ * WHICH KIND of identity this is, because they are not equally trustworthy: a
584
+ * `testID` is a promise from the app's authors that this control is this
585
+ * control; a `path` is an inference that breaks when the tree is refactored.
586
+ * Without this the accounting lane cannot distinguish "the control had no
587
+ * identity" from "the identity is a structural guess", and both would render
588
+ * as an equally confident target.
589
+ */
590
+ targetSource: (0, _touchTarget.targetIdentity)(target).source,
591
+ /* RN-4 — row identity from the app's own keyExtractor; see touchTarget.ts. */
592
+ itemKey: target?.itemKey,
593
+ itemIndex: target?.itemIndex,
594
+ /**
595
+ * RN-13 — the control's rectangle, so `heat ∈ target` is decidable.
596
+ *
597
+ * Read SYNCHRONOUSLY here, in the same tick as the touch, and normalized against
598
+ * the SAME measured root the tap's own coordinates use. Both halves matter:
599
+ *
600
+ * - A callback measurement (`measureInWindow`) would resolve after this handler
601
+ * returns, so the rect attached to a tap could be the previous tap's, with
602
+ * nothing afterwards able to tell. `targetGeometry` refuses to guess: it reads
603
+ * now or reports null.
604
+ * - Normalizing against a window size instead of the root view is off by the
605
+ * status bar on most devices, which would read as "the heat is just outside
606
+ * the button" everywhere rather than as a measurement bug.
607
+ *
608
+ * `measureRoot()` is called first because the cached root rect is populated
609
+ * asynchronously at mount; without it the first taps of a session normalize
610
+ * against nothing and get a null rect for a reason that has nothing to do with
611
+ * the control.
612
+ */
613
+ /**
614
+ * RN-3 — WHERE IN THE CONTENT, not just where on the glass.
615
+ *
616
+ * Two taps at the same screen coordinate one viewport apart are two different
617
+ * controls. Without an offset they merge into one hotspot, and the heatmap shows
618
+ * heavy engagement at a position where, in content terms, nothing in particular
619
+ * is. The offset is also a CACHE dimension — two scroll states of one route must
620
+ * not share a backdrop — so its absence corrupts the picture as well as the count.
621
+ *
622
+ * `ancestors` is innermost-first, so this takes the NEAREST scroller that has a
623
+ * recorded position: a tap inside a horizontal carousel belongs to the carousel's
624
+ * content, not to how far the page behind it had scrolled.
625
+ *
626
+ * Null when no ancestor has one — never zero. Zero is a real scroll position (the
627
+ * top of a list), and reporting it for "unknown" would place every unmeasured tap
628
+ * at the top of its content, indistinguishable from a genuine tap there.
629
+ */
630
+ ...(() => {
631
+ const hit = (0, _scrollContext.nearestRecordedOffset)((target?.ancestors ?? []).map(n => (0, _scrollContext.nativeTagOf)(n)));
632
+ return hit ? {
633
+ scrollX: hit.x,
634
+ scrollY: hit.y,
635
+ scrollSource: 'wrapper'
636
+ } : {
637
+ scrollSource: 'none'
638
+ };
639
+ })(),
640
+ targetRect: (() => {
641
+ measureRoot();
642
+ const root = rootRectRef.current;
643
+ /* null -> undefined: the context field is optional, and an explicit null would
644
+ serialise as a JSON null claiming "measured, and it was nothing". */
645
+ return (0, _targetGeometry.normalizeRect)((0, _targetGeometry.readRectSync)(target?.node), root ? {
646
+ w: root.w,
647
+ h: root.h
648
+ } : null) ?? undefined;
649
+ })(),
422
650
  screenName,
651
+ screenSource,
423
652
  ui,
424
653
  stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
425
654
  emitAutomaticAnalytics: captureAutomaticInteractions
@@ -464,7 +693,21 @@ function ScaleBunDebugRoot({
464
693
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
465
694
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
466
695
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
467
- const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
696
+ /**
697
+ * Resolved ONCE, so `target` and `targetId` cannot disagree about the same tap.
698
+ *
699
+ * The previous shape called `resolveTouchTarget(e)` inline for `target` while
700
+ * `targetId` came only from `start` — so on the fallback path (no JS start, or a
701
+ * refused correlation) the tap carried a description with NO id, and the two fields
702
+ * were computed from different sources. Anything joining on `targetId` therefore
703
+ * saw nothing for a tap that visibly had a target.
704
+ */
705
+ const freshTarget = start ? null : (0, _touchTarget.resolveTouchTarget)(e);
706
+ const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)(freshTarget);
707
+ const identity = start ? {
708
+ id: start.targetId,
709
+ source: start.targetSource
710
+ } : (0, _touchTarget.targetIdentity)(freshTarget);
468
711
  const gestureDetails = {
469
712
  target: tapped,
470
713
  interactionId: start?.interactionId ?? (0, _interactionProtocol.generateInteractionId)(),
@@ -472,8 +715,16 @@ function ScaleBunDebugRoot({
472
715
  occurredAt: start?.occurredAt ?? Date.now(),
473
716
  ui: start?.ui,
474
717
  stateStatus: start?.stateStatus ?? 'not_captured',
475
- targetId: start?.targetId,
718
+ targetId: identity.id,
719
+ targetSource: identity.source,
476
720
  screenName: start?.screenName,
721
+ screenSource: start?.screenSource ?? 'none',
722
+ targetRect: start?.targetRect,
723
+ scrollX: start?.scrollX,
724
+ scrollY: start?.scrollY,
725
+ scrollSource: start?.scrollSource ?? 'none',
726
+ itemKey: start?.itemKey,
727
+ itemIndex: start?.itemIndex,
477
728
  emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
478
729
  x: start?.x ?? touch.pageX,
479
730
  y: start?.y ?? touch.pageY,
@@ -496,6 +747,19 @@ function ScaleBunDebugRoot({
496
747
  const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
497
748
  gestureDetails.normalizedX = nx;
498
749
  gestureDetails.normalizedY = ny;
750
+ /**
751
+ * RN-15 on the JS-fallback lane.
752
+ *
753
+ * Computed here rather than in the literal above because that is where nx/ny first
754
+ * exist. The rect is the SAME one the native lane uses, so one physical tap yields
755
+ * the same local position whichever lane captured it — two lanes disagreeing about
756
+ * where in a control a tap landed would be worse than neither reporting it.
757
+ */
758
+ const lp = (0, _targetGeometry.localPoint)(start?.targetRect ?? null, nx, ny);
759
+ if (lp) {
760
+ gestureDetails.localU = lp.u;
761
+ gestureDetails.localV = lp.v;
762
+ }
499
763
  gestureDetails.normalizedPrecomputed = true;
500
764
  gestureDetails.viewportWidth = rootRect.w;
501
765
  gestureDetails.viewportHeight = rootRect.h;
@@ -679,6 +943,30 @@ function useScaleBunScreen(name) {
679
943
  }
680
944
  } catch {/* no-throw */}
681
945
 
946
+ /**
947
+ * RN-8 — TELL THE DETECTOR TOO, because interactions read their screen from IT.
948
+ *
949
+ * This hook fed SessionManager, PerformanceFeature, ProfilerFeature and JourneyManager
950
+ * — four consumers — and never `AutoScreenDetector`. But `handleTouchStart` resolves an
951
+ * interaction's `screenName` from `AutoScreenDetector.getCurrentScreen()`, so a
952
+ * manually-instrumented app produced perfectly-named SCREEN_VIEW events while every TAP
953
+ * carried no screen at all. `setManualScreen` existed and was called only by tests.
954
+ *
955
+ * That is the whole story for apps the auto-hook cannot help: expo-router, a custom
956
+ * navigator, anything not `@react-navigation/native`. The documented manual escape hatch
957
+ * did not reach the one field that partitions every heatmap.
958
+ *
959
+ * Requires the last-writer-wins fix in `_currentScreen()` to be useful: under the
960
+ * previous rule a manual screen was readable for only 2000 ms, so wiring this alone
961
+ * would have handed interactions a name that expired before most taps happened.
962
+ */
963
+ try {
964
+ const {
965
+ AutoScreenDetector
966
+ } = require('../navigation/AutoScreenDetector');
967
+ AutoScreenDetector.getInstance().setManualScreen(name);
968
+ } catch {/* no-throw */}
969
+
682
970
  // Feed PerformanceFeature (screen context for perf metrics)
683
971
  // NOTE: Only sets screen identity. Does NOT call markScreenLoadStart/End.
684
972
  // AutoScreenLoadCollector handles timing independently via rAF + InteractionManager.
@@ -59,6 +59,34 @@ const ScaleBunScrollView = exports.ScaleBunScrollView = /*#__PURE__*/_react.defa
59
59
  }
60
60
  } catch {/* no-throw */}
61
61
 
62
+ /**
63
+ * RN-3 — record WHERE THIS scroller sits, keyed by its own native tag, so a later
64
+ * tap can say which content was under the finger.
65
+ *
66
+ * OUTSIDE the `isActive` guard above, deliberately. That guard is correct for the
67
+ * gesture tracker, which produces scroll EVENTS and should only do so while
68
+ * recording. This is a different thing: it is the position a tap needs in order to
69
+ * be placed in content rather than on glass, and a tap is captured on the always-on
70
+ * analytics lane even when no recording is active. Gating this the same way would
71
+ * make content depth absent in exactly the sessions the heatmap is built from.
72
+ *
73
+ * The tag is taken from the EVENT's target rather than from this component's ref,
74
+ * so it identifies the scroller that actually moved. With nested scrollers the
75
+ * event may come from an inner one, and keying by the wrapper would file the
76
+ * carousel's offset under the list.
77
+ */
78
+ try {
79
+ const {
80
+ recordScrollOffset,
81
+ nativeTagOf
82
+ } = require('./scrollContext');
83
+ const {
84
+ contentOffset,
85
+ target
86
+ } = event.nativeEvent;
87
+ recordScrollOffset(typeof target === 'number' ? target : nativeTagOf(target), contentOffset.x, contentOffset.y);
88
+ } catch {/* no-throw */}
89
+
62
90
  // Forward to user's onScroll handler
63
91
  if (userOnScroll) {
64
92
  userOnScroll(event);
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.autoInstrumentScrollViews = autoInstrumentScrollViews;
7
+ exports.resetAutoInstrumentForTests = resetAutoInstrumentForTests;
8
+ var _scrollContext = require("./scrollContext");
9
+ /**
10
+ * RN-3, ZERO-CONFIG — make every ScrollView report its offset, not just the wrapped ones.
11
+ *
12
+ * THE PROBLEM THIS SOLVES. Scroll offsets reach the SDK from `ScaleBunScrollView` and nowhere
13
+ * else, so an app using React Native's own `<ScrollView>` — which is most apps — produces taps
14
+ * with no content depth at all. Two taps at the same screen coordinate one viewport apart then
15
+ * merge into one hotspot, and the heatmap shows heavy engagement at a position where, in content
16
+ * terms, nothing in particular is. Asking every integration to swap its scroll views is a real
17
+ * migration cost and will never reach the long tail.
18
+ *
19
+ * WHY IT IS OFF BY DEFAULT, and why that is not timidity. This patches React Native's own module
20
+ * exports. Every scroll view in the host app — including ones inside third-party libraries the
21
+ * app did not write — starts routing through an SDK component. The failure mode of getting that
22
+ * wrong is not a wrong number in a dashboard, it is the host's UI behaving differently because an
23
+ * analytics SDK replaced a primitive. That is a decision an integrator makes with their eyes open,
24
+ * not a default they discover afterwards.
25
+ *
26
+ * THE PRECEDENT, AND ITS LESSON. The SDK already patches `@react-navigation/native` this way
27
+ * (`AutoScreenDetector._autoHookReactNavigation`). That hook shipped BROKEN: it assigned to module
28
+ * exports that are getters, which throws in strict mode, inside a `catch` that discarded the
29
+ * error. It reported success, captured no ref, and the whole screen-name pipeline stayed empty
30
+ * with nothing to indicate it. So this uses the same defence that fixed it — define the property
31
+ * rather than assign to it, then READ IT BACK — and refuses to claim success it has not verified.
32
+ *
33
+ * WHAT IT DELIBERATELY DOES NOT PATCH. `FlatList` and `SectionList` render a `ScrollView`
34
+ * internally, so patching that one export covers them without touching three. Fewer patch points
35
+ * is fewer ways to be wrong, and RN-4's row identity comes off the fiber regardless of which
36
+ * component rendered it.
37
+ */
38
+
39
+ let alreadyPatched = false;
40
+
41
+ /**
42
+ * Replace an export on a module whose exports are GETTERS.
43
+ *
44
+ * React Native's index is compiled re-exports, which bundlers emit as accessors with a getter and
45
+ * no setter. Plain assignment throws in strict mode and silently does nothing in sloppy mode.
46
+ * Defining the property works where assigning does not, and the read-back is what turns a future
47
+ * failure into a visible one instead of a silent no-op.
48
+ */
49
+ function redefine(target, key, value) {
50
+ try {
51
+ Object.defineProperty(target, key, {
52
+ configurable: true,
53
+ enumerable: true,
54
+ writable: true,
55
+ value
56
+ });
57
+ return target[key] === value;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Wrap a scroll component so its offsets are recorded, forwarding everything else untouched.
65
+ *
66
+ * The host's own `onScroll` is called AFTER the offset is recorded and is never replaced: an SDK
67
+ * that swallows a host's handler breaks the app, which is a far worse outcome than missing
68
+ * analytics. Recording first also means a throwing host handler cannot cost us the measurement.
69
+ */
70
+ function instrument(Original, React) {
71
+ const Wrapped = React.forwardRef((props, ref) => {
72
+ const onScroll = event => {
73
+ try {
74
+ const target = event?.nativeEvent?.target;
75
+ const offset = event?.nativeEvent?.contentOffset;
76
+ if (offset) {
77
+ (0, _scrollContext.recordScrollOffset)(typeof target === 'number' ? target : (0, _scrollContext.nativeTagOf)(target), offset.x, offset.y);
78
+ }
79
+ } catch {
80
+ /* no-throw: a scroll offset is never worth breaking a gesture over */
81
+ }
82
+ try {
83
+ props?.onScroll?.(event);
84
+ } catch (e) {
85
+ /* The host's handler is the host's problem, but it must still run and still throw where
86
+ they can see it — rethrow on a later tick so this wrapper is not in the stack. */
87
+ setTimeout(() => {
88
+ throw e;
89
+ }, 0);
90
+ }
91
+ };
92
+ /**
93
+ * `scrollEventThrottle` defaults to 16 only when the host has not chosen one. Overriding a
94
+ * host's explicit value would change how often their own handler runs — a behaviour change
95
+ * for a measurement, which is exactly the trade this module is not allowed to make.
96
+ */
97
+ const throttle = props?.scrollEventThrottle ?? 16;
98
+ return React.createElement(Original, {
99
+ ...props,
100
+ scrollEventThrottle: throttle,
101
+ onScroll,
102
+ ref
103
+ });
104
+ });
105
+ Wrapped.displayName = 'ScaleBunAutoScrollView';
106
+ return Wrapped;
107
+ }
108
+
109
+ /**
110
+ * Patch React Native's `ScrollView` export so every scroll view reports its offset.
111
+ *
112
+ * Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
113
+ * than claiming a success it did not verify.
114
+ */
115
+ function autoInstrumentScrollViews() {
116
+ if (alreadyPatched) return {
117
+ installed: true
118
+ };
119
+ try {
120
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
121
+ const RN = require('react-native');
122
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
123
+ const React = require('react');
124
+ const Original = RN.ScrollView;
125
+ if (typeof Original !== 'function' && typeof Original !== 'object') {
126
+ return {
127
+ installed: false,
128
+ reason: 'react-native has no ScrollView export to wrap'
129
+ };
130
+ }
131
+ if (Original.__scalebunInstrumented) {
132
+ alreadyPatched = true;
133
+ return {
134
+ installed: true
135
+ };
136
+ }
137
+ const Wrapped = instrument(Original, React);
138
+ Wrapped.__scalebunInstrumented = true;
139
+ if (!redefine(RN, 'ScrollView', Wrapped)) {
140
+ return {
141
+ installed: false,
142
+ reason: "react-native's ScrollView export could not be replaced — it is a getter with no " + 'configurable descriptor on this bundler. Use ScaleBunScrollView explicitly instead.'
143
+ };
144
+ }
145
+ alreadyPatched = true;
146
+ return {
147
+ installed: true
148
+ };
149
+ } catch (e) {
150
+ return {
151
+ installed: false,
152
+ reason: e?.message ?? 'unknown failure'
153
+ };
154
+ }
155
+ }
156
+
157
+ /** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
158
+ function resetAutoInstrumentForTests() {
159
+ alreadyPatched = false;
160
+ }
161
+ //# sourceMappingURL=autoInstrumentScroll.js.map