@repliqo/sdk-react-native 0.3.7 → 0.4.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 (48) hide show
  1. package/INTEGRATION_GUIDE.md +1384 -1312
  2. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -230
  3. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -170
  4. package/dist/components/RepliqoFlatList.d.ts +5 -0
  5. package/dist/components/RepliqoFlatList.js +39 -0
  6. package/dist/components/RepliqoScrollView.d.ts +3 -0
  7. package/dist/components/RepliqoScrollView.js +16 -265
  8. package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
  9. package/dist/components/useRepliqoScrollTracking.js +304 -0
  10. package/dist/core/client.d.ts +52 -5
  11. package/dist/core/client.js +310 -48
  12. package/dist/core/config.d.ts +1 -1
  13. package/dist/core/config.js +5 -1
  14. package/dist/core/storage.d.ts +29 -0
  15. package/dist/core/storage.js +33 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +3 -2
  18. package/dist/snapshot/NativeScreenCapture.d.ts +6 -44
  19. package/dist/snapshot/NativeScreenCapture.js +6 -64
  20. package/dist/snapshot/capture.d.ts +7 -0
  21. package/dist/snapshot/capture.js +17 -5
  22. package/dist/trackers/error.tracker.d.ts +25 -0
  23. package/dist/trackers/error.tracker.js +114 -37
  24. package/dist/trackers/navigation.tracker.js +11 -0
  25. package/dist/transport/api.client.d.ts +34 -1
  26. package/dist/transport/api.client.js +102 -19
  27. package/dist/transport/batch-queue.d.ts +53 -1
  28. package/dist/transport/batch-queue.js +126 -19
  29. package/dist/types/events.d.ts +12 -0
  30. package/package.json +68 -64
  31. package/src/components/RepliqoFlatList.tsx +64 -0
  32. package/src/components/RepliqoScrollView.tsx +53 -302
  33. package/src/components/useRepliqoScrollTracking.ts +366 -0
  34. package/src/core/client.ts +953 -672
  35. package/src/core/config.ts +38 -30
  36. package/src/core/storage.ts +51 -0
  37. package/src/index.ts +54 -52
  38. package/src/snapshot/NativeScreenCapture.ts +62 -157
  39. package/src/snapshot/capture.ts +154 -142
  40. package/src/trackers/error.tracker.ts +211 -136
  41. package/src/trackers/navigation.tracker.ts +107 -98
  42. package/src/transport/api.client.ts +430 -327
  43. package/src/transport/batch-queue.ts +212 -95
  44. package/src/types/events.ts +72 -60
  45. package/android/src/main/java/com/repliqo/screencapture/FullContentCapture.java +0 -273
  46. package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +0 -279
  47. package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +0 -248
  48. package/src/snapshot/ScreenCaptureManager.ts +0 -156
@@ -4,6 +4,7 @@ exports.AppAnalytics = void 0;
4
4
  const react_native_1 = require("react-native");
5
5
  const capture_1 = require("../snapshot/capture");
6
6
  const config_1 = require("./config");
7
+ const storage_1 = require("./storage");
7
8
  const logger_1 = require("./logger");
8
9
  const api_client_1 = require("../transport/api.client");
9
10
  const batch_queue_1 = require("../transport/batch-queue");
