@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
@@ -169,7 +169,7 @@ class ScaleBunFacade {
169
169
  const realQueue = coreContainer.get('PersistentQueue');
170
170
  const buffered = this._preInitBuffer.popBatch(this._preInitBuffer.length());
171
171
  if (buffered.length > 0) {
172
- __DEV__ && logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
172
+ logger.debug(`[ScaleBun] Draining ${buffered.length} pre-init events into persistent queue`);
173
173
  for (const envelope of buffered) {
174
174
  realQueue.add(envelope);
175
175
  }
@@ -209,17 +209,11 @@ class ScaleBunFacade {
209
209
  /**
210
210
  * Boot the OTA orchestrator when the init config asks for it.
211
211
  *
212
- * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
213
- * enforcement is reachable from configuration alone. The list form exists
214
- * for key ROTATION: a build pinning [old, new] keeps verifying while the
215
- * server moves to the new key, so replacing a key never needs an emergency
216
- * store release. Both fields merge (deduplicated) into one pinned set.
217
- *
218
- * Verification comes from `ota.verifySignature` when supplied, else the
219
- * built-in @noble-based verifier (optional peers). Pinning keys with
220
- * NEITHER available is fail-CLOSED by design (signature.ts) — an update
221
- * that cannot be verified is not installed. We say that out loud rather
222
- * than letting the app discover it as a silent no-update condition.
212
+ * Signature enforcement is no longer configured from JS. Signed releases are
213
+ * verified and staged in NATIVE code against RSA keys embedded in the app
214
+ * package (SB-OTA-RSA-SHA256-V1); unsigned releases keep working on SHA-256
215
+ * integrity. There are no signing-key or `verifySignature` options to pass
216
+ * through the host embeds its trusted keys natively.
223
217
  */
224
218
  _maybeStartOta(rawConfig) {
225
219
  const ota = rawConfig?.ota;
@@ -231,42 +225,12 @@ class ScaleBunFacade {
231
225
  const {
232
226
  otaOrchestrator
233
227
  } = require('../features/ota/OtaOrchestrator');
234
- const rawSingle = ota.publicSigningKey;
235
- const rawList = ota.publicSigningKeys;
236
- const publicKeys = Array.from(new Set([...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []), ...(Array.isArray(rawList) ? rawList : [])].filter(k => typeof k === 'string' && k.trim().length > 0)));
237
- // Signing was REQUESTED if a usable key exists, or the host passed a
238
- // keys array at all — an empty one included. `publicSigningKeys: []`
239
- // is a broken attempt to enable signing, and it must flow through to
240
- // signature.ts's fail-closed handling, not silently disable signing.
241
- const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
242
- const verifier = ota.verifySignature;
243
- if (signingRequested && typeof verifier !== 'function') {
244
- // A host verifier is optional since the built-in one landed — the
245
- // old unconditional warning here told every correctly-configured
246
- // app that its updates would be rejected. Warn only when neither
247
- // verifier can actually be resolved.
248
- // eslint-disable-next-line @typescript-eslint/no-var-requires
249
- const {
250
- getBuiltinVerifier
251
- } = require('../features/ota/crypto/builtinVerifier');
252
- if (!getBuiltinVerifier()) {
253
- logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
254
- }
255
- }
256
228
  // `healthyAfterMs` is the field BootGuardConfig actually declares.
257
229
  // This passed `healthyTimeoutMs`, which nothing reads, so a host that
258
230
  // tuned the boot-guard window silently got the 10s default instead.
259
231
  // Both spellings are accepted so the older one keeps working.
260
232
  const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
261
233
  otaOrchestrator.init({
262
- ...(signingRequested ? {
263
- signature: {
264
- // Single key stays a plain string — the shape every
265
- // existing consumer and test already handles.
266
- publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
267
- verifier
268
- }
269
- } : {}),
270
234
  ...(healthyAfterMs !== undefined ? {
271
235
  healthyAfterMs
272
236
  } : {})
@@ -333,7 +297,9 @@ class ScaleBunFacade {
333
297
  }
334
298
  const clientKey = this._clientKey;
335
299
  const apiUrl = this._apiBaseUrl;
336
- if (!clientKey || !apiUrl) return;
300
+ if (!clientKey || !apiUrl) {
301
+ return;
302
+ }
337
303
  this._otaCheckInFlight = true;
338
304
  try {
339
305
  const info = await bridgeAdapter.getDeviceInfo();
@@ -390,7 +356,7 @@ class ScaleBunFacade {
390
356
  if (!dbgConfig) return;
391
357
  dbgConfig.__skipDeprecationWarning = true;
392
358
  this.enableDebug(dbgConfig);
393
- __DEV__ && logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
359
+ logger.info('[ScaleBun] Debug auto-connected via init({ debug }).');
394
360
  } catch (err) {
395
361
  logger.warn('[ScaleBun] Auto debug connection failed:', err?.message);
396
362
  }
@@ -407,7 +373,7 @@ class ScaleBunFacade {
407
373
  const apiBaseUrl = this._apiBaseUrl;
408
374
  if (!enabled || !appId || !clientKey || !apiBaseUrl) {
409
375
  if (enabled && !appId) {
410
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
376
+ logger.info('[ScaleBun] Envelope event tracking disabled — no appId in init config.');
411
377
  }
412
378
  return;
413
379
  }
@@ -459,7 +425,7 @@ class ScaleBunFacade {
459
425
  onEvent: (eventName, revenue) => this._skanManager?.onEvent(eventName, revenue)
460
426
  });
461
427
  this._eventTracker.start();
462
- __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
428
+ logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
463
429
 
464
430
  // Complete the device context from the native bridge.
465
431
  //
@@ -1141,7 +1107,7 @@ class ScaleBunFacade {
1141
1107
  const cfg = await (this._getEngageTransport()?.fetchEngageConfig(context) ?? Promise.resolve(null));
1142
1108
  const messages = cfg?.inAppMessages ?? [];
1143
1109
  const previews = messages.filter(m => m.preview === true || m.isTest === true);
1144
- __DEV__ && logger.info(`[Engage] debugFetchInAppMessages → ${messages.length} message(s), ${previews.length} test preview(s)`);
1110
+ logger.info(`[Engage] debugFetchInAppMessages → ${messages.length} message(s), ${previews.length} test preview(s)`);
1145
1111
  return messages;
1146
1112
  },
1147
1113
  /** Upload a survey/NPS/feedback response (idempotent, rides the outbox). */
@@ -1426,7 +1392,7 @@ class ScaleBunFacade {
1426
1392
  logger.warn('SDK not initialized. Event dropped:', name);
1427
1393
  return;
1428
1394
  }
1429
- __DEV__ && logger.debug('Tracking event:', name);
1395
+ logger.debug('Tracking event:', name);
1430
1396
 
1431
1397
  // Fan this event out to the Engage trigger engine. Same single funnel,
1432
1398
  // extra observer — lets behavioral triggers (custom_event, failed_action,
@@ -1599,7 +1565,7 @@ class ScaleBunFacade {
1599
1565
  await Promise.all([this._eventTracker?.flush() ?? Promise.resolve(), adapter?.flushAnalytics() ?? Promise.resolve()]);
1600
1566
  return;
1601
1567
  }
1602
- __DEV__ && logger.debug('Flushing events from persistent queue...');
1568
+ logger.debug('Flushing events from persistent queue...');
1603
1569
  const httpClient = coreContainer.get('HttpClient');
1604
1570
  await flushQueue(this._getQueue(), httpClient, getDeviceId());
1605
1571
  });
@@ -1622,9 +1588,9 @@ class ScaleBunFacade {
1622
1588
  const bootstrapPerfFeature = _getPerformanceFeature();
1623
1589
  if (bootstrapPerfFeature) {
1624
1590
  this._performanceFeature = bootstrapPerfFeature;
1625
- __DEV__ && logger.debug('Performance feature linked from bootstrap');
1591
+ logger.debug('Performance feature linked from bootstrap');
1626
1592
  }
1627
- __DEV__ && logger.info('Debug enabled');
1593
+ logger.info('Debug enabled');
1628
1594
  });
1629
1595
  }
1630
1596
 
@@ -1776,14 +1742,14 @@ class ScaleBunFacade {
1776
1742
  return false;
1777
1743
  }
1778
1744
  sm.startSession(metadata);
1779
- __DEV__ && logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
1745
+ logger.info('session.start() ✅ sessionId:', sm.currentSessionId);
1780
1746
  return true;
1781
1747
  },
1782
1748
  end: reason => {
1783
1749
  const sm = SessionManager.getExistingInstance();
1784
1750
  if (!sm || !sm.isActive) return false;
1785
1751
  sm.endSession(reason);
1786
- __DEV__ && logger.info('session.end() ✅');
1752
+ logger.info('session.end() ✅');
1787
1753
  return true;
1788
1754
  },
1789
1755
  emitEvent: (type, data) => {
@@ -1841,7 +1807,7 @@ class ScaleBunFacade {
1841
1807
  ...this._privacyConfig,
1842
1808
  ...config
1843
1809
  };
1844
- __DEV__ && logger.debug('[report] Privacy config updated');
1810
+ logger.debug('[report] Privacy config updated');
1845
1811
  },
1846
1812
  /** Set branding for exported reports. */
1847
1813
  setBranding: config => {
@@ -1849,7 +1815,7 @@ class ScaleBunFacade {
1849
1815
  ...this._brandingConfig,
1850
1816
  ...config
1851
1817
  };
1852
- __DEV__ && logger.debug('[report] Branding config updated');
1818
+ logger.debug('[report] Branding config updated');
1853
1819
  },
1854
1820
  /** Get current privacy config. */
1855
1821
  getPrivacy: () => ({
@@ -1904,9 +1870,9 @@ class ScaleBunFacade {
1904
1870
  AutoScreenDetector
1905
1871
  } = require('../features/navigation/AutoScreenDetector');
1906
1872
  AutoScreenDetector.getInstance().setNavigationRef(ref);
1907
- __DEV__ && logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1873
+ logger.info('[ScaleBun] Navigation ref set → auto screen detection active');
1908
1874
  } catch (err) {
1909
- __DEV__ && logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
1875
+ logger.debug('[ScaleBun] Navigation ref stashed (detector not ready):', err?.message);
1910
1876
  }
1911
1877
  });
1912
1878
  }
