@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
@@ -1,672 +1,953 @@
1
- import { AppState, AppStateStatus } from 'react-native';
2
- import {
3
- AnalyticsEvent,
4
- DeviceInfo,
5
- ScreenVisit,
6
- SDKConfig,
7
- } from '../types/events';
8
- import { SnapshotPayload } from '../types/snapshot';
9
- import { SnapshotCapture } from '../snapshot/capture';
10
- import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
11
- import { Logger } from './logger';
12
- import { ApiClient } from '../transport/api.client';
13
- import { BatchQueue } from '../transport/batch-queue';
14
- import { ErrorTracker } from '../trackers/error.tracker';
15
- import { captureNativeFrame, scanFullContent } from '../snapshot/NativeScreenCapture';
16
-
17
- export class AppAnalytics {
18
- private static instance: AppAnalytics | null = null;
19
-
20
- private config: ResolvedConfig;
21
- private apiClient: ApiClient;
22
- private eventQueue: BatchQueue<AnalyticsEvent>;
23
- private screenVisitQueue: BatchQueue<ScreenVisit>;
24
- private snapshotQueue: BatchQueue<SnapshotPayload>;
25
- private snapshotCapture: SnapshotCapture | null = null;
26
- private errorTracker: ErrorTracker | null = null;
27
- private sessionId: string | null = null;
28
- private currentScreen: string | null = null;
29
- private currentScreenEnteredAt: string | null = null;
30
- private scrollOffsetY: number = 0;
31
- private logger: Logger;
32
- private appStateSubscription: { remove: () => void } | null = null;
33
-
34
- /**
35
- * Registry of active ScrollViews on the current screen.
36
- * Each RepliqoScrollView registers its window-relative bounds and current
37
- * scroll state. Used by trackTouch to auto-detect:
38
- * - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
39
- * scrollOffset, mark as content touch
40
- * - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
41
- *
42
- * This makes heatmaps render fixed elements (nav bars, headers) at their
43
- * viewport position, while still mapping scrollable content correctly.
44
- */
45
- private scrollViewRegistry = new Map<
46
- string,
47
- {
48
- bounds: { x: number; y: number; width: number; height: number };
49
- scrollOffsetY: number;
50
- }
51
- >();
52
-
53
- private constructor(config: SDKConfig) {
54
- this.config = resolveConfig(config);
55
- this.logger = new Logger(this.config.debug);
56
- this.apiClient = new ApiClient(
57
- REPLIQO_API_URL,
58
- this.config.apiKey,
59
- this.logger,
60
- );
61
-
62
- this.eventQueue = new BatchQueue<AnalyticsEvent>(
63
- this.config.batchSize,
64
- this.config.flushInterval,
65
- async (events: AnalyticsEvent[]) => {
66
- if (!this.sessionId) {
67
- this.logger.warn(
68
- 'Cannot flush events: no active session',
69
- );
70
- throw new Error('No active session');
71
- }
72
- await this.apiClient.sendEventsBatch(this.sessionId, events);
73
- },
74
- );
75
-
76
- this.screenVisitQueue = new BatchQueue<ScreenVisit>(
77
- this.config.batchSize,
78
- this.config.flushInterval,
79
- async (visits: ScreenVisit[]) => {
80
- await this.apiClient.sendScreenVisitsBatch(visits);
81
- },
82
- );
83
-
84
- // Snapshots are heavier (~15-30 KB JPEG each) so they use a smaller
85
- // batch size, shorter flush interval, and a tighter memory cap than
86
- // the event queue. All configurable via SDKConfig.
87
- this.snapshotQueue = new BatchQueue<SnapshotPayload>(
88
- this.config.snapshotBatchSize,
89
- this.config.snapshotFlushInterval,
90
- async (snapshots: SnapshotPayload[]) => {
91
- await this.apiClient.sendSnapshotsBatch(snapshots);
92
- },
93
- this.config.snapshotMaxBufferSize,
94
- );
95
-
96
- this.eventQueue.startAutoFlush();
97
- this.screenVisitQueue.startAutoFlush();
98
- this.snapshotQueue.startAutoFlush();
99
-
100
- if (this.config.enableSnapshots) {
101
- this.snapshotCapture = new SnapshotCapture(
102
- {
103
- captureInterval: this.config.snapshotInterval,
104
- maxSnapshotsPerSession: this.config.maxSnapshotsPerSession,
105
- },
106
- (snapshot: SnapshotPayload) => {
107
- this.snapshotQueue.add(snapshot);
108
- },
109
- undefined, // default captureScreenshot provider
110
- (...args: any[]) => this.logger.log(...args),
111
- (...args: any[]) => this.logger.warn(...args),
112
- );
113
- }
114
-
115
- if (this.config.enableCrashTracking) {
116
- this.errorTracker = new ErrorTracker((crash) => {
117
- // Crash reports are sent immediately (fire-and-forget), not batched
118
- this.apiClient.sendCrashReport(crash).catch(() => {});
119
- });
120
- this.errorTracker.start();
121
- }
122
-
123
- this.setupAppStateListener();
124
-
125
- this.logger.log('SDK initialized with config:', {
126
- apiUrl: REPLIQO_API_URL,
127
- appId: this.config.appId,
128
- batchSize: this.config.batchSize,
129
- flushInterval: this.config.flushInterval,
130
- enableSnapshots: this.config.enableSnapshots,
131
- snapshotInterval: this.config.snapshotInterval,
132
- enableCrashTracking: this.config.enableCrashTracking,
133
- });
134
- }
135
-
136
- static init(config: SDKConfig): AppAnalytics {
137
- if (AppAnalytics.instance) {
138
- AppAnalytics.instance.logger.warn(
139
- 'AppAnalytics already initialized. Returning existing instance.',
140
- );
141
- return AppAnalytics.instance;
142
- }
143
-
144
- AppAnalytics.instance = new AppAnalytics(config);
145
- return AppAnalytics.instance;
146
- }
147
-
148
- static getInstance(): AppAnalytics {
149
- if (!AppAnalytics.instance) {
150
- throw new Error(
151
- 'AppAnalytics not initialized. Call AppAnalytics.init(config) first.',
152
- );
153
- }
154
- return AppAnalytics.instance;
155
- }
156
-
157
- static destroy(): void {
158
- if (AppAnalytics.instance) {
159
- const instance = AppAnalytics.instance;
160
- instance.logger.log('Destroying AppAnalytics instance');
161
-
162
- if (instance.errorTracker) {
163
- instance.errorTracker.stop();
164
- instance.errorTracker = null;
165
- }
166
-
167
- if (instance.snapshotCapture) {
168
- instance.snapshotCapture.stop();
169
- instance.snapshotCapture = null;
170
- }
171
-
172
-
173
- instance.eventQueue.stopAutoFlush();
174
- instance.screenVisitQueue.stopAutoFlush();
175
- instance.snapshotQueue.stopAutoFlush();
176
-
177
- if (instance.appStateSubscription) {
178
- instance.appStateSubscription.remove();
179
- instance.appStateSubscription = null;
180
- }
181
-
182
- // Attempt a final flush (fire-and-forget)
183
- instance.flush().catch(() => {});
184
-
185
- AppAnalytics.instance = null;
186
- }
187
- }
188
-
189
- async startSession(
190
- deviceId: string,
191
- deviceInfo?: DeviceInfo,
192
- ): Promise<string> {
193
- try {
194
- const response = await this.apiClient.startSession(deviceId, deviceInfo);
195
- this.sessionId = response.id;
196
- this.logger.log('Session started with ID:', this.sessionId);
197
-
198
- this.errorTracker?.setSessionId(this.sessionId);
199
-
200
- if (this.snapshotCapture) {
201
- this.snapshotCapture.setSessionId(this.sessionId);
202
- this.snapshotCapture.reset();
203
-
204
- // Native capture (MediaProjection) is NOT started automatically.
205
- // It requires a system dialog that is confusing if shown on every
206
- // launch. The host app can call enableNativeCapture() explicitly
207
- // when the user opts in. Until then, view-shot is used.
208
- this.startSnapshotCapture();
209
- }
210
-
211
- return this.sessionId;
212
- } catch (error) {
213
- this.logger.error('Failed to start session:', error);
214
- throw error;
215
- }
216
- }
217
-
218
- async endSession(): Promise<void> {
219
- if (!this.sessionId) {
220
- this.logger.warn('No active session to end');
221
- return;
222
- }
223
-
224
- // Stop snapshot capture
225
- this.stopSnapshotCapture();
226
-
227
- // Flush any pending screen exit
228
- if (this.currentScreen && this.currentScreenEnteredAt) {
229
- this.onScreenExit(this.currentScreen);
230
- }
231
-
232
- // Flush remaining data
233
- await this.flush();
234
-
235
- try {
236
- await this.apiClient.endSession(this.sessionId);
237
- this.logger.log('Session ended:', this.sessionId);
238
- } catch (error) {
239
- this.logger.error('Failed to end session:', error);
240
- }
241
-
242
- this.sessionId = null;
243
- this.currentScreen = null;
244
- this.currentScreenEnteredAt = null;
245
-
246
- this.errorTracker?.setSessionId(null);
247
-
248
- if (this.snapshotCapture) {
249
- this.snapshotCapture.setSessionId(null);
250
- }
251
- }
252
-
253
- /**
254
- * Track a touch event for heatmap analysis.
255
- *
256
- * @param x Touch X coordinate, in WINDOW coordinates (must match the
257
- * coordinate system of `measureInWindow` — typically `pageX`
258
- * from a React Native gesture event).
259
- * @param y Touch Y coordinate, in WINDOW coordinates (must match the
260
- * coordinate system of `measureInWindow` — typically `pageY`
261
- * from a React Native gesture event). On Android with edge-to-edge
262
- * enabled, ensure `pageY` excludes the status bar to stay
263
- * consistent with `measureInWindow`.
264
- * @param screenName Optional screen name (defaults to current screen).
265
- * @param extra Optional extra data to attach to the event.
266
- */
267
- trackTouch(
268
- x: number,
269
- y: number,
270
- screenName?: string,
271
- extra?: Record<string, any>,
272
- ): void {
273
- if (!this.sessionId) {
274
- this.logger.warn('Cannot track touch: no active session');
275
- return;
276
- }
277
-
278
- if (!this.config.enableTouchTracking) {
279
- return;
280
- }
281
-
282
- // Auto-detect: find which (if any) registered ScrollView contains
283
- // this touch. If multiple match (nested ScrollViews, e.g. FlatList
284
- // inside ScrollView), pick the SMALLEST by area — the most-nested
285
- // one is the actual scroll context for that touch.
286
- let bestMatchOffset = 0;
287
- let bestMatchArea = Infinity;
288
- let foundMatch = false;
289
-
290
- for (const info of this.scrollViewRegistry.values()) {
291
- const { bounds } = info;
292
- if (
293
- x >= bounds.x &&
294
- x <= bounds.x + bounds.width &&
295
- y >= bounds.y &&
296
- y <= bounds.y + bounds.height
297
- ) {
298
- const area = bounds.width * bounds.height;
299
- if (area < bestMatchArea) {
300
- bestMatchArea = area;
301
- bestMatchOffset = info.scrollOffsetY;
302
- foundMatch = true;
303
- }
304
- }
305
- }
306
-
307
- let activeScrollOffset: number;
308
- let isFixed: boolean;
309
-
310
- if (foundMatch) {
311
- // Touch is inside a registered ScrollView → content touch
312
- activeScrollOffset = bestMatchOffset;
313
- isFixed = false;
314
- } else if (this.scrollViewRegistry.size > 0) {
315
- // ScrollViews ARE registered, but this touch is outside all of them
316
- // it's on fixed UI (nav bar, header, etc.)
317
- activeScrollOffset = 0;
318
- isFixed = true;
319
- } else {
320
- // No ScrollViews registered at all. Two possible cases:
321
- // (a) The screen has no scrollable content → touch is fixed
322
- // (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
323
- // Either way, marking as fixed is safer than guessing content
324
- // position with stale scrollOffsetY from a previous screen.
325
- activeScrollOffset = 0;
326
- isFixed = true;
327
- }
328
-
329
- const adjustedY = y + activeScrollOffset;
330
-
331
- const event: AnalyticsEvent = {
332
- type: 'touch',
333
- screenName: screenName || this.currentScreen || undefined,
334
- data: {
335
- x,
336
- y: adjustedY,
337
- scrollOffsetY: activeScrollOffset,
338
- isFixed,
339
- ...extra,
340
- },
341
- timestamp: new Date().toISOString(),
342
- };
343
-
344
- this.eventQueue.add(event);
345
- this.logger.log('Touch tracked:', event);
346
- }
347
-
348
- trackNavigation(fromScreen: string, toScreen: string): void {
349
- if (!this.sessionId) {
350
- this.logger.warn('Cannot track navigation: no active session');
351
- return;
352
- }
353
-
354
- if (!this.config.enableNavigationTracking) {
355
- return;
356
- }
357
-
358
- const event: AnalyticsEvent = {
359
- type: 'navigation',
360
- screenName: toScreen,
361
- data: { fromScreen, toScreen },
362
- timestamp: new Date().toISOString(),
363
- };
364
-
365
- this.eventQueue.add(event);
366
- this.logger.log('Navigation tracked:', fromScreen, '->', toScreen);
367
- }
368
-
369
- trackCustomEvent(eventName: string, data?: Record<string, any>): void {
370
- if (!this.sessionId) {
371
- this.logger.warn('Cannot track custom event: no active session');
372
- return;
373
- }
374
-
375
- const event: AnalyticsEvent = {
376
- type: 'custom',
377
- screenName: this.currentScreen || undefined,
378
- data: { eventName, ...data },
379
- timestamp: new Date().toISOString(),
380
- };
381
-
382
- this.eventQueue.add(event);
383
- this.logger.log('Custom event tracked:', eventName);
384
- }
385
-
386
- reportError(error: Error, metadata?: Record<string, any>): void {
387
- if (!this.errorTracker) {
388
- this.logger.warn(
389
- 'Cannot report error: crash tracking is not enabled',
390
- );
391
- return;
392
- }
393
-
394
- this.errorTracker.reportError(error, metadata);
395
- }
396
-
397
- /**
398
- * Update the current scroll offset. Called by RepliqoScrollView
399
- * or manually from the host app's ScrollView onScroll handler.
400
- * The offset is added to touch Y coordinates for accurate heatmaps
401
- * on scrollable screens.
402
- *
403
- * @deprecated Prefer registerScrollView + updateScrollViewOffset for
404
- * auto-detection of fixed vs scrollable touches. Kept for backward compat.
405
- */
406
- setScrollOffset(y: number): void {
407
- this.scrollOffsetY = y;
408
- }
409
-
410
- /**
411
- * Register a ScrollView's window-relative bounds with the SDK.
412
- * Called by RepliqoScrollView on layout. Used to auto-detect whether
413
- * a touch is on scrollable content or on fixed UI (nav bars, headers).
414
- */
415
- registerScrollView(
416
- id: string,
417
- bounds: { x: number; y: number; width: number; height: number },
418
- ): void {
419
- const existing = this.scrollViewRegistry.get(id);
420
- this.scrollViewRegistry.set(id, {
421
- bounds,
422
- scrollOffsetY: existing?.scrollOffsetY ?? 0,
423
- });
424
- }
425
-
426
- /**
427
- * Update the scroll offset for a previously-registered ScrollView.
428
- * Called by RepliqoScrollView on every scroll event.
429
- */
430
- updateScrollViewOffset(id: string, scrollOffsetY: number): void {
431
- const existing = this.scrollViewRegistry.get(id);
432
- if (existing) {
433
- existing.scrollOffsetY = scrollOffsetY;
434
- }
435
- }
436
-
437
- /**
438
- * Remove a ScrollView from the registry. Called by RepliqoScrollView
439
- * on unmount.
440
- */
441
- unregisterScrollView(id: string): void {
442
- this.scrollViewRegistry.delete(id);
443
- }
444
-
445
- /**
446
- * Capture the current viewport as a tile for collaborative screen
447
- * building. Called by RepliqoScrollView on scroll stops.
448
- *
449
- * The tile includes:
450
- * - `scrollOffsetY`: where in the full content this viewport belongs
451
- * - `viewportTop`: Y position (window logical px) of the ScrollView's
452
- * top edge. Used by the backend to crop each tile to just the
453
- * scrollable area, removing fixed headers/footers that would
454
- * otherwise duplicate in the composite.
455
- * - `windowHeight`: window height in logical px, for image-to-logical
456
- * scale computation during backend cropping.
457
- */
458
- captureScrollTile(
459
- scrollOffsetY: number,
460
- viewportHeight: number,
461
- contentHeight: number,
462
- viewportTop?: number,
463
- windowHeight?: number,
464
- ): void {
465
- if (!this.sessionId || !this.currentScreen) return;
466
- if (viewportHeight <= 0) return; // No real dimensions yet
467
-
468
- const screenName = this.currentScreen;
469
- const sessionId = this.sessionId;
470
-
471
- // Fire-and-forget: capture + upload in background
472
- (async () => {
473
- try {
474
- const frame = await captureNativeFrame();
475
- if (!frame) return;
476
-
477
- this.logger.log(
478
- `Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
479
- `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
480
- `top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
481
- `${Math.round(frame.image.length / 1024)}KB`,
482
- );
483
-
484
- await this.apiClient.uploadScreenTile(
485
- sessionId,
486
- screenName,
487
- frame.image,
488
- frame.width,
489
- frame.height,
490
- scrollOffsetY,
491
- viewportHeight,
492
- contentHeight,
493
- viewportTop,
494
- windowHeight,
495
- );
496
- } catch (err) {
497
- this.logger.warn('Scroll tile failed:', err);
498
- }
499
- })();
500
- }
501
-
502
- /**
503
- * Programmatically scroll-scan the current ScrollView and upload all
504
- * tiles to the backend. Invisible to the user (~300-500ms).
505
- */
506
- private autoScanScreen(screenName: string): void {
507
- (async () => {
508
- try {
509
- const tiles = await scanFullContent();
510
- if (!tiles || tiles.length === 0) {
511
- this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
512
- return;
513
- }
514
-
515
- this.logger.log(
516
- `Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
517
- `(content=${tiles[0].contentHeight}px)`,
518
- );
519
-
520
- // Upload all tiles in parallel
521
- const sessionId = this.sessionId!;
522
- await Promise.all(
523
- tiles.map((tile) =>
524
- this.apiClient.uploadScreenTile(
525
- sessionId,
526
- screenName,
527
- tile.image,
528
- tile.width,
529
- tile.height,
530
- tile.scrollOffsetY,
531
- tile.viewportHeight,
532
- tile.contentHeight,
533
- ).catch(() => {}),
534
- ),
535
- );
536
-
537
- this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
538
- } catch (err) {
539
- this.logger.warn('Auto-scan failed:', err);
540
- }
541
- })();
542
- }
543
-
544
- onScreenEnter(screenName: string): void {
545
- this.currentScreen = screenName;
546
- this.currentScreenEnteredAt = new Date().toISOString();
547
- this.scrollOffsetY = 0;
548
- // Clear the ScrollView registry — old screen's scrollviews are gone
549
- this.scrollViewRegistry.clear();
550
- this.errorTracker?.setCurrentScreen(screenName);
551
- this.snapshotCapture?.setCurrentScreen(screenName);
552
- // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
553
- // React Native does NOT render off-screen content to the GPU or view
554
- // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
555
- // tiles. Worse, those blank tiles poison the dedup cache and block
556
- // real tiles captured during natural user scroll.
557
- //
558
- // Full-content capture relies entirely on RepliqoScrollView capturing
559
- // viewport tiles as the user naturally scrolls through the content.
560
-
561
- this.logger.log('Screen entered:', screenName);
562
- }
563
-
564
- onScreenExit(screenName: string): void {
565
- if (
566
- this.sessionId &&
567
- this.currentScreen === screenName &&
568
- this.currentScreenEnteredAt
569
- ) {
570
- const enteredAt = this.currentScreenEnteredAt;
571
- const exitedAt = new Date().toISOString();
572
- const duration =
573
- new Date(exitedAt).getTime() - new Date(enteredAt).getTime();
574
-
575
- const visit: ScreenVisit = {
576
- sessionId: this.sessionId,
577
- screenName,
578
- enteredAt,
579
- exitedAt,
580
- duration,
581
- };
582
-
583
- this.screenVisitQueue.add(visit);
584
- this.logger.log('Screen exited:', screenName, 'duration:', duration);
585
- }
586
-
587
- if (this.currentScreen === screenName) {
588
- this.currentScreen = null;
589
- this.currentScreenEnteredAt = null;
590
- this.errorTracker?.setCurrentScreen(null);
591
- }
592
- }
593
-
594
- async flush(): Promise<void> {
595
- this.logger.log('Flushing queues...');
596
- await Promise.all([
597
- this.eventQueue.flush(),
598
- this.screenVisitQueue.flush(),
599
- this.snapshotQueue.flush(),
600
- ]);
601
- this.logger.log('Flush complete');
602
- }
603
-
604
- getSessionId(): string | null {
605
- return this.sessionId;
606
- }
607
-
608
- getCurrentScreen(): string | null {
609
- return this.currentScreen;
610
- }
611
-
612
- /**
613
- * Force an immediate screenshot capture, outside the periodic schedule.
614
- * Useful for capturing specific moments (e.g. right after a critical
615
- * user action). Respects the in-flight lock and session cap.
616
- */
617
- async captureSnapshot(screenName?: string): Promise<void> {
618
- if (!this.sessionId || !this.snapshotCapture) {
619
- return;
620
- }
621
- if (screenName) {
622
- this.snapshotCapture.setCurrentScreen(screenName);
623
- }
624
- await this.snapshotCapture.captureNow();
625
- }
626
-
627
- startSnapshotCapture(): void {
628
- if (this.snapshotCapture) {
629
- this.snapshotCapture.start();
630
- this.logger.log('Snapshot capture started');
631
- }
632
- }
633
-
634
- stopSnapshotCapture(): void {
635
- if (this.snapshotCapture) {
636
- this.snapshotCapture.stop();
637
- this.logger.log('Snapshot capture stopped');
638
- }
639
- }
640
-
641
- isInitialized(): boolean {
642
- return true;
643
- }
644
-
645
- private setupAppStateListener(): void {
646
- let wasInBackground = false;
647
-
648
- this.appStateSubscription = AppState.addEventListener(
649
- 'change',
650
- (nextAppState: AppStateStatus) => {
651
- if (nextAppState === 'background' || nextAppState === 'inactive') {
652
- this.logger.log(
653
- 'App going to background, pausing snapshots + flushing',
654
- );
655
- this.stopSnapshotCapture();
656
- wasInBackground = true;
657
-
658
- this.flush().catch((error) => {
659
- this.logger.error('Error flushing on app state change:', error);
660
- });
661
- } else if (nextAppState === 'active' && wasInBackground) {
662
- wasInBackground = false;
663
- this.logger.log('App returned to foreground, resuming snapshots');
664
-
665
- if (this.snapshotCapture && this.sessionId) {
666
- this.startSnapshotCapture();
667
- }
668
- }
669
- },
670
- );
671
- }
672
- }
1
+ import { AppState, AppStateStatus } from 'react-native';
2
+ import {
3
+ AnalyticsEvent,
4
+ DeviceInfo,
5
+ ScreenVisit,
6
+ SDKConfig,
7
+ } from '../types/events';
8
+ import { SnapshotPayload } from '../types/snapshot';
9
+ import { CrashReport } from '../types/crash';
10
+ import { SnapshotCapture } from '../snapshot/capture';
11
+ import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
12
+ import {
13
+ PersistentStorage,
14
+ tryLoadAsyncStorage,
15
+ STORAGE_KEYS,
16
+ MAX_PERSISTED_EVENTS,
17
+ MAX_PERSISTED_CRASHES,
18
+ } from './storage';
19
+ import { Logger } from './logger';
20
+ import { ApiClient } from '../transport/api.client';
21
+ import {
22
+ BatchQueue,
23
+ NonRetryableFlushError,
24
+ PartialFlushError,
25
+ } from '../transport/batch-queue';
26
+ import { ErrorTracker } from '../trackers/error.tracker';
27
+ import { captureNativeFrame } from '../snapshot/NativeScreenCapture';
28
+
29
+ /**
30
+ * An analytics event stamped with the session that was active when it was
31
+ * enqueued. Grouped per session at flush time so buffered events are never
32
+ * attributed to a newer session (cross-session contamination).
33
+ */
34
+ type QueuedEvent = AnalyticsEvent & { __sessionId: string };
35
+
36
+ export class AppAnalytics {
37
+ private static instance: AppAnalytics | null = null;
38
+
39
+ private config: ResolvedConfig;
40
+ private apiClient: ApiClient;
41
+ private eventQueue: BatchQueue<QueuedEvent>;
42
+ private screenVisitQueue: BatchQueue<ScreenVisit>;
43
+ private snapshotQueue: BatchQueue<SnapshotPayload>;
44
+ private snapshotCapture: SnapshotCapture | null = null;
45
+ private errorTracker: ErrorTracker | null = null;
46
+ private sessionId: string | null = null;
47
+ private currentScreen: string | null = null;
48
+ private currentScreenEnteredAt: string | null = null;
49
+ private scrollOffsetY: number = 0;
50
+ private logger: Logger;
51
+ private appStateSubscription: { remove: () => void } | null = null;
52
+
53
+ /**
54
+ * User identity set via identify(). Kept for the process lifetime and
55
+ * automatically re-attached to every new session, so the host app only
56
+ * needs to call identify() once (e.g. after login).
57
+ */
58
+ private identity: {
59
+ userId: string;
60
+ traits?: Record<string, unknown>;
61
+ } | null = null;
62
+
63
+ /**
64
+ * Offline persistence backend (AsyncStorage-compatible). Null when the
65
+ * host app neither passed one nor has AsyncStorage installed — the SDK
66
+ * then behaves exactly as before (in-memory buffering only).
67
+ */
68
+ private storage: PersistentStorage | null = null;
69
+
70
+ /**
71
+ * Registry of active ScrollViews on the current screen.
72
+ * Each RepliqoScrollView registers its window-relative bounds and current
73
+ * scroll state. Used by trackTouch to auto-detect:
74
+ * - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
75
+ * scrollOffset, mark as content touch
76
+ * - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
77
+ *
78
+ * This makes heatmaps render fixed elements (nav bars, headers) at their
79
+ * viewport position, while still mapping scrollable content correctly.
80
+ */
81
+ private scrollViewRegistry = new Map<
82
+ string,
83
+ {
84
+ bounds: { x: number; y: number; width: number; height: number };
85
+ scrollOffsetY: number;
86
+ }
87
+ >();
88
+
89
+ private constructor(config: SDKConfig) {
90
+ this.config = resolveConfig(config);
91
+ this.logger = new Logger(this.config.debug);
92
+ this.apiClient = new ApiClient(
93
+ REPLIQO_API_URL,
94
+ this.config.apiKey,
95
+ this.logger,
96
+ );
97
+
98
+ // Events are stamped with their session at ENQUEUE time (__sessionId)
99
+ // and sent per session at flush time. Reading this.sessionId at flush
100
+ // time instead would attribute events buffered at the end of session A
101
+ // to session B if a new session starts before the flush.
102
+ //
103
+ // Delivery is per CONTIGUOUS RUN of same-session events (not a merged
104
+ // map) so the queue's prefix accounting stays exact even if sessions
105
+ // interleave in the buffer (e.g. restored old-session events landing
106
+ // between new-session events). On a transient failure we report the
107
+ // delivered prefix via PartialFlushError so the queue retries ONLY the
108
+ // remainder — never re-sending delivered events (duplicates) and never
109
+ // dropping unattempted ones alongside a poison run.
110
+ this.eventQueue = new BatchQueue<QueuedEvent>(
111
+ this.config.batchSize,
112
+ this.config.flushInterval,
113
+ async (queued: QueuedEvent[]) => {
114
+ // Split into contiguous same-session runs, preserving order.
115
+ const runs: Array<{ sessionId: string; events: AnalyticsEvent[] }> = [];
116
+ for (const { __sessionId, ...event } of queued) {
117
+ const last = runs[runs.length - 1];
118
+ if (last && last.sessionId === __sessionId) {
119
+ last.events.push(event);
120
+ } else {
121
+ runs.push({ sessionId: __sessionId, events: [event] });
122
+ }
123
+ }
124
+
125
+ let consumed = 0;
126
+ for (const run of runs) {
127
+ try {
128
+ await this.apiClient.sendEventsBatch(run.sessionId, run.events);
129
+ consumed += run.events.length;
130
+ } catch (err) {
131
+ if (err instanceof NonRetryableFlushError) {
132
+ // Poison run: permanently rejected. Count it as consumed
133
+ // (dropped) and keep delivering the remaining runs.
134
+ this.logger.warn(
135
+ `Dropping ${run.events.length} events permanently rejected for session ${run.sessionId}`,
136
+ );
137
+ consumed += run.events.length;
138
+ continue;
139
+ }
140
+ // Transient failure: stop here. Anything already delivered or
141
+ // dropped must not be retried.
142
+ if (consumed > 0) {
143
+ throw new PartialFlushError(consumed, true);
144
+ }
145
+ throw err;
146
+ }
147
+ }
148
+ },
149
+ );
150
+
151
+ this.screenVisitQueue = new BatchQueue<ScreenVisit>(
152
+ this.config.batchSize,
153
+ this.config.flushInterval,
154
+ async (visits: ScreenVisit[]) => {
155
+ await this.apiClient.sendScreenVisitsBatch(visits);
156
+ },
157
+ );
158
+
159
+ // Snapshots are heavier (~15-30 KB JPEG each) so they use a smaller
160
+ // batch size, shorter flush interval, and a tighter memory cap than
161
+ // the event queue. All configurable via SDKConfig.
162
+ this.snapshotQueue = new BatchQueue<SnapshotPayload>(
163
+ this.config.snapshotBatchSize,
164
+ this.config.snapshotFlushInterval,
165
+ async (snapshots: SnapshotPayload[]) => {
166
+ await this.apiClient.sendSnapshotsBatch(snapshots);
167
+ },
168
+ this.config.snapshotMaxBufferSize,
169
+ );
170
+
171
+ this.eventQueue.startAutoFlush();
172
+ this.screenVisitQueue.startAutoFlush();
173
+ this.snapshotQueue.startAutoFlush();
174
+
175
+ if (this.config.enableSnapshots) {
176
+ this.snapshotCapture = new SnapshotCapture(
177
+ {
178
+ captureInterval: this.config.snapshotInterval,
179
+ maxSnapshotsPerSession: this.config.maxSnapshotsPerSession,
180
+ },
181
+ (snapshot: SnapshotPayload) => {
182
+ this.snapshotQueue.add(snapshot);
183
+ },
184
+ undefined, // default captureScreenshot provider
185
+ (...args: any[]) => this.logger.log(...args),
186
+ (...args: any[]) => this.logger.warn(...args),
187
+ );
188
+ }
189
+
190
+ // Offline persistence: explicit storage from config, or auto-loaded
191
+ // AsyncStorage if the host app has it installed.
192
+ this.storage = this.config.storage ?? tryLoadAsyncStorage();
193
+
194
+ if (this.config.enableCrashTracking) {
195
+ this.errorTracker = new ErrorTracker((crash) => {
196
+ // Persist FIRST on a fatal crash the app dies before the network
197
+ // request completes; the persisted copy is re-sent on next launch.
198
+ // Then send immediately and clear the persisted copy on success.
199
+ (async () => {
200
+ const key = await this.persistCrash(crash);
201
+ const sent = await this.apiClient.sendCrashReport(crash);
202
+ if (sent && key) {
203
+ await this.removePendingCrash(key);
204
+ }
205
+ })().catch(() => {});
206
+ });
207
+ this.errorTracker.start();
208
+ }
209
+
210
+ this.setupAppStateListener();
211
+
212
+ // Re-send anything persisted by a previous run (events buffered at
213
+ // backgrounding, crashes whose send never completed).
214
+ this.restorePersistedData().catch(() => {});
215
+
216
+ this.logger.log('SDK initialized with config:', {
217
+ apiUrl: REPLIQO_API_URL,
218
+ appId: this.config.appId,
219
+ batchSize: this.config.batchSize,
220
+ flushInterval: this.config.flushInterval,
221
+ enableSnapshots: this.config.enableSnapshots,
222
+ snapshotInterval: this.config.snapshotInterval,
223
+ enableCrashTracking: this.config.enableCrashTracking,
224
+ });
225
+ }
226
+
227
+ static init(config: SDKConfig): AppAnalytics {
228
+ if (AppAnalytics.instance) {
229
+ AppAnalytics.instance.logger.warn(
230
+ 'AppAnalytics already initialized. Returning existing instance.',
231
+ );
232
+ return AppAnalytics.instance;
233
+ }
234
+
235
+ AppAnalytics.instance = new AppAnalytics(config);
236
+ return AppAnalytics.instance;
237
+ }
238
+
239
+ static getInstance(): AppAnalytics {
240
+ if (!AppAnalytics.instance) {
241
+ throw new Error(
242
+ 'AppAnalytics not initialized. Call AppAnalytics.init(config) first.',
243
+ );
244
+ }
245
+ return AppAnalytics.instance;
246
+ }
247
+
248
+ static destroy(): void {
249
+ if (AppAnalytics.instance) {
250
+ const instance = AppAnalytics.instance;
251
+ instance.logger.log('Destroying AppAnalytics instance');
252
+
253
+ if (instance.errorTracker) {
254
+ instance.errorTracker.stop();
255
+ instance.errorTracker = null;
256
+ }
257
+
258
+ if (instance.snapshotCapture) {
259
+ instance.snapshotCapture.stop();
260
+ instance.snapshotCapture = null;
261
+ }
262
+
263
+
264
+ instance.eventQueue.stopAutoFlush();
265
+ instance.screenVisitQueue.stopAutoFlush();
266
+ instance.snapshotQueue.stopAutoFlush();
267
+
268
+ if (instance.appStateSubscription) {
269
+ instance.appStateSubscription.remove();
270
+ instance.appStateSubscription = null;
271
+ }
272
+
273
+ // Close the session on the server (also flushes remaining data).
274
+ // Fire-and-forget: destroy() is sync and must not block.
275
+ if (instance.sessionId) {
276
+ instance.endSession().catch(() => {});
277
+ } else {
278
+ instance.flush().catch(() => {});
279
+ }
280
+
281
+ AppAnalytics.instance = null;
282
+ }
283
+ }
284
+
285
+ async startSession(
286
+ deviceId: string,
287
+ deviceInfo?: DeviceInfo,
288
+ ): Promise<string> {
289
+ // Calling startSession with a session already active would leak the
290
+ // previous session (left open forever on the backend) and strand its
291
+ // buffered events. Close it first.
292
+ if (this.sessionId) {
293
+ this.logger.warn('startSession called with an active session; ending it first');
294
+ await this.endSession();
295
+ }
296
+
297
+ try {
298
+ const response = await this.apiClient.startSession(deviceId, deviceInfo);
299
+ this.sessionId = response.id;
300
+ this.logger.log('Session started with ID:', this.sessionId);
301
+
302
+ this.errorTracker?.setSessionId(this.sessionId);
303
+
304
+ // Re-attach a previously-set identity to the new session so the
305
+ // host app only needs to call identify() once per login.
306
+ if (this.identity) {
307
+ this.apiClient
308
+ .identifySession(this.sessionId, this.identity.userId, this.identity.traits)
309
+ .catch(() => {});
310
+ }
311
+
312
+ if (this.snapshotCapture) {
313
+ this.snapshotCapture.setSessionId(this.sessionId);
314
+ this.snapshotCapture.reset();
315
+
316
+ // Native capture (MediaProjection) is NOT started automatically.
317
+ // It requires a system dialog that is confusing if shown on every
318
+ // launch. The host app can call enableNativeCapture() explicitly
319
+ // when the user opts in. Until then, view-shot is used.
320
+ this.startSnapshotCapture();
321
+ }
322
+
323
+ return this.sessionId;
324
+ } catch (error) {
325
+ this.logger.error('Failed to start session:', error);
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ async endSession(): Promise<void> {
331
+ if (!this.sessionId) {
332
+ this.logger.warn('No active session to end');
333
+ return;
334
+ }
335
+
336
+ // Stop snapshot capture
337
+ this.stopSnapshotCapture();
338
+
339
+ // Flush any pending screen exit
340
+ if (this.currentScreen && this.currentScreenEnteredAt) {
341
+ this.onScreenExit(this.currentScreen);
342
+ }
343
+
344
+ // Flush remaining data
345
+ await this.flush();
346
+
347
+ try {
348
+ await this.apiClient.endSession(this.sessionId);
349
+ this.logger.log('Session ended:', this.sessionId);
350
+ } catch (error) {
351
+ this.logger.error('Failed to end session:', error);
352
+ }
353
+
354
+ this.sessionId = null;
355
+ this.currentScreen = null;
356
+ this.currentScreenEnteredAt = null;
357
+
358
+ this.errorTracker?.setSessionId(null);
359
+
360
+ if (this.snapshotCapture) {
361
+ this.snapshotCapture.setSessionId(null);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Track a touch event for heatmap analysis.
367
+ *
368
+ * @param x Touch X coordinate, in WINDOW coordinates (must match the
369
+ * coordinate system of `measureInWindow` typically `pageX`
370
+ * from a React Native gesture event).
371
+ * @param y Touch Y coordinate, in WINDOW coordinates (must match the
372
+ * coordinate system of `measureInWindow` — typically `pageY`
373
+ * from a React Native gesture event). On Android with edge-to-edge
374
+ * enabled, ensure `pageY` excludes the status bar to stay
375
+ * consistent with `measureInWindow`.
376
+ * @param screenName Optional screen name (defaults to current screen).
377
+ * @param extra Optional extra data to attach to the event.
378
+ */
379
+ trackTouch(
380
+ x: number,
381
+ y: number,
382
+ screenName?: string,
383
+ extra?: Record<string, any>,
384
+ ): void {
385
+ if (!this.sessionId) {
386
+ this.logger.warn('Cannot track touch: no active session');
387
+ return;
388
+ }
389
+
390
+ if (!this.config.enableTouchTracking) {
391
+ return;
392
+ }
393
+
394
+ // Auto-detect: find which (if any) registered ScrollView contains
395
+ // this touch. If multiple match (nested ScrollViews, e.g. FlatList
396
+ // inside ScrollView), pick the SMALLEST by area — the most-nested
397
+ // one is the actual scroll context for that touch.
398
+ let bestMatchOffset = 0;
399
+ let bestMatchArea = Infinity;
400
+ let foundMatch = false;
401
+
402
+ for (const info of this.scrollViewRegistry.values()) {
403
+ const { bounds } = info;
404
+ if (
405
+ x >= bounds.x &&
406
+ x <= bounds.x + bounds.width &&
407
+ y >= bounds.y &&
408
+ y <= bounds.y + bounds.height
409
+ ) {
410
+ const area = bounds.width * bounds.height;
411
+ if (area < bestMatchArea) {
412
+ bestMatchArea = area;
413
+ bestMatchOffset = info.scrollOffsetY;
414
+ foundMatch = true;
415
+ }
416
+ }
417
+ }
418
+
419
+ let activeScrollOffset: number;
420
+ let isFixed: boolean;
421
+
422
+ if (foundMatch) {
423
+ // Touch is inside a registered ScrollView → content touch
424
+ activeScrollOffset = bestMatchOffset;
425
+ isFixed = false;
426
+ } else if (this.scrollViewRegistry.size > 0) {
427
+ // ScrollViews ARE registered, but this touch is outside all of them
428
+ // it's on fixed UI (nav bar, header, etc.)
429
+ activeScrollOffset = 0;
430
+ isFixed = true;
431
+ } else if (this.scrollOffsetY > 0) {
432
+ // No ScrollViews registered, but the host app is reporting scroll
433
+ // manually via setScrollOffset() (legacy integration without
434
+ // RepliqoScrollView). Honor it — otherwise those touches would be
435
+ // misclassified as fixed and land at the wrong heatmap position.
436
+ // Stale values are not a risk: onScreenEnter resets it to 0.
437
+ activeScrollOffset = this.scrollOffsetY;
438
+ isFixed = false;
439
+ } else {
440
+ // No ScrollViews registered and no manual offset. Two cases:
441
+ // (a) The screen has no scrollable content → touch is fixed
442
+ // (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
443
+ // Either way, marking as fixed is safer than guessing.
444
+ activeScrollOffset = 0;
445
+ isFixed = true;
446
+ }
447
+
448
+ const adjustedY = y + activeScrollOffset;
449
+
450
+ const event: AnalyticsEvent = {
451
+ type: 'touch',
452
+ screenName: screenName || this.currentScreen || undefined,
453
+ data: {
454
+ x,
455
+ y: adjustedY,
456
+ scrollOffsetY: activeScrollOffset,
457
+ isFixed,
458
+ ...extra,
459
+ },
460
+ timestamp: new Date().toISOString(),
461
+ };
462
+
463
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
464
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
465
+ this.logger.log('Touch tracked:', event);
466
+ }
467
+
468
+ trackNavigation(fromScreen: string, toScreen: string): void {
469
+ if (!this.sessionId) {
470
+ this.logger.warn('Cannot track navigation: no active session');
471
+ return;
472
+ }
473
+
474
+ if (!this.config.enableNavigationTracking) {
475
+ return;
476
+ }
477
+
478
+ const event: AnalyticsEvent = {
479
+ type: 'navigation',
480
+ screenName: toScreen,
481
+ data: { fromScreen, toScreen },
482
+ timestamp: new Date().toISOString(),
483
+ };
484
+
485
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
486
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
487
+ this.logger.log('Navigation tracked:', fromScreen, '->', toScreen);
488
+ }
489
+
490
+ trackCustomEvent(eventName: string, data?: Record<string, any>): void {
491
+ if (!this.sessionId) {
492
+ this.logger.warn('Cannot track custom event: no active session');
493
+ return;
494
+ }
495
+
496
+ const event: AnalyticsEvent = {
497
+ type: 'custom',
498
+ screenName: this.currentScreen || undefined,
499
+ data: { eventName, ...data },
500
+ timestamp: new Date().toISOString(),
501
+ };
502
+
503
+ // Stamp the ACTIVE session at enqueue time (see QueuedEvent).
504
+ this.eventQueue.add({ ...event, __sessionId: this.sessionId });
505
+ this.logger.log('Custom event tracked:', eventName);
506
+ }
507
+
508
+ /**
509
+ * Identify the current user (call once after login). The identity is
510
+ * attached to the active session immediately and re-attached to every
511
+ * future session automatically until resetIdentity() is called.
512
+ *
513
+ * @param userId The host app's own user identifier.
514
+ * @param traits Optional free-form traits (plan, role, locale...).
515
+ * Avoid PII you don't want stored (emails in plain text).
516
+ */
517
+ identify(userId: string, traits?: Record<string, unknown>): void {
518
+ if (!userId || typeof userId !== 'string') {
519
+ this.logger.warn('identify() requires a non-empty string userId');
520
+ return;
521
+ }
522
+
523
+ this.identity = { userId, traits };
524
+ this.logger.log('Identity set:', userId);
525
+
526
+ if (this.sessionId) {
527
+ this.apiClient
528
+ .identifySession(this.sessionId, userId, traits)
529
+ .catch(() => {});
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Clear the stored identity (call on logout). Sessions started after
535
+ * this are anonymous again; already-identified sessions keep their user.
536
+ */
537
+ resetIdentity(): void {
538
+ this.identity = null;
539
+ this.logger.log('Identity cleared');
540
+ }
541
+
542
+ reportError(error: Error, metadata?: Record<string, any>): void {
543
+ if (!this.errorTracker) {
544
+ this.logger.warn(
545
+ 'Cannot report error: crash tracking is not enabled',
546
+ );
547
+ return;
548
+ }
549
+
550
+ this.errorTracker.reportError(error, metadata);
551
+ }
552
+
553
+ /**
554
+ * Update the current scroll offset. Called by RepliqoScrollView
555
+ * or manually from the host app's ScrollView onScroll handler.
556
+ * The offset is added to touch Y coordinates for accurate heatmaps
557
+ * on scrollable screens.
558
+ *
559
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
560
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
561
+ */
562
+ setScrollOffset(y: number): void {
563
+ this.scrollOffsetY = y;
564
+ }
565
+
566
+ /**
567
+ * Register a ScrollView's window-relative bounds with the SDK.
568
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
569
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
570
+ */
571
+ registerScrollView(
572
+ id: string,
573
+ bounds: { x: number; y: number; width: number; height: number },
574
+ ): void {
575
+ const existing = this.scrollViewRegistry.get(id);
576
+ this.scrollViewRegistry.set(id, {
577
+ bounds,
578
+ scrollOffsetY: existing?.scrollOffsetY ?? 0,
579
+ });
580
+ }
581
+
582
+ /**
583
+ * Update the scroll offset for a previously-registered ScrollView.
584
+ * Called by RepliqoScrollView on every scroll event.
585
+ */
586
+ updateScrollViewOffset(id: string, scrollOffsetY: number): void {
587
+ const existing = this.scrollViewRegistry.get(id);
588
+ if (existing) {
589
+ existing.scrollOffsetY = scrollOffsetY;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
595
+ * on unmount.
596
+ */
597
+ unregisterScrollView(id: string): void {
598
+ this.scrollViewRegistry.delete(id);
599
+ }
600
+
601
+ /**
602
+ * Capture the current viewport as a tile for collaborative screen
603
+ * building. Called by RepliqoScrollView on scroll stops.
604
+ *
605
+ * The tile includes:
606
+ * - `scrollOffsetY`: where in the full content this viewport belongs
607
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
608
+ * top edge. Used by the backend to crop each tile to just the
609
+ * scrollable area, removing fixed headers/footers that would
610
+ * otherwise duplicate in the composite.
611
+ * - `windowHeight`: window height in logical px, for image-to-logical
612
+ * scale computation during backend cropping.
613
+ */
614
+ captureScrollTile(
615
+ scrollOffsetY: number,
616
+ viewportHeight: number,
617
+ contentHeight: number,
618
+ viewportTop?: number,
619
+ windowHeight?: number,
620
+ ): void {
621
+ if (!this.sessionId || !this.currentScreen) return;
622
+ if (viewportHeight <= 0) return; // No real dimensions yet
623
+
624
+ const screenName = this.currentScreen;
625
+ const sessionId = this.sessionId;
626
+
627
+ // Fire-and-forget: capture + upload in background
628
+ (async () => {
629
+ try {
630
+ const frame = await captureNativeFrame();
631
+ if (!frame) return;
632
+
633
+ this.logger.log(
634
+ `Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
635
+ `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
636
+ `top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
637
+ `${Math.round(frame.image.length / 1024)}KB`,
638
+ );
639
+
640
+ await this.apiClient.uploadScreenTile(
641
+ sessionId,
642
+ screenName,
643
+ frame.image,
644
+ frame.width,
645
+ frame.height,
646
+ scrollOffsetY,
647
+ viewportHeight,
648
+ contentHeight,
649
+ viewportTop,
650
+ windowHeight,
651
+ );
652
+ } catch (err) {
653
+ this.logger.warn('Scroll tile failed:', err);
654
+ }
655
+ })();
656
+ }
657
+
658
+ onScreenEnter(screenName: string): void {
659
+ // Re-entering the SAME screen (e.g. onReady + onStateChange both firing
660
+ // for the initial route) must not wipe the ScrollView registry that the
661
+ // screen's RepliqoScrollView already populated touches in the gap
662
+ // until re-measure would be misclassified as fixed UI.
663
+ if (this.currentScreen === screenName) {
664
+ this.logger.log('Screen re-entered (no-op):', screenName);
665
+ return;
666
+ }
667
+
668
+ this.currentScreen = screenName;
669
+ this.currentScreenEnteredAt = new Date().toISOString();
670
+ this.scrollOffsetY = 0;
671
+ // Clear the ScrollView registry — old screen's scrollviews are gone
672
+ this.scrollViewRegistry.clear();
673
+ this.errorTracker?.setCurrentScreen(screenName);
674
+ this.snapshotCapture?.setCurrentScreen(screenName);
675
+ // NOTE: full-content capture is NOT done via programmatic scroll +
676
+ // native capture. React Native does NOT render off-screen content to
677
+ // the GPU or view tree, so any programmatic-scroll approach produces
678
+ // blank tiles. Full-content heatmap backgrounds are built entirely
679
+ // from viewport tiles captured by RepliqoScrollView as the user
680
+ // naturally scrolls through the content.
681
+
682
+ this.logger.log('Screen entered:', screenName);
683
+ }
684
+
685
+ onScreenExit(screenName: string): void {
686
+ if (
687
+ this.sessionId &&
688
+ this.currentScreen === screenName &&
689
+ this.currentScreenEnteredAt
690
+ ) {
691
+ const enteredAt = this.currentScreenEnteredAt;
692
+ const exitedAt = new Date().toISOString();
693
+ const duration =
694
+ new Date(exitedAt).getTime() - new Date(enteredAt).getTime();
695
+
696
+ const visit: ScreenVisit = {
697
+ sessionId: this.sessionId,
698
+ screenName,
699
+ enteredAt,
700
+ exitedAt,
701
+ duration,
702
+ };
703
+
704
+ this.screenVisitQueue.add(visit);
705
+ this.logger.log('Screen exited:', screenName, 'duration:', duration);
706
+ }
707
+
708
+ if (this.currentScreen === screenName) {
709
+ this.currentScreen = null;
710
+ this.currentScreenEnteredAt = null;
711
+ this.errorTracker?.setCurrentScreen(null);
712
+ }
713
+ }
714
+
715
+ async flush(): Promise<void> {
716
+ this.logger.log('Flushing queues...');
717
+ await Promise.all([
718
+ this.eventQueue.flush(),
719
+ this.screenVisitQueue.flush(),
720
+ this.snapshotQueue.flush(),
721
+ ]);
722
+ this.logger.log('Flush complete');
723
+ }
724
+
725
+ getSessionId(): string | null {
726
+ return this.sessionId;
727
+ }
728
+
729
+ getCurrentScreen(): string | null {
730
+ return this.currentScreen;
731
+ }
732
+
733
+ /**
734
+ * Force an immediate screenshot capture, outside the periodic schedule.
735
+ * Useful for capturing specific moments (e.g. right after a critical
736
+ * user action). Respects the in-flight lock and session cap.
737
+ */
738
+ async captureSnapshot(screenName?: string): Promise<void> {
739
+ if (!this.sessionId || !this.snapshotCapture) {
740
+ return;
741
+ }
742
+ if (screenName) {
743
+ this.snapshotCapture.setCurrentScreen(screenName);
744
+ }
745
+ await this.snapshotCapture.captureNow();
746
+ }
747
+
748
+ startSnapshotCapture(): void {
749
+ if (this.snapshotCapture) {
750
+ this.snapshotCapture.start();
751
+ this.logger.log('Snapshot capture started');
752
+ }
753
+ }
754
+
755
+ stopSnapshotCapture(): void {
756
+ if (this.snapshotCapture) {
757
+ this.snapshotCapture.stop();
758
+ this.logger.log('Snapshot capture stopped');
759
+ }
760
+ }
761
+
762
+ isInitialized(): boolean {
763
+ return true;
764
+ }
765
+
766
+ // ─── Offline persistence ────────────────────────────────────────────
767
+
768
+ /**
769
+ * Persist the un-flushed event buffer (called when backgrounding).
770
+ * Events carry their __sessionId stamp, so on restore they are still
771
+ * attributed to the session they belonged to.
772
+ */
773
+ private async persistPendingEvents(): Promise<void> {
774
+ if (!this.storage) return;
775
+ try {
776
+ const pending = this.eventQueue.peekAll().slice(-MAX_PERSISTED_EVENTS);
777
+ if (pending.length === 0) {
778
+ await this.storage.removeItem(STORAGE_KEYS.pendingEvents);
779
+ return;
780
+ }
781
+ await this.storage.setItem(
782
+ STORAGE_KEYS.pendingEvents,
783
+ JSON.stringify(pending),
784
+ );
785
+ this.logger.log('Persisted', pending.length, 'pending events');
786
+ } catch (err) {
787
+ this.logger.warn('Failed to persist pending events:', err);
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Serialization chain for pending-crash read-modify-write operations.
793
+ * Two near-simultaneous crashes (e.g. an error cascade) would otherwise
794
+ * interleave getItem/setItem and lose one of the entries.
795
+ */
796
+ private crashStoreLock: Promise<unknown> = Promise.resolve();
797
+
798
+ private withCrashStoreLock<T>(op: () => Promise<T>): Promise<T> {
799
+ const run = this.crashStoreLock.then(op, op);
800
+ // Keep the chain alive regardless of op outcome.
801
+ this.crashStoreLock = run.catch(() => {});
802
+ return run;
803
+ }
804
+
805
+ /** Monotonic suffix so identical crashes get distinct keys. */
806
+ private crashSeq = 0;
807
+
808
+ /** Append a crash to the persisted pending list; returns its key. */
809
+ private async persistCrash(crash: CrashReport): Promise<string | null> {
810
+ if (!this.storage) return null;
811
+ const storage = this.storage;
812
+ try {
813
+ return await this.withCrashStoreLock(async () => {
814
+ const raw = await storage.getItem(STORAGE_KEYS.pendingCrashes);
815
+ const list: Array<{ key: string; crash: CrashReport }> = raw
816
+ ? JSON.parse(raw)
817
+ : [];
818
+ // Unique per crash: identical timestamp+message pairs must not
819
+ // share a key or removePendingCrash would delete both.
820
+ const key = `${crash.timestamp}|${++this.crashSeq}|${crash.message}`.slice(0, 300);
821
+ list.push({ key, crash });
822
+ await storage.setItem(
823
+ STORAGE_KEYS.pendingCrashes,
824
+ JSON.stringify(list.slice(-MAX_PERSISTED_CRASHES)),
825
+ );
826
+ return key;
827
+ });
828
+ } catch (err) {
829
+ this.logger.warn('Failed to persist crash:', err);
830
+ return null;
831
+ }
832
+ }
833
+
834
+ /** Remove a crash from the persisted pending list (after send success). */
835
+ private async removePendingCrash(key: string): Promise<void> {
836
+ if (!this.storage) return;
837
+ const storage = this.storage;
838
+ try {
839
+ await this.withCrashStoreLock(async () => {
840
+ const raw = await storage.getItem(STORAGE_KEYS.pendingCrashes);
841
+ if (!raw) return;
842
+ const list: Array<{ key: string; crash: CrashReport }> = JSON.parse(raw);
843
+ const remaining = list.filter((entry) => entry.key !== key);
844
+ if (remaining.length === 0) {
845
+ await storage.removeItem(STORAGE_KEYS.pendingCrashes);
846
+ } else {
847
+ await storage.setItem(
848
+ STORAGE_KEYS.pendingCrashes,
849
+ JSON.stringify(remaining),
850
+ );
851
+ }
852
+ });
853
+ } catch {
854
+ // Best effort — a stale entry only means a duplicate crash report.
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Restore data persisted by a previous run: re-enqueue events (still
860
+ * stamped with their original session) and re-send pending crashes.
861
+ * Called once from the constructor.
862
+ */
863
+ private async restorePersistedData(): Promise<void> {
864
+ if (!this.storage) return;
865
+
866
+ // Events: re-enqueue and clear. They flush with the normal cycle.
867
+ try {
868
+ const raw = await this.storage.getItem(STORAGE_KEYS.pendingEvents);
869
+ if (raw) {
870
+ await this.storage.removeItem(STORAGE_KEYS.pendingEvents);
871
+ const events: QueuedEvent[] = JSON.parse(raw);
872
+ if (Array.isArray(events) && events.length > 0) {
873
+ for (const event of events) {
874
+ if (event && typeof event.__sessionId === 'string') {
875
+ this.eventQueue.add(event);
876
+ }
877
+ }
878
+ this.logger.log('Restored', events.length, 'persisted events');
879
+ }
880
+ }
881
+ } catch (err) {
882
+ this.logger.warn('Failed to restore persisted events:', err);
883
+ }
884
+
885
+ // Crashes: send as a batch; clear only on success.
886
+ try {
887
+ const raw = await this.storage.getItem(STORAGE_KEYS.pendingCrashes);
888
+ if (raw) {
889
+ const list: Array<{ key: string; crash: CrashReport }> = JSON.parse(raw);
890
+ if (Array.isArray(list) && list.length > 0) {
891
+ await this.apiClient.sendCrashesBatch(list.map((e) => e.crash));
892
+ await this.storage.removeItem(STORAGE_KEYS.pendingCrashes);
893
+ this.logger.log('Re-sent', list.length, 'persisted crash reports');
894
+ } else {
895
+ await this.storage.removeItem(STORAGE_KEYS.pendingCrashes);
896
+ }
897
+ }
898
+ } catch (err) {
899
+ // sendCrashesBatch throws on failure → keep the list for next launch.
900
+ this.logger.warn('Failed to re-send persisted crashes:', err);
901
+ }
902
+ }
903
+
904
+ private setupAppStateListener(): void {
905
+ let wasInBackground = false;
906
+
907
+ this.appStateSubscription = AppState.addEventListener(
908
+ 'change',
909
+ (nextAppState: AppStateStatus) => {
910
+ if (nextAppState === 'background' || nextAppState === 'inactive') {
911
+ this.logger.log(
912
+ 'App going to background, pausing snapshots + flushing',
913
+ );
914
+ this.stopSnapshotCapture();
915
+ wasInBackground = true;
916
+
917
+ // Persist FIRST: iOS suspends JS within seconds of backgrounding,
918
+ // and a flush stalled on bad network (up to the 15s fetch timeout)
919
+ // would mean the persist never runs before the OS kills the app —
920
+ // exactly the scenario persistence exists for. A possible
921
+ // duplicate (flush succeeded after persisting) is the lesser
922
+ // evil, and the snapshot is refreshed after the flush anyway.
923
+ this.persistPendingEvents()
924
+ .catch(() => {})
925
+ .then(() => this.flush())
926
+ .catch((error) => {
927
+ this.logger.error('Error flushing on app state change:', error);
928
+ })
929
+ .finally(() => {
930
+ // Refresh the snapshot with whatever is still undelivered
931
+ // (empty ⇒ the key is removed, preventing stale re-sends).
932
+ this.persistPendingEvents().catch(() => {});
933
+ });
934
+ } else if (nextAppState === 'active' && wasInBackground) {
935
+ wasInBackground = false;
936
+ this.logger.log('App returned to foreground, resuming snapshots');
937
+
938
+ // The app survived the background stint — the in-memory buffer is
939
+ // authoritative again. Drop the persisted snapshot so a later
940
+ // crash doesn't re-send events that will be delivered normally
941
+ // from memory (it gets re-written on the next backgrounding).
942
+ if (this.storage) {
943
+ this.storage.removeItem(STORAGE_KEYS.pendingEvents).catch(() => {});
944
+ }
945
+
946
+ if (this.snapshotCapture && this.sessionId) {
947
+ this.startSnapshotCapture();
948
+ }
949
+ }
950
+ },
951
+ );
952
+ }
953
+ }