@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.
- package/INTEGRATION_GUIDE.md +1384 -1312
- package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -230
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -170
- package/dist/components/RepliqoFlatList.d.ts +5 -0
- package/dist/components/RepliqoFlatList.js +39 -0
- package/dist/components/RepliqoScrollView.d.ts +3 -0
- package/dist/components/RepliqoScrollView.js +16 -265
- package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
- package/dist/components/useRepliqoScrollTracking.js +304 -0
- package/dist/core/client.d.ts +52 -5
- package/dist/core/client.js +310 -48
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/storage.d.ts +29 -0
- package/dist/core/storage.js +33 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/snapshot/NativeScreenCapture.d.ts +6 -44
- package/dist/snapshot/NativeScreenCapture.js +6 -64
- package/dist/snapshot/capture.d.ts +7 -0
- package/dist/snapshot/capture.js +17 -5
- package/dist/trackers/error.tracker.d.ts +25 -0
- package/dist/trackers/error.tracker.js +114 -37
- package/dist/trackers/navigation.tracker.js +11 -0
- package/dist/transport/api.client.d.ts +34 -1
- package/dist/transport/api.client.js +102 -19
- package/dist/transport/batch-queue.d.ts +53 -1
- package/dist/transport/batch-queue.js +126 -19
- package/dist/types/events.d.ts +12 -0
- package/package.json +68 -64
- package/src/components/RepliqoFlatList.tsx +64 -0
- package/src/components/RepliqoScrollView.tsx +53 -302
- package/src/components/useRepliqoScrollTracking.ts +366 -0
- package/src/core/client.ts +953 -672
- package/src/core/config.ts +38 -30
- package/src/core/storage.ts +51 -0
- package/src/index.ts +54 -52
- package/src/snapshot/NativeScreenCapture.ts +62 -157
- package/src/snapshot/capture.ts +154 -142
- package/src/trackers/error.tracker.ts +211 -136
- package/src/trackers/navigation.tracker.ts +107 -98
- package/src/transport/api.client.ts +430 -327
- package/src/transport/batch-queue.ts +212 -95
- package/src/types/events.ts +72 -60
- package/android/src/main/java/com/repliqo/screencapture/FullContentCapture.java +0 -273
- package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +0 -279
- package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +0 -248
- package/src/snapshot/ScreenCaptureManager.ts +0 -156
package/src/core/client.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
this.errorTracker
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
+
}
|