@@ -86,15 +86,14 @@ export interface ScaleBunConfig {
86
86
  redactBodies?: boolean;
87
87
  };
88
88
  captureInteractionHeatmap: boolean;
89
+ /** RN-3 zero-config scroll instrumentation. OFF by default — it changes what the HOST renders. */
90
+ autoInstrumentScrollViews: boolean;
89
91
  /** Opt-in: capture console.* into the Logs lane in SaaS mode. Default false. */
90
92
  captureConsoleLogs: boolean;
91
93
  ota?: {
92
94
  enabled: boolean;
93
95
  checkOnForeground: boolean;
94
96
  channelOverride?: string;
95
- publicSigningKey?: string;
96
- /** Additional pinned keys for rotation; unioned with publicSigningKey. */
97
- publicSigningKeys?: string[];
98
97
  mandatoryBlocksUi: boolean;
99
98
  };
100
99
  }
@@ -8,5 +8,5 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export declare const SDK_VERSION = "1.13.0";
11
+ export declare const SDK_VERSION = "2.0.1";
12
12
  //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * RN-3, ZERO-CONFIG — make every ScrollView report its offset, not just the wrapped ones.
3
+ *
4
+ * THE PROBLEM THIS SOLVES. Scroll offsets reach the SDK from `ScaleBunScrollView` and nowhere
5
+ * else, so an app using React Native's own `<ScrollView>` — which is most apps — produces taps
6
+ * with no content depth at all. Two taps at the same screen coordinate one viewport apart then
7
+ * merge into one hotspot, and the heatmap shows heavy engagement at a position where, in content
8
+ * terms, nothing in particular is. Asking every integration to swap its scroll views is a real
9
+ * migration cost and will never reach the long tail.
10
+ *
11
+ * WHY IT IS OFF BY DEFAULT, and why that is not timidity. This patches React Native's own module
12
+ * exports. Every scroll view in the host app — including ones inside third-party libraries the
13
+ * app did not write — starts routing through an SDK component. The failure mode of getting that
14
+ * wrong is not a wrong number in a dashboard, it is the host's UI behaving differently because an
15
+ * analytics SDK replaced a primitive. That is a decision an integrator makes with their eyes open,
16
+ * not a default they discover afterwards.
17
+ *
18
+ * THE PRECEDENT, AND ITS LESSON. The SDK already patches `@react-navigation/native` this way
19
+ * (`AutoScreenDetector._autoHookReactNavigation`). That hook shipped BROKEN: it assigned to module
20
+ * exports that are getters, which throws in strict mode, inside a `catch` that discarded the
21
+ * error. It reported success, captured no ref, and the whole screen-name pipeline stayed empty
22
+ * with nothing to indicate it. So this uses the same defence that fixed it — define the property
23
+ * rather than assign to it, then READ IT BACK — and refuses to claim success it has not verified.
24
+ *
25
+ * WHAT IT DELIBERATELY DOES NOT PATCH. `FlatList` and `SectionList` render a `ScrollView`
26
+ * internally, so patching that one export covers them without touching three. Fewer patch points
27
+ * is fewer ways to be wrong, and RN-4's row identity comes off the fiber regardless of which
28
+ * component rendered it.
29
+ */
30
+ interface PatchResult {
31
+ /** True only when the replacement was verified by reading it back. */
32
+ installed: boolean;
33
+ /** Why not, when it did not install — for a diagnostic the host can act on. */
34
+ reason?: string;
35
+ }
36
+ /**
37
+ * Patch React Native's `ScrollView` export so every scroll view reports its offset.
38
+ *
39
+ * Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
40
+ * than claiming a success it did not verify.
41
+ */
42
+ export declare function autoInstrumentScrollViews(): PatchResult;
43
+ /** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
44
+ export declare function resetAutoInstrumentForTests(): void;
45
+ export {};
46
+ //# sourceMappingURL=autoInstrumentScroll.d.ts.map
@@ -8,14 +8,152 @@ export interface InteractionStartContext {
8
8
  y: number;
9
9
  target?: string;
10
10
  targetId?: string;
11
+ /**
12
+ * WHICH kind of identity `targetId` is: an authored `testID`, a structural component path, or
13
+ * a bare component name.
14
+ *
15
+ * Carried because they are not equally trustworthy — a testID is a promise from the app's
16
+ * authors, a path is an inference that breaks when the tree is refactored — and a consumer
17
+ * that cannot tell them apart renders a refactor-fragile guess as a confident target.
18
+ */
19
+ targetSource?: 'testID' | 'path' | 'component' | 'none';
11
20
  screenName?: string;