@@ -18,6 +19,18 @@ class AppAnalytics {
18
19
  this.currentScreenEnteredAt = null;
19
20
  this.scrollOffsetY = 0;
20
21
  this.appStateSubscription = null;
22
+ /**
23
+ * User identity set via identify(). Kept for the process lifetime and
24
+ * automatically re-attached to every new session, so the host app only
25
+ * needs to call identify() once (e.g. after login).
26
+ */
27
+ this.identity = null;
28
+ /**
29
+ * Offline persistence backend (AsyncStorage-compatible). Null when the
30
+ * host app neither passed one nor has AsyncStorage installed — the SDK
31
+ * then behaves exactly as before (in-memory buffering only).
32
+ */
33
+ this.storage = null;
21
34
  /**
22
35
  * Registry of active ScrollViews on the current screen.
23
36
  * Each RepliqoScrollView registers its window-relative bounds and current
@@ -30,15 +43,63 @@ class AppAnalytics {
30
43
  * viewport position, while still mapping scrollable content correctly.
31
44
  */
32
45
  this.scrollViewRegistry = new Map();
46
+ /**
47
+ * Serialization chain for pending-crash read-modify-write operations.
48
+ * Two near-simultaneous crashes (e.g. an error cascade) would otherwise
49
+ * interleave getItem/setItem and lose one of the entries.
50
+ */
51
+ this.crashStoreLock = Promise.resolve();
52
+ /** Monotonic suffix so identical crashes get distinct keys. */
53
+ this.crashSeq = 0;
33
54
  this.config = (0, config_1.resolveConfig)(config);
34
55
  this.logger = new logger_1.Logger(this.config.debug);
35
56
  this.apiClient = new api_client_1.ApiClient(config_1.REPLIQO_API_URL, this.config.apiKey, this.logger);
36
- this.eventQueue = new batch_queue_1.BatchQueue(this.config.batchSize, this.config.flushInterval, async (events) => {
37
- if (!this.sessionId) {
38
- this.logger.warn('Cannot flush events: no active session');
39
- throw new Error('No active session');
57
+ // Events are stamped with their session at ENQUEUE time (__sessionId)
58
+ // and sent per session at flush time. Reading this.sessionId at flush
59
+ // time instead would attribute events buffered at the end of session A
60
+ // to session B if a new session starts before the flush.
61
+ //
62
+ // Delivery is per CONTIGUOUS RUN of same-session events (not a merged
63
+ // map) so the queue's prefix accounting stays exact even if sessions
64
+ // interleave in the buffer (e.g. restored old-session events landing
65
+ // between new-session events). On a transient failure we report the
66
+ // delivered prefix via PartialFlushError so the queue retries ONLY the
67
+ // remainder — never re-sending delivered events (duplicates) and never
68
+ // dropping unattempted ones alongside a poison run.
69
+ this.eventQueue = new batch_queue_1.BatchQueue(this.config.batchSize, this.config.flushInterval, async (queued) => {
70
+ // Split into contiguous same-session runs, preserving order.
71
+ const runs = [];
72
+ for (const { __sessionId, ...event } of queued) {
73
+ const last = runs[runs.length - 1];
74
+ if (last && last.sessionId === __sessionId) {
75
+ last.events.push(event);
76
+ }
77
+ else {
78
+ runs.push({ sessionId: __sessionId, events: [event] });
79
+ }
80
+ }
81
+ let consumed = 0;
82
+ for (const run of runs) {
83
+ try {
84
+ await this.apiClient.sendEventsBatch(run.sessionId, run.events);
85
+ consumed += run.events.length;
86
+ }
87
+ catch (err) {
88
+ if (err instanceof batch_queue_1.NonRetryableFlushError) {
89
+ // Poison run: permanently rejected. Count it as consumed
90
+ // (dropped) and keep delivering the remaining runs.
91
+ this.logger.warn(`Dropping ${run.events.length} events permanently rejected for session ${run.sessionId}`);
92
+ consumed += run.events.length;
93
+ continue;
94
+ }
95
+ // Transient failure: stop here. Anything already delivered or
96
+ // dropped must not be retried.
97
+ if (consumed > 0) {
98
+ throw new batch_queue_1.PartialFlushError(consumed, true);
99
+ }
100
+ throw err;
101
+ }
40
102
  }
41
- await this.apiClient.sendEventsBatch(this.sessionId, events);
42
103
  });
43
104
  this.screenVisitQueue = new batch_queue_1.BatchQueue(this.config.batchSize, this.config.flushInterval, async (visits) => {
44
105
  await this.apiClient.sendScreenVisitsBatch(visits);
@@ -61,14 +122,28 @@ class AppAnalytics {
61
122
  }, undefined, // default captureScreenshot provider
62
123
  (...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
63
124
  }
125
+ // Offline persistence: explicit storage from config, or auto-loaded
126
+ // AsyncStorage if the host app has it installed.
127
+ this.storage = this.config.storage ?? (0, storage_1.tryLoadAsyncStorage)();
64
128
  if (this.config.enableCrashTracking) {
65
129
  this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
66
- // Crash reports are sent immediately (fire-and-forget), not batched
67
- this.apiClient.sendCrashReport(crash).catch(() => { });
130
+ // Persist FIRST on a fatal crash the app dies before the network
131
+ // request completes; the persisted copy is re-sent on next launch.
132
+ // Then send immediately and clear the persisted copy on success.
133
+ (async () => {
134
+ const key = await this.persistCrash(crash);
135
+ const sent = await this.apiClient.sendCrashReport(crash);
136
+ if (sent && key) {
137
+ await this.removePendingCrash(key);
138
+ }
139
+ })().catch(() => { });
68
140
  });
69
141
  this.errorTracker.start();
70
142
  }
71
143
  this.setupAppStateListener();
144
+ // Re-send anything persisted by a previous run (events buffered at
145
+ // backgrounding, crashes whose send never completed).
146
+ this.restorePersistedData().catch(() => { });
72
147
  this.logger.log('SDK initialized with config:', {
73
148
  apiUrl: config_1.REPLIQO_API_URL,
74
149
  appId: this.config.appId,
@@ -112,17 +187,37 @@ class AppAnalytics {
112
187
  instance.appStateSubscription.remove();
113
188
  instance.appStateSubscription = null;
114
189
  }
115
- // Attempt a final flush (fire-and-forget)
116
- instance.flush().catch(() => { });
190
+ // Close the session on the server (also flushes remaining data).
191
+ // Fire-and-forget: destroy() is sync and must not block.
192
+ if (instance.sessionId) {
193
+ instance.endSession().catch(() => { });
194
+ }
195
+ else {
196
+ instance.flush().catch(() => { });
197
+ }
117
198
  AppAnalytics.instance = null;
118
199
  }
119
200
  }
120
201
  async startSession(deviceId, deviceInfo) {
202
+ // Calling startSession with a session already active would leak the
203
+ // previous session (left open forever on the backend) and strand its
204
+ // buffered events. Close it first.
205
+ if (this.sessionId) {
206
+ this.logger.warn('startSession called with an active session; ending it first');
207
+ await this.endSession();
208
+ }
121
209
  try {
122
210
  const response = await this.apiClient.startSession(deviceId, deviceInfo);
123
211
  this.sessionId = response.id;
124
212
  this.logger.log('Session started with ID:', this.sessionId);
125
213
  this.errorTracker?.setSessionId(this.sessionId);
214
+ // Re-attach a previously-set identity to the new session so the
215
+ // host app only needs to call identify() once per login.
216
+ if (this.identity) {
217
+ this.apiClient
218
+ .identifySession(this.sessionId, this.identity.userId, this.identity.traits)
219
+ .catch(() => { });
220
+ }
126
221
  if (this.snapshotCapture) {
127
222
  this.snapshotCapture.setSessionId(this.sessionId);
128
223
  this.snapshotCapture.reset();
@@ -223,12 +318,20 @@ class AppAnalytics {
223
318
  activeScrollOffset = 0;
224
319
  isFixed = true;
225
320
  }
321
+ else if (this.scrollOffsetY > 0) {
322
+ // No ScrollViews registered, but the host app is reporting scroll
323
+ // manually via setScrollOffset() (legacy integration without
324
+ // RepliqoScrollView). Honor it — otherwise those touches would be
325
+ // misclassified as fixed and land at the wrong heatmap position.
326
+ // Stale values are not a risk: onScreenEnter resets it to 0.
327
+ activeScrollOffset = this.scrollOffsetY;
328
+ isFixed = false;
329
+ }
226
330
  else {
227
- // No ScrollViews registered at all. Two possible cases:
331
+ // No ScrollViews registered and no manual offset. Two cases:
228
332
  // (a) The screen has no scrollable content → touch is fixed
229
333
  // (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
230
- // Either way, marking as fixed is safer than guessing content
231
- // position with stale scrollOffsetY from a previous screen.
334
+ // Either way, marking as fixed is safer than guessing.
232
335
  activeScrollOffset = 0;
233
336
  isFixed = true;
234
337
  }
@@ -245,7 +348,8 @@ class AppAnalytics {
245
348
  },
246
349
  timestamp: new Date().toISOString(),
247
350
  };
248
- this.eventQueue.add(event);
351
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
352
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
249
353
  this.logger.log('Touch tracked:', event);
250
354
  }
251
355
  trackNavigation(fromScreen, toScreen) {
@@ -262,7 +366,8 @@ class AppAnalytics {
262
366
  data: { fromScreen, toScreen },
263
367
  timestamp: new Date().toISOString(),
264
368
  };
265
- this.eventQueue.add(event);
369
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
370
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
266
371
  this.logger.log('Navigation tracked:', fromScreen, '->', toScreen);
267
372
  }
268
373
  trackCustomEvent(eventName, data) {
@@ -276,9 +381,40 @@ class AppAnalytics {
276
381
  data: { eventName, ...data },
277
382
  timestamp: new Date().toISOString(),
278
383
  };
279
- this.eventQueue.add(event);
384
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
385
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
280
386
  this.logger.log('Custom event tracked:', eventName);
281
387
  }
388
+ /**
389
+ * Identify the current user (call once after login). The identity is
390
+ * attached to the active session immediately and re-attached to every
391
+ * future session automatically until resetIdentity() is called.
392
+ *
393
+ * @param userId The host app's own user identifier.
394
+ * @param traits Optional free-form traits (plan, role, locale...).
395
+ * Avoid PII you don't want stored (emails in plain text).
396
+ */
397
+ identify(userId, traits) {
398
+ if (!userId || typeof userId !== 'string') {
399
+ this.logger.warn('identify() requires a non-empty string userId');
400
+ return;
401
+ }
402
+ this.identity = { userId, traits };
403
+ this.logger.log('Identity set:', userId);
404
+ if (this.sessionId) {
405
+ this.apiClient
406
+ .identifySession(this.sessionId, userId, traits)
407
+ .catch(() => { });
408
+ }
409
+ }
410
+ /**
411
+ * Clear the stored identity (call on logout). Sessions started after
412
+ * this are anonymous again; already-identified sessions keep their user.
413
+ */
414
+ resetIdentity() {
415
+ this.identity = null;
416
+ this.logger.log('Identity cleared');
417
+ }
282
418
  reportError(error, metadata) {
283
419
  if (!this.errorTracker) {
284
420
  this.logger.warn('Cannot report error: crash tracking is not enabled');
@@ -364,31 +500,15 @@ class AppAnalytics {
364
500
  }
365
501
  })();
366
502
  }
367
- /**
368
- * Programmatically scroll-scan the current ScrollView and upload all
369
- * tiles to the backend. Invisible to the user (~300-500ms).
370
- */
371
- autoScanScreen(screenName) {
372
- (async () => {
373
- try {
374
- const tiles = await (0, NativeScreenCapture_1.scanFullContent)();
375
- if (!tiles || tiles.length === 0) {
376
- this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
377
- return;
378
- }
379
- this.logger.log(`Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
380
- `(content=${tiles[0].contentHeight}px)`);
381
- // Upload all tiles in parallel
382
- const sessionId = this.sessionId;
383
- await Promise.all(tiles.map((tile) => this.apiClient.uploadScreenTile(sessionId, screenName, tile.image, tile.width, tile.height, tile.scrollOffsetY, tile.viewportHeight, tile.contentHeight).catch(() => { })));
384
- this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
385
- }
386
- catch (err) {
387
- this.logger.warn('Auto-scan failed:', err);
388
- }
389
- })();
390
- }
391
503
  onScreenEnter(screenName) {
504
+ // Re-entering the SAME screen (e.g. onReady + onStateChange both firing
505
+ // for the initial route) must not wipe the ScrollView registry that the
506
+ // screen's RepliqoScrollView already populated — touches in the gap
507
+ // until re-measure would be misclassified as fixed UI.
508
+ if (this.currentScreen === screenName) {
509
+ this.logger.log('Screen re-entered (no-op):', screenName);
510
+ return;
511
+ }
392
512
  this.currentScreen = screenName;
393
513
  this.currentScreenEnteredAt = new Date().toISOString();
394
514
  this.scrollOffsetY = 0;
@@ -396,14 +516,12 @@ class AppAnalytics {
396
516
  this.scrollViewRegistry.clear();
397
517
  this.errorTracker?.setCurrentScreen(screenName);
398
518
  this.snapshotCapture?.setCurrentScreen(screenName);
399
- // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
400
- // React Native does NOT render off-screen content to the GPU or view
401
- // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
402
- // tiles. Worse, those blank tiles poison the dedup cache and block
403
- // real tiles captured during natural user scroll.
404
- //
405
- // Full-content capture relies entirely on RepliqoScrollView capturing
406
- // viewport tiles as the user naturally scrolls through the content.
519
+ // NOTE: full-content capture is NOT done via programmatic scroll +
520
+ // native capture. React Native does NOT render off-screen content to
521
+ // the GPU or view tree, so any programmatic-scroll approach produces
522
+ // blank tiles. Full-content heatmap backgrounds are built entirely
523
+ // from viewport tiles captured by RepliqoScrollView as the user
524
+ // naturally scrolls through the content.
407
525
  this.logger.log('Screen entered:', screenName);
408
526
  }
409
527
  onScreenExit(screenName) {
@@ -473,6 +591,129 @@ class AppAnalytics {
473
591
  isInitialized() {
474
592
  return true;
475
593
  }
594
+ // ─── Offline persistence ────────────────────────────────────────────
595
+ /**
596
+ * Persist the un-flushed event buffer (called when backgrounding).
597
+ * Events carry their __sessionId stamp, so on restore they are still
598
+ * attributed to the session they belonged to.
599
+ */
600
+ async persistPendingEvents() {
601
+ if (!this.storage)
602
+ return;
603
+ try {
604
+ const pending = this.eventQueue.peekAll().slice(-storage_1.MAX_PERSISTED_EVENTS);
605
+ if (pending.length === 0) {
606
+ await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents);
607
+ return;
608
+ }
609
+ await this.storage.setItem(storage_1.STORAGE_KEYS.pendingEvents, JSON.stringify(pending));
610
+ this.logger.log('Persisted', pending.length, 'pending events');
611
+ }
612
+ catch (err) {
613
+ this.logger.warn('Failed to persist pending events:', err);
614
+ }
615
+ }
616
+ withCrashStoreLock(op) {
617
+ const run = this.crashStoreLock.then(op, op);
618
+ // Keep the chain alive regardless of op outcome.
619
+ this.crashStoreLock = run.catch(() => { });
620
+ return run;
621
+ }
622
+ /** Append a crash to the persisted pending list; returns its key. */
623
+ async persistCrash(crash) {
624
+ if (!this.storage)
625
+ return null;
626
+ const storage = this.storage;
627
+ try {
628
+ return await this.withCrashStoreLock(async () => {
629
+ const raw = await storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
630
+ const list = raw
631
+ ? JSON.parse(raw)
632
+ : [];
633
+ // Unique per crash: identical timestamp+message pairs must not
634
+ // share a key or removePendingCrash would delete both.
635
+ const key = `${crash.timestamp}|${++this.crashSeq}|${crash.message}`.slice(0, 300);
636
+ list.push({ key, crash });
637
+ await storage.setItem(storage_1.STORAGE_KEYS.pendingCrashes, JSON.stringify(list.slice(-storage_1.MAX_PERSISTED_CRASHES)));
638
+ return key;
639
+ });
640
+ }
641
+ catch (err) {
642
+ this.logger.warn('Failed to persist crash:', err);
643
+ return null;
644
+ }
645
+ }
646
+ /** Remove a crash from the persisted pending list (after send success). */
647
+ async removePendingCrash(key) {
648
+ if (!this.storage)
649
+ return;
650
+ const storage = this.storage;
651
+ try {
652
+ await this.withCrashStoreLock(async () => {
653
+ const raw = await storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
654
+ if (!raw)
655
+ return;
656
+ const list = JSON.parse(raw);
657
+ const remaining = list.filter((entry) => entry.key !== key);
658
+ if (remaining.length === 0) {
659
+ await storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
660
+ }
661
+ else {
662
+ await storage.setItem(storage_1.STORAGE_KEYS.pendingCrashes, JSON.stringify(remaining));
663
+ }
664
+ });
665
+ }
666
+ catch {
667
+ // Best effort — a stale entry only means a duplicate crash report.
668
+ }
669
+ }
670
+ /**
671
+ * Restore data persisted by a previous run: re-enqueue events (still
672
+ * stamped with their original session) and re-send pending crashes.
673
+ * Called once from the constructor.
674
+ */
675
+ async restorePersistedData() {
676
+ if (!this.storage)
677
+ return;
678
+ // Events: re-enqueue and clear. They flush with the normal cycle.
679
+ try {
680
+ const raw = await this.storage.getItem(storage_1.STORAGE_KEYS.pendingEvents);
681
+ if (raw) {
682
+ await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents);
683
+ const events = JSON.parse(raw);
684
+ if (Array.isArray(events) && events.length > 0) {
685
+ for (const event of events) {
686
+ if (event && typeof event.__sessionId === 'string') {
687
+ this.eventQueue.add(event);
688
+ }
689
+ }
690
+ this.logger.log('Restored', events.length, 'persisted events');
691
+ }
692
+ }
693
+ }
694
+ catch (err) {
695
+ this.logger.warn('Failed to restore persisted events:', err);
696
+ }
697
+ // Crashes: send as a batch; clear only on success.
698
+ try {
699
+ const raw = await this.storage.getItem(storage_1.STORAGE_KEYS.pendingCrashes);
700
+ if (raw) {
701
+ const list = JSON.parse(raw);
702
+ if (Array.isArray(list) && list.length > 0) {
703
+ await this.apiClient.sendCrashesBatch(list.map((e) => e.crash));
704
+ await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
705
+ this.logger.log('Re-sent', list.length, 'persisted crash reports');
706
+ }
707
+ else {
708
+ await this.storage.removeItem(storage_1.STORAGE_KEYS.pendingCrashes);
709
+ }
710
+ }
711
+ }
712
+ catch (err) {
713
+ // sendCrashesBatch throws on failure → keep the list for next launch.
714
+ this.logger.warn('Failed to re-send persisted crashes:', err);
715
+ }
716
+ }
476
717
  setupAppStateListener() {
477
718
  let wasInBackground = false;
478
719
  this.appStateSubscription = react_native_1.AppState.addEventListener('change', (nextAppState) => {
@@ -480,13 +721,34 @@ class AppAnalytics {
480
721
  this.logger.log('App going to background, pausing snapshots + flushing');
481
722
  this.stopSnapshotCapture();
482
723
  wasInBackground = true;
483
- this.flush().catch((error) => {
724
+ // Persist FIRST: iOS suspends JS within seconds of backgrounding,
725
+ // and a flush stalled on bad network (up to the 15s fetch timeout)
726
+ // would mean the persist never runs before the OS kills the app —
727
+ // exactly the scenario persistence exists for. A possible
728
+ // duplicate (flush succeeded after persisting) is the lesser
729
+ // evil, and the snapshot is refreshed after the flush anyway.
730
+ this.persistPendingEvents()
731
+ .catch(() => { })
732
+ .then(() => this.flush())
733
+ .catch((error) => {
484
734
  this.logger.error('Error flushing on app state change:', error);
735
+ })
736
+ .finally(() => {
737
+ // Refresh the snapshot with whatever is still undelivered
738
+ // (empty ⇒ the key is removed, preventing stale re-sends).
739
+ this.persistPendingEvents().catch(() => { });
485
740
  });
486
741
  }
487
742
  else if (nextAppState === 'active' && wasInBackground) {
488
743
  wasInBackground = false;
489
744
  this.logger.log('App returned to foreground, resuming snapshots');
745
+ // The app survived the background stint — the in-memory buffer is
746
+ // authoritative again. Drop the persisted snapshot so a later
747
+ // crash doesn't re-send events that will be delivered normally
748
+ // from memory (it gets re-written on the next backgrounding).
749
+ if (this.storage) {
750
+ this.storage.removeItem(storage_1.STORAGE_KEYS.pendingEvents).catch(() => { });
751
+ }
490
752
  if (this.snapshotCapture && this.sessionId) {
491
753
  this.startSnapshotCapture();
492
754
  }
@@ -1,5 +1,5 @@
1
1
  import { SDKConfig } from '../types/events';
2
2
  export declare const REPLIQO_API_URL = "https://api-repliqo.odmservice.site";
3
- export declare const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId'>>;
3
+ export declare const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId' | 'storage'>>;
4
4
  export type ResolvedConfig = SDKConfig & typeof DEFAULT_CONFIG;
5
5
  export declare function resolveConfig(config: SDKConfig): ResolvedConfig;
@@ -24,5 +24,9 @@ exports.DEFAULT_CONFIG = {
24
24
  debug: false,
25
25
  };
26
26
  function resolveConfig(config) {
27
- return { ...exports.DEFAULT_CONFIG, ...config };
27
+ // Strip keys whose value is explicitly `undefined` — otherwise
28
+ // `{ enableSnapshots: maybeUndefinedVar }` would silently override the
29
+ // default `true` with `undefined` and disable the feature.
30
+ const cleaned = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined));
31
+ return { ...exports.DEFAULT_CONFIG, ...cleaned };
28
32
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Minimal async key-value storage contract, compatible with
3
+ * @react-native-async-storage/async-storage (and trivially mockable).
4
+ *
5
+ * Used to persist analytics data across app kills:
6
+ * - buffered events that couldn't be flushed before backgrounding
7
+ * - crash reports (a fatal crash kills the app before the report's
8
+ * network request can complete — persistence is the only way those
9
+ * reports survive to the next launch)
10
+ */
11
+ export interface PersistentStorage {
12
+ getItem(key: string): Promise<string | null>;
13
+ setItem(key: string, value: string): Promise<void>;
14
+ removeItem(key: string): Promise<void>;
15
+ }
16
+ /**
17
+ * Try to auto-load @react-native-async-storage/async-storage. Returns null
18
+ * when the host app doesn't have it installed — persistence is then
19
+ * disabled gracefully (in-memory only, pre-existing behavior).
20
+ */
21
+ export declare function tryLoadAsyncStorage(): PersistentStorage | null;
22
+ /** Storage keys namespaced to avoid colliding with the host app's data. */
23
+ export declare const STORAGE_KEYS: {
24
+ readonly pendingEvents: "@repliqo/pending_events";
25
+ readonly pendingCrashes: "@repliqo/pending_crashes";
26
+ };
27
+ /** Caps so a long-offline device can't grow storage unboundedly. */
28
+ export declare const MAX_PERSISTED_EVENTS = 500;
29
+ export declare const MAX_PERSISTED_CRASHES = 20;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_PERSISTED_CRASHES = exports.MAX_PERSISTED_EVENTS = exports.STORAGE_KEYS = void 0;
4
+ exports.tryLoadAsyncStorage = tryLoadAsyncStorage;
5
+ /**
6
+ * Try to auto-load @react-native-async-storage/async-storage. Returns null
7
+ * when the host app doesn't have it installed — persistence is then
8
+ * disabled gracefully (in-memory only, pre-existing behavior).
9
+ */
10
+ function tryLoadAsyncStorage() {
11
+ try {
12
+ const mod = require('@react-native-async-storage/async-storage');
13
+ const storage = mod?.default ?? mod;
14
+ if (storage &&
15
+ typeof storage.getItem === 'function' &&
16
+ typeof storage.setItem === 'function' &&
17
+ typeof storage.removeItem === 'function') {
18
+ return storage;
19
+ }
20
+ }
21
+ catch {
22
+ // Not installed — fine.
23
+ }
24
+ return null;
25
+ }
26
+ /** Storage keys namespaced to avoid colliding with the host app's data. */
27
+ exports.STORAGE_KEYS = {
28
+ pendingEvents: '@repliqo/pending_events',
29
+ pendingCrashes: '@repliqo/pending_crashes',
30
+ };
31
+ /** Caps so a long-offline device can't grow storage unboundedly. */
32
+ exports.MAX_PERSISTED_EVENTS = 500;
33
+ exports.MAX_PERSISTED_CRASHES = 20;
package/dist/index.d.ts CHANGED
@@ -5,13 +5,14 @@ export { trackScreenEnter, trackScreenExit, } from './trackers/screen.tracker';
5
5
  export { ErrorTracker } from './trackers/error.tracker';
6
6
  export { SnapshotCapture } from './snapshot/capture';
7
7
  export { captureScreenshot } from './snapshot/captureScreenshot';
8
- export { isNativeScreenCaptureAvailable, captureNativeFrame, captureFullContent, } from './snapshot/NativeScreenCapture';
9
- export type { FullContentResult } from './snapshot/NativeScreenCapture';
8
+ export { isNativeScreenCaptureAvailable, captureNativeFrame, } from './snapshot/NativeScreenCapture';
10
9
  export type { EventType, AnalyticsEvent, TouchEventData, NavigationEventData, ScreenVisit, DeviceInfo, SDKConfig, } from './types/events';
11
10
  export type { ScreenshotData, SnapshotPayload, } from './types/snapshot';
12
11
  export type { CrashReport } from './types/crash';
13
12
  export type { SnapshotCaptureConfig } from './snapshot/capture';
14
13
  export type { ScreenshotResult } from './snapshot/captureScreenshot';
15
14
  export { RepliqoScrollView } from './components/RepliqoScrollView';
15
+ export { RepliqoFlatList } from './components/RepliqoFlatList';
16
+ export type { PersistentStorage } from './core/storage';
16
17
  export { DEFAULT_CONFIG } from './core/config';
17
18
  export type { ResolvedConfig } from './core/config';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_CONFIG = exports.RepliqoScrollView = exports.captureFullContent = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
3
+ exports.DEFAULT_CONFIG = exports.RepliqoFlatList = exports.RepliqoScrollView = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
4
4
  // Main class
5
5
  var client_1 = require("./core/client");
6
6
  Object.defineProperty(exports, "AppAnalytics", { enumerable: true, get: function () { return client_1.AppAnalytics; } });
@@ -23,10 +23,11 @@ Object.defineProperty(exports, "captureScreenshot", { enumerable: true, get: fun
23
23
  var NativeScreenCapture_1 = require("./snapshot/NativeScreenCapture");
24
24
  Object.defineProperty(exports, "isNativeScreenCaptureAvailable", { enumerable: true, get: function () { return NativeScreenCapture_1.isNativeScreenCaptureAvailable; } });
25
25
  Object.defineProperty(exports, "captureNativeFrame", { enumerable: true, get: function () { return NativeScreenCapture_1.captureNativeFrame; } });
26
- Object.defineProperty(exports, "captureFullContent", { enumerable: true, get: function () { return NativeScreenCapture_1.captureFullContent; } });
27
26
  // Components
28
27
  var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
29
28
  Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
29
+ var RepliqoFlatList_1 = require("./components/RepliqoFlatList");
30
+ Object.defineProperty(exports, "RepliqoFlatList", { enumerable: true, get: function () { return RepliqoFlatList_1.RepliqoFlatList; } });
30
31
  // Config
31
32
  var config_1 = require("./core/config");
32
33
  Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
@@ -12,50 +12,12 @@ export declare function isNativeScreenCaptureAvailable(): boolean;
12
12
  *
13
13
  * No permissions required. No dialog. No foreground service.
14
14
  *
15
- * @returns NativeFrameResult or null if capture failed / not available
16
- */
17
- export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;
18
- /**
19
- * Capture the FULL content of the primary ScrollView on screen,
20
- * including content scrolled off-screen. Used for heatmap backgrounds.
21
- *
22
- * Falls back to a viewport capture if no ScrollView is found.
23
- * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
15
+ * This is the ONLY native capture path. Full-content capture of
16
+ * off-screen ScrollView content is NOT possible natively (React Native
17
+ * doesn't render off-screen content to the GPU/view tree) full-content
18
+ * heatmap backgrounds are instead built collaboratively from viewport
19
+ * tiles captured as the user naturally scrolls (see RepliqoScrollView).
24
20
  *
25
21
  * @returns NativeFrameResult or null if capture failed / not available
26
22
  */
27
- export interface FullContentResult extends NativeFrameResult {
28
- /** True if the capture includes full scrollable content. False if viewport-only fallback. */
29
- isFullContent: boolean;
30
- }
31
- /**
32
- * Capture the FULL content of the primary ScrollView on screen,
33
- * including content scrolled off-screen. Used for heatmap backgrounds.
34
- *
35
- * Falls back to a viewport capture if no ScrollView is found.
36
- * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
37
- *
38
- * Known limitation: FlatList/SectionList use RecyclerView which only
39
- * renders visible items — full-content capture falls back to viewport.
40
- *
41
- * @returns FullContentResult with isFullContent flag, or null
42
- */
43
- export declare function captureFullContent(): Promise<FullContentResult | null>;
44
- export interface ScanTile {
45
- image: string;
46
- width: number;
47
- height: number;
48
- scrollOffsetY: number;
49
- viewportHeight: number;
50
- contentHeight: number;
51
- }
52
- /**
53
- * Perform a fast programmatic scroll-scan of the main ScrollView.
54
- * Captures the entire content as viewport tiles in ~300-500ms.
55
- *
56
- * The scan is invisible to the user — scroll position is saved and
57
- * restored. Returns an array of tiles covering the full content.
58
- *
59
- * @returns Array of tiles, or null if no ScrollView or not available
60
- */
61
- export declare function scanFullContent(): Promise<ScanTile[] | null>;
23
+ export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;