@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
@@ -2,8 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isNativeScreenCaptureAvailable = isNativeScreenCaptureAvailable;
4
4
  exports.captureNativeFrame = captureNativeFrame;
5
- exports.captureFullContent = captureFullContent;
6
- exports.scanFullContent = scanFullContent;
7
5
  const react_native_1 = require("react-native");
8
6
  const NativeModule = react_native_1.Platform.OS === 'android'
9
7
  ? react_native_1.NativeModules.ScreenCaptureModule ?? null
@@ -18,6 +16,12 @@ function isNativeScreenCaptureAvailable() {
18
16
  *
19
17
  * No permissions required. No dialog. No foreground service.
20
18
  *
19
+ * This is the ONLY native capture path. Full-content capture of
20
+ * off-screen ScrollView content is NOT possible natively (React Native
21
+ * doesn't render off-screen content to the GPU/view tree) — full-content
22
+ * heatmap backgrounds are instead built collaboratively from viewport
23
+ * tiles captured as the user naturally scrolls (see RepliqoScrollView).
24
+ *
21
25
  * @returns NativeFrameResult or null if capture failed / not available
22
26
  */
23
27
  async function captureNativeFrame() {
@@ -38,65 +42,3 @@ async function captureNativeFrame() {
38
42
  return null;
39
43
  }
40
44
  }
41
- /**
42
- * Capture the FULL content of the primary ScrollView on screen,
43
- * including content scrolled off-screen. Used for heatmap backgrounds.
44
- *
45
- * Falls back to a viewport capture if no ScrollView is found.
46
- * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
47
- *
48
- * Known limitation: FlatList/SectionList use RecyclerView which only
49
- * renders visible items — full-content capture falls back to viewport.
50
- *
51
- * @returns FullContentResult with isFullContent flag, or null
52
- */
53
- async function captureFullContent() {
54
- if (!NativeModule)
55
- return null;
56
- try {
57
- const result = await NativeModule.captureFullContent();
58
- if (!result || !result.image)
59
- return null;
60
- return {
61
- image: result.image,
62
- width: result.width,
63
- height: result.height,
64
- format: 'jpeg',
65
- isFullContent: !!result.isFullContent,
66
- };
67
- }
68
- catch {
69
- return null;
70
- }
71
- }
72
- /**
73
- * Perform a fast programmatic scroll-scan of the main ScrollView.
74
- * Captures the entire content as viewport tiles in ~300-500ms.
75
- *
76
- * The scan is invisible to the user — scroll position is saved and
77
- * restored. Returns an array of tiles covering the full content.
78
- *
79
- * @returns Array of tiles, or null if no ScrollView or not available
80
- */
81
- async function scanFullContent() {
82
- if (!NativeModule)
83
- return null;
84
- try {
85
- const tiles = await NativeModule.scanFullContent();
86
- if (!tiles || !Array.isArray(tiles) || tiles.length === 0)
87
- return null;
88
- return tiles
89
- .filter((t) => t && t.image)
90
- .map((t) => ({
91
- image: t.image,
92
- width: t.width,
93
- height: t.height,
94
- scrollOffsetY: t.scrollOffsetY,
95
- viewportHeight: t.viewportHeight,
96
- contentHeight: t.contentHeight,
97
- }));
98
- }
99
- catch {
100
- return null;
101
- }
102
- }
@@ -22,6 +22,13 @@ export declare class SnapshotCapture {
22
22
  private log;
23
23
  private warn;
24
24
  constructor(config: SnapshotCaptureConfig, onSnapshot: (snapshot: SnapshotPayload) => void, provider?: ScreenshotProvider, log?: LogFn, warn?: LogFn);
25
+ /**
26
+ * Generation counter. Incremented on every start()/stop() so that timer
27
+ * chains from a PREVIOUS generation cannot reschedule themselves. Without
28
+ * it, a fast stop()→start() while a tick's capture is still in flight
29
+ * leaves two parallel timer chains alive (double capture rate).
30
+ */
31
+ private epoch;
25
32
  start(): void;
26
33
  stop(): void;
27
34
  captureNow(): Promise<void>;
@@ -14,6 +14,13 @@ class SnapshotCapture {
14
14
  this.currentScreen = 'unknown';
15
15
  this.capturing = false;
16
16
  this.running = false;
17
+ /**
18
+ * Generation counter. Incremented on every start()/stop() so that timer
19
+ * chains from a PREVIOUS generation cannot reschedule themselves. Without
20
+ * it, a fast stop()→start() while a tick's capture is still in flight
21
+ * leaves two parallel timer chains alive (double capture rate).
22
+ */
23
+ this.epoch = 0;
17
24
  this.config = config;
18
25
  this.onSnapshot = onSnapshot;
19
26
  this.provider = provider;
@@ -24,11 +31,13 @@ class SnapshotCapture {
24
31
  if (this.running)
25
32
  return;
26
33
  this.running = true;
34
+ this.epoch++;
27
35
  this.log('Snapshot capture started (interval:', this.config.captureInterval, 'ms)');
28
- this.scheduleNext();
36
+ this.scheduleNext(this.epoch);
29
37
  }
30
38
  stop() {
31
39
  this.running = false;
40
+ this.epoch++;
32
41
  if (this.timeoutId !== null) {
33
42
  clearTimeout(this.timeoutId);
34
43
  this.timeoutId = null;
@@ -38,13 +47,16 @@ class SnapshotCapture {
38
47
  async captureNow() {
39
48
  await this.tick();
40
49
  }
41
- scheduleNext() {
42
- if (!this.running)
50
+ scheduleNext(epoch) {
51
+ if (!this.running || epoch !== this.epoch)
43
52
  return;
44
53
  this.timeoutId = setTimeout(() => {
54
+ // Orphan timer from a previous generation — do nothing.
55
+ if (epoch !== this.epoch)
56
+ return;
45
57
  Promise.resolve(this.tick()).finally(() => {
46
- if (this.running) {
47
- this.scheduleNext();
58
+ if (this.running && epoch === this.epoch) {
59
+ this.scheduleNext(epoch);
48
60
  }
49
61
  });
50
62
  }, this.config.captureInterval);
@@ -5,6 +5,7 @@ export declare class ErrorTracker {
5
5
  private sessionId;
6
6
  private currentScreen;
7
7
  private originalHandler;
8
+ private wrapperHandler;
8
9
  private isActive;
9
10
  constructor(onCrash: CrashCallback);
10
11
  setSessionId(id: string | null): void;
@@ -16,7 +17,31 @@ export declare class ErrorTracker {
16
17
  */
17
18
  reportError(error: Error, metadata?: Record<string, any>): void;
18
19
  private setupGlobalErrorHandler;
20
+ /**
21
+ * Handle an unhandled promise rejection. Guarded on isActive because the
22
+ * underlying trackers can't always be unregistered.
23
+ */
24
+ private handleUnhandledRejection;
25
+ /**
26
+ * Register with the mechanisms React Native ACTUALLY uses to surface
27
+ * unhandled promise rejections:
28
+ *
29
+ * 1. Hermes' native promise rejection tracker (Hermes engine).
30
+ * 2. The `promise` polyfill's rejection-tracking module (JSC and older
31
+ * RN versions).
32
+ *
33
+ * A bare global callback (the old approach) is never invoked by RN — it
34
+ * silently captured nothing.
35
+ *
36
+ * KNOWN LIMITATION: these trackers hold a single handler — registering
37
+ * REPLACES whatever was installed before (RN's dev LogBox warning, or
38
+ * another SDK like Sentry if it initialized first), and there is no
39
+ * unregister API, so after stop() an inert handler remains installed.
40
+ * If you run multiple error-tracking SDKs, initialize Repliqo FIRST so
41
+ * the other SDK's handler wins, or disable enableCrashTracking.
42
+ */
19
43
  private setupUnhandledRejectionHandler;
44
+ private teardownUnhandledRejectionHandler;
20
45
  private restoreGlobalErrorHandler;
21
46
  }
22
47
  export {};
@@ -6,7 +6,32 @@ class ErrorTracker {
6
6
  this.sessionId = null;
7
7
  this.currentScreen = null;
8
8
  this.originalHandler = null;
9
+ this.wrapperHandler = null;
9
10
  this.isActive = false;
11
+ /**
12
+ * Handle an unhandled promise rejection. Guarded on isActive because the
13
+ * underlying trackers can't always be unregistered.
14
+ */
15
+ this.handleUnhandledRejection = (_id, error) => {
16
+ if (!this.isActive)
17
+ return;
18
+ try {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ const stack = error instanceof Error ? error.stack : undefined;
21
+ const crash = {
22
+ sessionId: this.sessionId || undefined,
23
+ type: 'unhandled_rejection',
24
+ message,
25
+ stackTrace: stack || undefined,
26
+ screenName: this.currentScreen || undefined,
27
+ timestamp: new Date().toISOString(),
28
+ };
29
+ this.onCrash(crash);
30
+ }
31
+ catch (_e) {
32
+ // Never crash the app due to error reporting
33
+ }
34
+ };
10
35
  this.onCrash = onCrash;
11
36
  }
12
37
  setSessionId(id) {
@@ -25,8 +50,11 @@ class ErrorTracker {
25
50
  stop() {
26
51
  if (!this.isActive)
27
52
  return;
53
+ // Flip the flag FIRST — the rejection trackers can't always be
54
+ // unregistered (Hermes), so their callbacks guard on isActive.
28
55
  this.isActive = false;
29
56
  this.restoreGlobalErrorHandler();
57
+ this.teardownUnhandledRejectionHandler();
30
58
  }
31
59
  /**
32
60
  * Manual error reporting - allows consumers to report caught errors.
@@ -53,66 +81,115 @@ class ErrorTracker {
53
81
  const ErrorUtils = global.ErrorUtils;
54
82
  if (ErrorUtils) {
55
83
  this.originalHandler = ErrorUtils.getGlobalHandler();
56
- ErrorUtils.setGlobalHandler((error, isFatal) => {
57
- try {
58
- const crash = {
59
- sessionId: this.sessionId || undefined,
60
- type: isFatal ? 'native_crash' : 'js_error',
61
- message: error.message,
62
- stackTrace: error.stack || undefined,
63
- screenName: this.currentScreen || undefined,
64
- metadata: { isFatal },
65
- timestamp: new Date().toISOString(),
66
- };
67
- this.onCrash(crash);
68
- }
69
- catch (_e) {
70
- // Never crash the app due to error reporting
84
+ this.wrapperHandler = (error, isFatal) => {
85
+ if (this.isActive) {
86
+ try {
87
+ const crash = {
88
+ sessionId: this.sessionId || undefined,
89
+ type: isFatal ? 'native_crash' : 'js_error',
90
+ message: error.message,
91
+ stackTrace: error.stack || undefined,
92
+ screenName: this.currentScreen || undefined,
93
+ metadata: { isFatal },
94
+ timestamp: new Date().toISOString(),
95
+ };
96
+ this.onCrash(crash);
97
+ }
98
+ catch (_e) {
99
+ // Never crash the app due to error reporting
100
+ }
71
101
  }
72
102
  // Always call original handler so RN can handle the error
73
103
  if (this.originalHandler) {
74
104
  this.originalHandler(error, isFatal);
75
105
  }
76
- });
106
+ };
107
+ ErrorUtils.setGlobalHandler(this.wrapperHandler);
77
108
  }
78
109
  }
79
110
  catch (_e) {
80
111
  // Never crash the app due to error handler setup
81
112
  }
82
113
  }
114
+ /**
115
+ * Register with the mechanisms React Native ACTUALLY uses to surface
116
+ * unhandled promise rejections:
117
+ *
118
+ * 1. Hermes' native promise rejection tracker (Hermes engine).
119
+ * 2. The `promise` polyfill's rejection-tracking module (JSC and older
120
+ * RN versions).
121
+ *
122
+ * A bare global callback (the old approach) is never invoked by RN — it
123
+ * silently captured nothing.
124
+ *
125
+ * KNOWN LIMITATION: these trackers hold a single handler — registering
126
+ * REPLACES whatever was installed before (RN's dev LogBox warning, or
127
+ * another SDK like Sentry if it initialized first), and there is no
128
+ * unregister API, so after stop() an inert handler remains installed.
129
+ * If you run multiple error-tracking SDKs, initialize Repliqo FIRST so
130
+ * the other SDK's handler wins, or disable enableCrashTracking.
131
+ */
83
132
  setupUnhandledRejectionHandler() {
133
+ // Kept for manual invocation / test hooks.
84
134
  try {
85
- if (typeof global !== 'undefined') {
86
- global.__analyticsUnhandledRejection = (_id, error) => {
87
- try {
88
- const message = error instanceof Error ? error.message : String(error);
89
- const stack = error instanceof Error ? error.stack : undefined;
90
- const crash = {
91
- sessionId: this.sessionId || undefined,
92
- type: 'unhandled_rejection',
93
- message,
94
- stackTrace: stack || undefined,
95
- screenName: this.currentScreen || undefined,
96
- timestamp: new Date().toISOString(),
97
- };
98
- this.onCrash(crash);
99
- }
100
- catch (_e) {
101
- // Never crash the app due to error reporting
102
- }
103
- };
135
+ global.__analyticsUnhandledRejection =
136
+ this.handleUnhandledRejection;
137
+ }
138
+ catch (_e) {
139
+ /* ignore */
140
+ }
141
+ // Hermes: native promise rejection tracking.
142
+ try {
143
+ const hermes = global.HermesInternal;
144
+ if (hermes?.hasPromise?.() && hermes?.enablePromiseRejectionTracker) {
145
+ hermes.enablePromiseRejectionTracker({
146
+ allRejections: true,
147
+ onUnhandled: this.handleUnhandledRejection,
148
+ onHandled: () => { },
149
+ });
150
+ return;
104
151
  }
105
152
  }
106
153
  catch (_e) {
107
- // Never crash the app due to rejection handler setup
154
+ /* ignore fall through to the polyfill */
155
+ }
156
+ // JSC / promise polyfill path.
157
+ try {
158
+ const rejectionTracking = require('promise/setimmediate/rejection-tracking');
159
+ rejectionTracking.enable({
160
+ allRejections: true,
161
+ onUnhandled: this.handleUnhandledRejection,
162
+ onHandled: () => { },
163
+ });
164
+ }
165
+ catch (_e) {
166
+ // Module unavailable (e.g. jest without the polyfill) — the global
167
+ // hook above still allows manual reporting.
168
+ }
169
+ }
170
+ teardownUnhandledRejectionHandler() {
171
+ try {
172
+ delete global.__analyticsUnhandledRejection;
173
+ }
174
+ catch (_e) {
175
+ /* ignore */
108
176
  }
177
+ // Hermes offers no unregister API and disabling the polyfill tracker
178
+ // could clobber another library's handler — the isActive guard in
179
+ // handleUnhandledRejection makes lingering registrations inert.
109
180
  }
110
181
  restoreGlobalErrorHandler() {
111
182
  try {
112
183
  const ErrorUtils = global.ErrorUtils;
113
184
  if (ErrorUtils && this.originalHandler) {
114
- ErrorUtils.setGlobalHandler(this.originalHandler);
185
+ // Only restore if WE are still the installed handler — another
186
+ // library may have wrapped us after start(), and restoring would
187
+ // clobber its handler.
188
+ if (ErrorUtils.getGlobalHandler() === this.wrapperHandler) {
189
+ ErrorUtils.setGlobalHandler(this.originalHandler);
190
+ }
115
191
  this.originalHandler = null;
192
+ this.wrapperHandler = null;
116
193
  }
117
194
  }
118
195
  catch (_e) {
@@ -57,6 +57,17 @@ function useNavigationTracker() {
57
57
  // AppAnalytics not initialized, silently ignore
58
58
  }
59
59
  }
60
+ else if (currentRouteName && !previousRouteName) {
61
+ // First route seen without onReady having fired (some apps only wire
62
+ // onStateChange). Without this, currentScreen stays null until the
63
+ // SECOND navigation and early touches/visits go unattributed.
64
+ try {
65
+ client_1.AppAnalytics.getInstance().onScreenEnter(currentRouteName);
66
+ }
67
+ catch {
68
+ // AppAnalytics not initialized, silently ignore
69
+ }
70
+ }
60
71
  routeNameRef.current = currentRouteName;
61
72
  }, []);
62
73
  return {
@@ -11,10 +11,39 @@ export declare class ApiClient {
11
11
  private logger;
12
12
  constructor(baseUrl: string, apiKey: string, logger: Logger);
13
13
  private getHeaders;
14
+ /**
15
+ * fetch with a hard timeout. Without it, a single stalled request keeps
16
+ * the BatchQueue's in-flight lock held indefinitely — no flush can run,
17
+ * the buffer fills up and starts evicting events (silent data loss).
18
+ */
19
+ private fetchWithTimeout;
20
+ /**
21
+ * Classify a non-ok batch response. Permanent PAYLOAD errors (the batch
22
+ * itself will never be accepted: 400 malformed, 413 too large, 422
23
+ * unprocessable, 404 gone) surface as NonRetryableFlushError so the
24
+ * queue drops the poison batch instead of retrying it forever.
25
+ *
26
+ * 401/403 are NOT the payload's fault — they mean the API key is wrong
27
+ * or was rotated. Treat them as transient (retry with backoff) so a key
28
+ * rotation doesn't silently destroy every live SDK's buffered data.
29
+ * 408/429 are transient by definition.
30
+ */
31
+ private batchError;
32
+ /**
33
+ * Parse a 2xx response body, tolerating malformed JSON. The server
34
+ * already accepted the data — throwing here would make the queue retry
35
+ * an already-persisted batch and create duplicates.
36
+ */
37
+ private safeJson;
14
38
  startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<{
15
39
  id: string;
16
40
  }>;
17
41
  endSession(sessionId: string): Promise<void>;
42
+ /**
43
+ * Attach the host app's user identity to a session.
44
+ * Fire-and-forget: errors are logged but never thrown.
45
+ */
46
+ identifySession(sessionId: string, userId: string, traits?: Record<string, unknown>): Promise<void>;
18
47
  sendEventsBatch(sessionId: string, events: AnalyticsEvent[]): Promise<{
19
48
  count: number;
20
49
  }>;
@@ -22,7 +51,11 @@ export declare class ApiClient {
22
51
  sendSnapshotsBatch(snapshots: SnapshotPayload[]): Promise<{
23
52
  count: number;
24
53
  }>;
25
- sendCrashReport(crash: CrashReport): Promise<void>;
54
+ /**
55
+ * Send a single crash report. Never throws; returns true when the
56
+ * server accepted it (used to clear the persisted pending copy).
57
+ */
58
+ sendCrashReport(crash: CrashReport): Promise<boolean>;
26
59
  sendCrashesBatch(crashes: CrashReport[]): Promise<{
27
60
  count: number;
28
61
  }>;