21
+ /** RN-8: where `screenName` came from, so low coverage is diagnosable rather than merely visible. */
22
+ screenSource?: 'manual' | 'navigation' | 'none';
23
+ /**
24
+ * RN-13: the target control's rectangle as fractions of the capture base, or undefined when it
25
+ * could not be measured synchronously. Comparable to the tap's own normalized point, which is
26
+ * what makes 'heat is on this control' checkable rather than assumed.
27
+ */
28
+ targetRect?: {
29
+ x: number;
30
+ y: number;
31
+ width: number;
32
+ height: number;
33
+ };
34
+ /**
35
+ * RN-3: the content offset of the nearest ancestor scroller at finger-down, so two taps at the
36
+ * same screen coordinate one viewport apart do not merge. Absent when no ancestor reported one
37
+ * — never defaulted to 0, which is a real scroll position (the top of a list).
38
+ */
39
+ scrollX?: number;
40
+ scrollY?: number;
41
+ /** Whether a scroll offset was available at all. 'none' means no wrapped scroller was an ancestor. */
42
+ scrollSource?: 'wrapper' | 'none';
43
+ /**
44
+ * RN-4: the list row's key, taken from the app's own `keyExtractor` via the cell's `cellKey`
45
+ * prop. Distinguishes two taps that share a RECYCLED native view — the same slot showing a
46
+ * different row after scrolling — which would otherwise merge into one hotspot.
47
+ */
48
+ itemKey?: string;
49
+ /** The row's index at render time. Same index + different key = a recycled slot. */
50
+ itemIndex?: number;
51
+ /**
52
+ * RN-15: the tap's position INSIDE the target control — (0,0) its top-left, (1,1) its
53
+ * bottom-right. Resolution-independent, so one control is one distribution across every device
54
+ * size rather than a separate cloud of heat per screen width.
55
+ *
56
+ * NOT clamped. A value outside 0..1 means the tap landed outside the control's visual box and
57
+ * was still attributed to it, which is what `hitSlop` does — and a control whose taps cluster
58
+ * outside its own bounds is drawn too small, which is a finding rather than noise.
59
+ */
60
+ localU?: number;
61
+ localV?: number;
12
62
  ui?: string;
13
63
  stateStatus: InteractionStateStatus;
14
64
  emitAutomaticAnalytics: boolean;
15
65
  }
16
66
  export declare function generateInteractionId(): string;
67
+ /**
68
+ * RN-6 — THE CORRELATION CONTRACT.
69
+ *
70
+ * WHAT IS BEING JOINED, AND WHY IT IS HARD. A physical tap is observed twice: natively, by
71
+ * a window callback that sees ACTION_DOWN/UP and produces the coordinates; and in JS, by a
72
+ * capture-phase touch handler that produces the IDENTITY — which control was hit, what UI
73
+ * state was declared, which screen. Both sides mint their own id and neither can derive
74
+ * the other's: native builds `ixn-a-<process nonce>-<MotionEvent ts>` from a clock JS
75
+ * cannot read, and JS builds `ixj-<Date.now()>-<random>`. So the two observations have to
76
+ * be JOINED, and the only shared quantity is time.
77
+ *
78
+ * WHY THE OLD JOIN WAS DANGEROUS RATHER THAN MERELY APPROXIMATE. It took whichever start
79
+ * was nearest in time within ±1500 ms and used it unconditionally. The fields it carries
80
+ * across are `target`, `targetId`, `ui`, `stateStatus` and `screenName`; the coordinates
81
+ * come from the native side and are always right. So a wrong pairing does not produce
82
+ * obviously broken data — it produces heat in the CORRECT position attributed to the
83
+ * WRONG control, in the wrong UI state, on the wrong screen. Nothing downstream can
84
+ * detect that, because every field is individually well-formed.
85
+ *
86
+ * 1500 ms is a very long time on a touch surface. A deliberate double-tap is 150–300 ms
87
+ * apart; an impatient tapper is faster. So "nearest within 1500 ms" routinely had more
88
+ * than one candidate, and picked one with no record that it had a choice.
89
+ *
90
+ * WHAT THIS REPLACES IT WITH — three outcomes instead of one, and a refusal:
91
+ *
92
+ * 'exact' one candidate is clearly closest: the runner-up is at least
93
+ * `marginMs` further away. The join is used.
94
+ * 'ambiguous' two candidates are within `marginMs` of each other, so choosing
95
+ * between them is a coin flip. The join is REFUSED and the caller must
96
+ * emit the interaction WITHOUT js-derived identity. A tap with no target
97
+ * is honest and visibly incomplete; a tap with the wrong target is
98
+ * indistinguishable from a correct one and corrupts every aggregate it
99
+ * enters.
100
+ * 'none' nothing within tolerance. Same refusal, different reason — worth
101
+ * separating because 'none' means the JS handler never fired (a real
102
+ * capture gap) while 'ambiguous' means it fired too often to attribute.
103
+ *
104
+ * It also reports `deltaMs` and `runnerUpDeltaMs` so the outcome is auditable after the
105
+ * fact rather than being a claim this comment makes.
106
+ *
107
+ * WHAT IT DELIBERATELY DOES NOT DO: use position to disambiguate. The native event carries
108
+ * raw px in the capture view and normalized 0..1 coordinates; the JS start carries its own
109
+ * x/y in a different space (dp in the root view). Comparing them needs a proven scale
110
+ * factor between the two, and getting it wrong would REJECT valid pairings — strictly
111
+ * worse than today. That calibration needs a device run, so it is a follow-up, not a
112
+ * guess made here.
113
+ */
114
+ export type CorrelationOutcome = 'exact' | 'ambiguous' | 'none';
115
+ export interface CorrelationResult<T> {
116
+ /** Present only when `outcome` is 'exact'. Never a best-guess. */
117
+ start?: T;
118
+ /** Index into the input array, so the caller can consume it. -1 unless 'exact'. */
119
+ index: number;
120
+ outcome: CorrelationOutcome;
121
+ /** Distance to the chosen (or best rejected) candidate; null when there was none. */
122
+ deltaMs: number | null;
123
+ /** Distance to the second candidate — what makes 'ambiguous' auditable. */
124
+ runnerUpDeltaMs: number | null;
125
+ }
126
+ /**
127
+ * How far apart a native event and a JS start may be and still be the same touch.
128
+ *
129
+ * Unchanged at 1500 ms from the previous implementation, deliberately: narrowing it is a
130
+ * separate, measurable decision about how late the JS handler can run on a slow device,
131
+ * and bundling it into this change would make any regression impossible to attribute.
132
+ */
133
+ export declare const CORRELATION_TOLERANCE_MS = 1500;
134
+ /**
135
+ * How much closer the winner must be than the runner-up to count as identified.
136
+ *
137
+ * 100 ms because that is comfortably below the fastest deliberate double-tap (~150 ms):
138
+ * when two candidate starts are within 100 ms of each other in distance, the native event
139
+ * sits effectively between them and the "nearest" one is a coin flip.
140
+ */
141
+ export declare const CORRELATION_MARGIN_MS = 100;
17
142
  /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
18
- export declare function nearestInteractionStart<T extends InteractionStartContext>(starts: readonly T[], occurredAt: number, toleranceMs?: number): T | undefined;
143
+ export declare function correlateInteraction<T extends InteractionStartContext>(starts: readonly T[], occurredAt: number, toleranceMs?: number, marginMs?: number): CorrelationResult<T>;
144
+ /**
145
+ * Drop starts that can no longer match anything, in place.
146
+ *
147
+ * WHY IT MATTERS BEYOND MEMORY. A start older than the tolerance is unreachable, but while
148
+ * it sits in the list it is still a CANDIDATE for the ambiguity test — so a stale entry can
149
+ * turn a perfectly clear pairing into a refusal, and an accumulating list makes that more
150
+ * likely the longer a session runs. Pruning is therefore part of the correctness of the
151
+ * join, not housekeeping.
152
+ *
153
+ * Mutates rather than returning a copy because the caller holds it in a ref that the native
154
+ * listener and the touch handler both read.
155
+ */
156
+ export declare function pruneInteractionStarts<T extends InteractionStartContext>(starts: T[], now: number, toleranceMs?: number): number;
19
157
  /** Analytics is a projection of the same evidence; no second click is invented. */
20
158
  export declare function automaticInteractionProperties(payload: Record<string, unknown>, screenName: string | undefined, canonicalMirror: boolean): Record<string, unknown>;
21
159
  //# sourceMappingURL=interactionProtocol.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * RN-3 — WHERE IN THE CONTENT A TAP HAPPENED, not just where on the glass.
3
+ *
4
+ * THE DEFECT. A tap carries screen coordinates. Two taps at the SAME screen coordinate, one
5
+ * viewport apart in a scrolling list, are two different controls — and with no scroll offset they
6
+ * are indistinguishable, so they merge into one hotspot. The heatmap then shows heavy engagement
7
+ * at a position where, in content terms, nothing in particular is.
8
+ *
9
+ * That is not a rendering nicety. Scroll offset is a PLACEMENT dimension (which content was under
10
+ * the finger) and a CACHE dimension (two states of one route that must not share a backdrop), so
11
+ * its absence corrupts both the aggregate and the picture drawn beneath it.
12
+ *
13
+ * WHY A REGISTRY RATHER THAN A SINGLE NUMBER. `ScrollTracker` already accumulates
14
+ * `previousOffsetX/Y`, but as ONE global pair with no idea which scroller moved. A screen with a
15
+ * vertical list containing a horizontal carousel has two scrollers, and whichever fired last
16
+ * overwrites the other — so the vertical position of a tap inside the carousel is whatever the
17
+ * carousel's horizontal offset happened to leave behind. A single global scrollY cannot express a
18
+ * nested scroller, and silently reports a wrong number rather than none.
19
+ *
20
+ * So offsets are keyed by the scroller's NATIVE TAG, which is the one identity both ends of this
21
+ * can see: the wrapper holds a ref to its own ScrollView, and the touch handler's fiber walk
22
+ * passes the same host instances on its way up. Matching on anything else — a name, an index, a
23
+ * generated id — would require the two sides to agree on a convention, and a convention that can
24
+ * drift is a join that can silently attach the wrong scroller's offset.
25
+ *
26
+ * WHAT THIS DELIBERATELY DOES NOT DO: guess. If no ancestor of the tapped node has a recorded
27
+ * offset, the answer is null, not zero. Zero is a real scroll position — the top of a list — and
28
+ * reporting it for "we do not know" would place every unmeasured tap at the top of its content,
29
+ * which is both wrong and indistinguishable from a genuine tap there.
30
+ *
31
+ * KNOWN LIMIT, stated because it bounds what this can deliver: offsets only arrive from
32
+ * `ScaleBunScrollView`, which an app has to opt into. A plain `<ScrollView>` reports nothing, so
33
+ * `scrollSource` is `'none'` and the tap has no content depth. Making it automatic means patching
34
+ * React Native's own exports — the SDK already does that for `@react-navigation/native` — and that
35
+ * is a decision with a much larger blast radius, not one to make silently inside a data fix.
36
+ */
37
+ export interface ScrollOffset {
38
+ x: number;
39
+ y: number;
40
+ /** When it was recorded; used only to expire entries, never reported. */
41
+ at: number;
42
+ }
43
+ /**
44
+ * How long a recorded offset stays usable.
45
+ *
46
+ * A scroller that has not moved in this long may well have been unmounted — a list on a screen the
47
+ * user left — and its stale offset must not be attached to a tap on a DIFFERENT screen that happens
48
+ * to reuse the same native tag. Tags are recycled by the platform, which makes this a correctness
49
+ * bound rather than a memory one.
50
+ *
51
+ * Five minutes is far longer than any plausible gap between scrolling a list and tapping in it,
52
+ * and far shorter than a session.
53
+ */
54
+ export declare const SCROLL_OFFSET_TTL_MS: number;
55
+ /**
56
+ * The native tag of a host instance, across the shapes React Native has used.
57
+ *
58
+ * Fabric exposes `__nativeTag`; the old architecture used `_nativeTag`. Probing both means this
59
+ * keeps working on either architecture, and returns null rather than throwing on anything else —
60
+ * a scroll offset is never worth a crash.
61
+ */
62
+ export declare function nativeTagOf(node: unknown): number | null;
63
+ /** Record where a scroller currently sits. Called on every scroll event of a wrapped scroller. */
64
+ export declare function recordScrollOffset(tag: number | null, x: number, y: number, now?: number): void;
65
+ /**
66
+ * The offset of the NEAREST ancestor scroller that has one, given the tapped node's ancestor tags
67
+ * ordered innermost first.
68
+ *
69
+ * Nearest wins because that is the scroller the content under the finger actually moved with. A
70
+ * tap inside a horizontal carousel inside a vertical list belongs to the carousel's content
71
+ * position; attributing it to the outer list would place it by how far the PAGE had scrolled,
72
+ * which is the merge this whole module exists to prevent.
73
+ *
74
+ * Returns null when no ancestor has a recorded offset — never a zero.
75
+ */
76
+ export declare function nearestRecordedOffset(ancestorTags: readonly (number | null)[], now?: number): {
77
+ x: number;
78
+ y: number;
79
+ tag: number;
80
+ } | null;
81
+ /** Forget everything. Called between sessions so one recording cannot inherit another's scrollers. */
82
+ export declare function clearScrollOffsets(): void;
83
+ /** How many scrollers are currently tracked — for the leak assertion in tests. */
84
+ export declare function trackedScrollerCount(): number;
85
+ //# sourceMappingURL=scrollContext.d.ts.map
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RN-13 — THE TARGET'S RECTANGLE, without which "placed on Add to Cart" is unprovable.
3
+ *
4
+ * WHAT IS MISSING WITHOUT IT. A tap carries a point and an identity. That is enough to say "this
5
+ * tap resolved to the control named `checkout-cta`" and NOT enough to say the heat is actually ON
6
+ * that control — those are different claims, and only the second is what a heatmap draws. With no
7
+ * rect, an off-by-a-status-bar coordinate transform, a stale layout, or a mispaired identity all
8
+ * produce output that looks correct: a marker somewhere on the screen, labelled with a real
9
+ * control's name. The rect is what makes `heat ∈ target` decidable rather than assumed, which is
10
+ * why the plan moved this to P0 and ships it WITH semantic identity rather than after.
11
+ *
12
+ * WHY A SYNCHRONOUS MEASUREMENT IS THE WHOLE DESIGN CONSTRAINT. The tap handler runs synchronously
13
+ * and the interaction is emitted shortly after. React Native's classic measurement APIs
14
+ * (`measure`, `measureInWindow`) are CALLBACK-based, so a rect requested at finger-down arrives
15
+ * after the handler has returned and possibly after the event has been emitted. An implementation
16
+ * built on them would attach whichever rect happened to have resolved in time — sometimes the
17
+ * right one, sometimes the previous tap's, with no way to tell the two apart afterwards. That is
18
+ * the failure this module refuses to have.
19
+ *
20
+ * The New Architecture's host components expose `unstable_getBoundingClientRect()`, which returns
21
+ * a DOMRect SYNCHRONOUSLY. So the rect is either read in the same tick as the touch, or it is
22
+ * null. There is no third option and no best-effort.
23
+ *
24
+ * THE `unstable_` PREFIX IS LOAD-BEARING and is why every access here is a probe rather than a
25
+ * call. The method may be renamed when it stabilises, may be absent on the old architecture
26
+ * entirely, and may throw on a node that has been unmounted between the touch and the read. Each
27
+ * of those degrades to `null` — the SDK reports that it could not measure, which is a fact, rather
28
+ * than a rectangle it invented, which would be indistinguishable from a measured one.
29
+ */
30
+ /** A rectangle in the same coordinate space as `pageX`/`pageY`: the RN root view. */
31
+ export interface ElementRect {
32
+ x: number;
33
+ y: number;
34
+ width: number;
35
+ height: number;
36
+ }
37
+ /** A rectangle expressed as fractions of the capture base, directly comparable to a tap's normalized point. */
38
+ export interface NormalizedRect {
39
+ x: number;
40
+ y: number;
41
+ width: number;
42
+ height: number;
43
+ }
44
+ /**
45
+ * Read a node's rectangle synchronously, or return null.
46
+ *
47
+ * Both spellings are probed: `unstable_getBoundingClientRect` is what React Native 0.81 ships, and
48
+ * the unprefixed name is what it becomes if the API stabilises. Probing for both means the day it
49
+ * is renamed the SDK keeps working instead of silently losing geometry — and silently is exactly
50
+ * how it would go, because a missing rect is a legitimate outcome that produces no error.
51
+ */
52
+ export declare function readRectSync(node: unknown): ElementRect | null;
53
+ /**
54
+ * Express a rect as fractions of the capture base, so it is comparable to the tap's normalized point.
55
+ *
56
+ * WHY THIS SHARES THE TAP'S BASE AND NOT A WINDOW SIZE. The tap is normalized against the measured
57
+ * ROOT VIEW — the exact view the screenshot captures — precisely because `Dimensions.get('window')`
58
+ * excludes system insets differently across devices and pushes markers about 10% off vertically.
59
+ * A rect normalized against a different base than the point it will be compared with produces a
60
+ * containment test that is wrong by that same offset, which would read as "the heat is just outside
61
+ * the button" on every device with a status bar. Same base, or no rect.
62
+ *
63
+ * Values are NOT clamped to 0..1. A control genuinely extending past the capture base (a row
64
+ * scrolled half off-screen) has a rect that runs past the edge, and clamping it would silently
65
+ * enlarge the containment test — turning a tap that missed the visible part of a control into a hit.
66
+ */
67
+ export declare function normalizeRect(rect: ElementRect | null, base: {
68
+ w: number;
69
+ h: number;
70
+ } | null): NormalizedRect | null;
71
+ /**
72
+ * WHERE IN THE CONTROL the tap landed — RN-15.
73
+ *
74
+ * `u` and `v` are the tap's position as fractions of the target's own box: (0,0) is its top-left,
75
+ * (1,1) its bottom-right, (0.5,0.5) dead centre. This is what turns "they tapped Add to Cart" into
76
+ * "they tapped the left edge of Add to Cart", which is the difference between knowing a control is
77
+ * used and knowing whether its hit area is right.
78
+ *
79
+ * RESOLUTION-INDEPENDENT BY CONSTRUCTION, which is the point on mobile. A screen-space heatmap
80
+ * aggregates a control differently on every device size, so a button that sits at 40% width on a
81
+ * phone and 25% on a tablet produces two clouds of heat that never combine. Local coordinates are
82
+ * the same numbers on both, so one control is one distribution regardless of what it is rendered on.
83
+ *
84
+ * DELIBERATELY NOT CLAMPED, and this is the interesting part rather than an oversight. A value
85
+ * outside 0..1 means the tap landed OUTSIDE the control's visual box and was still attributed to
86
+ * it — which is exactly what `hitSlop` does, and it is real: `hitSlop={24}` makes a control
87
+ * respond to touches two dozen points beyond its own edge. Clamping would erase the one signal
88
+ * that distinguishes "tapped the button" from "tapped near the button and the button took it",
89
+ * and that distinction is a design finding: a control whose taps cluster outside its own bounds is
90
+ * drawn too small.
91
+ *
92
+ * Returns null, not a centre, when either input is missing. A fabricated (0.5, 0.5) would sit in
93
+ * the middle of every distribution and look exactly like a deliberate central tap.
94
+ */
95
+ export declare function localPoint(rect: NormalizedRect | null, nx: number | null | undefined, ny: number | null | undefined): {
96
+ u: number;
97
+ v: number;
98
+ } | null;
99
+ /**
100
+ * Is the tap's normalized point inside the target's normalized rect?
101
+ *
102
+ * THE PREDICATE THE WHOLE MODULE EXISTS FOR. A heatmap that draws a marker labelled with a
103
+ * control's name is asserting this, and until now nothing checked it.
104
+ *
105
+ * Edges are INCLUSIVE. A tap on a button's exact border is a tap on the button — exclusive edges
106
+ * would report a sliver of every control as a miss, and at the resolution these coordinates carry
107
+ * that sliver is not a real distinction.
108
+ *
109
+ * Returns null, not false, when either input is missing. "The point is outside the control" and
110
+ * "we could not measure the control" are different findings with different fixes, and collapsing
111
+ * them into `false` would make an unmeasurable control look like a placement failure.
112
+ */
113
+ export declare function containsNormalizedPoint(rect: NormalizedRect | null, nx: number | null | undefined, ny: number | null | undefined): boolean | null;
114
+ //# sourceMappingURL=targetGeometry.d.ts.map