@repliqo/sdk-react-native 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ package com.repliqo.screencapture;
2
+
3
+ import android.app.Activity;
4
+ import android.graphics.Bitmap;
5
+ import android.graphics.Canvas;
6
+ import android.util.Base64;
7
+ import android.util.Log;
8
+ import android.view.View;
9
+ import android.view.ViewGroup;
10
+ import android.widget.ScrollView;
11
+
12
+ import java.io.ByteArrayOutputStream;
13
+ import java.lang.reflect.Field;
14
+ import java.util.ArrayList;
15
+
16
+ /**
17
+ * Captures the FULL content of the primary vertical ScrollView found in
18
+ * the app's view hierarchy — including content scrolled off-screen.
19
+ *
20
+ * Used for heatmap backgrounds where we need the complete screen layout,
21
+ * not just the visible viewport.
22
+ *
23
+ * Supports:
24
+ * - Android ScrollView
25
+ * - AndroidX NestedScrollView
26
+ * - React Native ReactScrollView (old arch)
27
+ * - React Native ReactScrollViewManager (Fabric / new arch)
28
+ *
29
+ * Known limitation: FlatList / SectionList use RecyclerView which only
30
+ * renders visible items. Full-content capture is not possible for those.
31
+ * The method falls back to a viewport capture in that case.
32
+ *
33
+ * No permissions needed. Runs on UI thread.
34
+ */
35
+ public class FullContentCapture {
36
+ private static final String TAG = "RepliqoCapture";
37
+ private static final int TARGET_WIDTH = 500;
38
+ private static final int JPEG_QUALITY = 60;
39
+ private static final int MAX_HEIGHT = 5000;
40
+
41
+ /** Result that includes whether this is a full-content or viewport capture. */
42
+ public static class FullCaptureResult {
43
+ public final String image;
44
+ public final int width;
45
+ public final int height;
46
+ public final boolean isFullContent;
47
+
48
+ FullCaptureResult(String image, int width, int height, boolean isFullContent) {
49
+ this.image = image;
50
+ this.width = width;
51
+ this.height = height;
52
+ this.isFullContent = isFullContent;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Capture the full content of the primary vertical ScrollView.
58
+ * Falls back to viewport capture if no suitable ScrollView is found.
59
+ *
60
+ * @param activity Current activity
61
+ * @return FullCaptureResult with isFullContent flag
62
+ */
63
+ public static FullCaptureResult capture(Activity activity) {
64
+ if (activity == null || activity.isFinishing()) return null;
65
+
66
+ try {
67
+ // Find the primary vertical ScrollView
68
+ View scrollView = findVerticalScrollView(activity);
69
+ if (scrollView == null) {
70
+ Log.d(TAG, "No vertical ScrollView found, falling back to viewport");
71
+ return viewportFallback(activity);
72
+ }
73
+
74
+ // Get the content child
75
+ View contentView = getContentChild(scrollView);
76
+ if (contentView == null) {
77
+ return viewportFallback(activity);
78
+ }
79
+
80
+ int contentWidth = contentView.getWidth();
81
+ int contentHeight = contentView.getHeight();
82
+
83
+ if (contentWidth <= 0 || contentHeight <= 0) {
84
+ return viewportFallback(activity);
85
+ }
86
+
87
+ // If content height is same as viewport, it's not scrollable
88
+ int viewportHeight = scrollView.getHeight();
89
+ if (contentHeight <= viewportHeight) {
90
+ Log.d(TAG, "Content fits in viewport, no scroll needed");
91
+ return viewportFallback(activity);
92
+ }
93
+
94
+ // Cap height to avoid OOM
95
+ int captureHeight = Math.min(contentHeight, MAX_HEIGHT);
96
+
97
+ // Scale to output dimensions
98
+ float scale = (float) TARGET_WIDTH / contentWidth;
99
+ int outWidth = TARGET_WIDTH & ~1;
100
+ int outHeight = Math.round(captureHeight * scale) & ~1;
101
+
102
+ if (outWidth <= 0 || outHeight <= 0) {
103
+ return viewportFallback(activity);
104
+ }
105
+
106
+ // Create bitmap — wrapped in OOM catch
107
+ Bitmap bitmap;
108
+ try {
109
+ bitmap = Bitmap.createBitmap(outWidth, outHeight,
110
+ Bitmap.Config.ARGB_8888);
111
+ } catch (OutOfMemoryError oom) {
112
+ Log.w(TAG, "OOM creating bitmap " + outWidth + "x" + outHeight +
113
+ ", falling back to viewport");
114
+ return viewportFallback(activity);
115
+ }
116
+
117
+ Canvas canvas = new Canvas(bitmap);
118
+ canvas.scale(scale, scale);
119
+
120
+ // Draw the full content
121
+ contentView.draw(canvas);
122
+
123
+ // Compress to JPEG
124
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
125
+ bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
126
+ String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
127
+ bitmap.recycle();
128
+
129
+ Log.d(TAG, "Full content captured: " + outWidth + "x" + outHeight +
130
+ " (content: " + contentWidth + "x" + contentHeight +
131
+ ", viewport: " + contentWidth + "x" + viewportHeight + ")");
132
+
133
+ return new FullCaptureResult(base64, outWidth, outHeight, true);
134
+
135
+ } catch (Exception e) {
136
+ Log.w(TAG, "FullContentCapture failed: " + e.getMessage());
137
+ return viewportFallback(activity);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Fallback: capture just the visible viewport via MultiWindowCapture.
143
+ */
144
+ private static FullCaptureResult viewportFallback(Activity activity) {
145
+ MultiWindowCapture.CaptureResult vp = MultiWindowCapture.capture(activity);
146
+ if (vp == null) return null;
147
+ return new FullCaptureResult(vp.image, vp.width, vp.height, false);
148
+ }
149
+
150
+ /**
151
+ * Find the first VERTICAL ScrollView in the view hierarchy.
152
+ * Searches all root views (including modals).
153
+ *
154
+ * Matches by walking the class hierarchy for known scroll classes:
155
+ * - android.widget.ScrollView
156
+ * - androidx.core.widget.NestedScrollView
157
+ * - com.facebook.react.views.scroll.ReactScrollView (old arch)
158
+ * - Any class with "ScrollView" in the name (catches Fabric variants)
159
+ *
160
+ * Explicitly SKIPS HorizontalScrollView and classes containing
161
+ * "Horizontal" to avoid capturing carousels.
162
+ */
163
+ private static View findVerticalScrollView(Activity activity) {
164
+ View decorView = activity.getWindow().getDecorView();
165
+ View found = findVerticalScrollInTree(decorView);
166
+ if (found != null) return found;
167
+
168
+ // Check other windows (modals)
169
+ try {
170
+ Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
171
+ Object wmg = wmgClass.getMethod("getInstance").invoke(null);
172
+ Field viewsField = wmgClass.getDeclaredField("mViews");
173
+ viewsField.setAccessible(true);
174
+ @SuppressWarnings("unchecked")
175
+ ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
176
+ if (views != null) {
177
+ for (View root : views) {
178
+ if (root == decorView) continue;
179
+ found = findVerticalScrollInTree(root);
180
+ if (found != null) return found;
181
+ }
182
+ }
183
+ } catch (Exception e) {
184
+ // Reflection failed
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * DFS for a vertical scroll view in the tree.
192
+ */
193
+ private static View findVerticalScrollInTree(View view) {
194
+ if (view == null) return null;
195
+
196
+ if (isVerticalScrollView(view)) {
197
+ if (view instanceof ViewGroup && ((ViewGroup) view).getChildCount() > 0) {
198
+ return view;
199
+ }
200
+ }
201
+
202
+ if (view instanceof ViewGroup) {
203
+ ViewGroup group = (ViewGroup) view;
204
+ for (int i = 0; i < group.getChildCount(); i++) {
205
+ View found = findVerticalScrollInTree(group.getChildAt(i));
206
+ if (found != null) return found;
207
+ }
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Check if a view is a vertical scroll view.
215
+ * Walks the class hierarchy to catch subclasses.
216
+ */
217
+ private static boolean isVerticalScrollView(View view) {
218
+ // Direct instanceof for standard Android ScrollView
219
+ if (view instanceof ScrollView) return true;
220
+
221
+ // Walk class hierarchy for name-based matching
222
+ Class<?> clazz = view.getClass();
223
+ while (clazz != null && clazz != View.class) {
224
+ String name = clazz.getName();
225
+
226
+ // Skip horizontal variants
227
+ if (name.contains("Horizontal")) return false;
228
+
229
+ // Match known vertical scroll classes:
230
+ // - androidx.core.widget.NestedScrollView
231
+ // - com.facebook.react.views.scroll.ReactScrollView (old arch)
232
+ // - com.facebook.react.views.scroll.ReactScrollViewManager$... (Fabric)
233
+ // - Any other class ending in "ScrollView" that isn't horizontal
234
+ if (name.contains("NestedScrollView") ||
235
+ name.contains("ReactScrollView") ||
236
+ (name.endsWith("ScrollView") && !name.contains("Horizontal"))) {
237
+ return true;
238
+ }
239
+
240
+ clazz = clazz.getSuperclass();
241
+ }
242
+
243
+ return false;
244
+ }
245
+
246
+ /**
247
+ * Get the content child of a ScrollView.
248
+ */
249
+ private static View getContentChild(View scrollView) {
250
+ if (scrollView instanceof ViewGroup) {
251
+ ViewGroup group = (ViewGroup) scrollView;
252
+ if (group.getChildCount() > 0) {
253
+ return group.getChildAt(0);
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+ }
@@ -91,4 +91,45 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
91
91
  }
92
92
  });
93
93
  }
94
+
95
+ /**
96
+ * Capture the FULL content of the primary ScrollView on screen,
97
+ * including content that is scrolled off-screen.
98
+ *
99
+ * Used for heatmap backgrounds. Falls back to a viewport capture
100
+ * if no ScrollView is found.
101
+ *
102
+ * Returns: { image: base64, width: int, height: int, format: "jpeg" }
103
+ */
104
+ @ReactMethod
105
+ public void captureFullContent(Promise promise) {
106
+ Activity activity = getCurrentActivity();
107
+ if (activity == null) {
108
+ promise.resolve(null);
109
+ return;
110
+ }
111
+
112
+ mainHandler.post(() -> {
113
+ try {
114
+ FullContentCapture.FullCaptureResult result =
115
+ FullContentCapture.capture(activity);
116
+
117
+ if (result == null) {
118
+ promise.resolve(null);
119
+ return;
120
+ }
121
+
122
+ WritableMap map = Arguments.createMap();
123
+ map.putString("image", result.image);
124
+ map.putInt("width", result.width);
125
+ map.putInt("height", result.height);
126
+ map.putString("format", "jpeg");
127
+ map.putBoolean("isFullContent", result.isFullContent);
128
+ promise.resolve(map);
129
+ } catch (Exception e) {
130
+ Log.w(TAG, "captureFullContent failed: " + e.getMessage());
131
+ promise.resolve(null);
132
+ }
133
+ });
134
+ }
94
135
  }
@@ -7,6 +7,7 @@ export declare class AppAnalytics {
7
7
  private screenVisitQueue;
8
8
  private snapshotQueue;
9
9
  private snapshotCapture;
10
+ private screenCaptureManager;
10
11
  private errorTracker;
11
12
  private sessionId;
12
13
  private currentScreen;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AppAnalytics = void 0;
4
4
  const react_native_1 = require("react-native");
5
5
  const capture_1 = require("../snapshot/capture");
6
+ const ScreenCaptureManager_1 = require("../snapshot/ScreenCaptureManager");
6
7
  const config_1 = require("./config");
7
8
  const logger_1 = require("./logger");
8
9
  const api_client_1 = require("../transport/api.client");
@@ -13,6 +14,7 @@ const error_tracker_1 = require("../trackers/error.tracker");
13
14
  class AppAnalytics {
14
15
  constructor(config) {
15
16
  this.snapshotCapture = null;
17
+ this.screenCaptureManager = null;
16
18
  this.errorTracker = null;
17
19
  this.sessionId = null;
18
20
  this.currentScreen = null;
@@ -50,6 +52,12 @@ class AppAnalytics {
50
52
  }, undefined, // default captureScreenshot provider
51
53
  (...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
52
54
  }
55
+ // Screen capture manager for heatmap backgrounds.
56
+ // Captures full ScrollView content on screen enter (24h cache).
57
+ // Independent of session replay snapshots.
58
+ if (this.config.enableHeatmapCapture) {
59
+ this.screenCaptureManager = new ScreenCaptureManager_1.ScreenCaptureManager((screenName, image, width, height, isFullContent) => this.apiClient.uploadScreenCapture(screenName, image, width, height, isFullContent), this.config.debug);
60
+ }
53
61
  if (this.config.enableCrashTracking) {
54
62
  this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
55
63
  // Crash reports are sent immediately (fire-and-forget), not batched
@@ -94,6 +102,8 @@ class AppAnalytics {
94
102
  instance.snapshotCapture.stop();
95
103
  instance.snapshotCapture = null;
96
104
  }
105
+ instance.screenCaptureManager?.reset();
106
+ instance.screenCaptureManager = null;
97
107
  instance.eventQueue.stopAutoFlush();
98
108
  instance.screenVisitQueue.stopAutoFlush();
99
109
  instance.snapshotQueue.stopAutoFlush();
@@ -227,9 +237,11 @@ class AppAnalytics {
227
237
  onScreenEnter(screenName) {
228
238
  this.currentScreen = screenName;
229
239
  this.currentScreenEnteredAt = new Date().toISOString();
230
- this.scrollOffsetY = 0; // Reset scroll on screen change
240
+ this.scrollOffsetY = 0;
231
241
  this.errorTracker?.setCurrentScreen(screenName);
232
242
  this.snapshotCapture?.setCurrentScreen(screenName);
243
+ // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
244
+ this.screenCaptureManager?.onScreenEnter(screenName);
233
245
  this.logger.log('Screen entered:', screenName);
234
246
  }
235
247
  onScreenExit(screenName) {
@@ -19,6 +19,7 @@ exports.DEFAULT_CONFIG = {
19
19
  snapshotFlushInterval: 15000,
20
20
  // ~32 * 30KB ≈ 1 MB cap on in-memory buffer if the backend is down.
21
21
  snapshotMaxBufferSize: 32,
22
+ enableHeatmapCapture: true,
22
23
  enableCrashTracking: true,
23
24
  debug: false,
24
25
  };
package/dist/index.d.ts CHANGED
@@ -5,7 +5,8 @@ export { trackScreenEnter, trackScreenExit, } from './trackers/screen.tracker';
5
5
  export { ErrorTracker } from './trackers/error.tracker';
6
6
  export { SnapshotCapture } from './snapshot/capture';
7
7
  export { captureScreenshot } from './snapshot/captureScreenshot';
8
- export { isNativeScreenCaptureAvailable, captureNativeFrame, } from './snapshot/NativeScreenCapture';
8
+ export { isNativeScreenCaptureAvailable, captureNativeFrame, captureFullContent, } from './snapshot/NativeScreenCapture';
9
+ export type { FullContentResult } from './snapshot/NativeScreenCapture';
9
10
  export type { EventType, AnalyticsEvent, TouchEventData, NavigationEventData, ScreenVisit, DeviceInfo, SDKConfig, } from './types/events';
10
11
  export type { ScreenshotData, SnapshotPayload, } from './types/snapshot';
11
12
  export type { CrashReport } from './types/crash';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_CONFIG = exports.RepliqoScrollView = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
3
+ exports.DEFAULT_CONFIG = exports.RepliqoScrollView = exports.captureFullContent = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
4
4
  // Main class
5
5
  var client_1 = require("./core/client");
6
6
  Object.defineProperty(exports, "AppAnalytics", { enumerable: true, get: function () { return client_1.AppAnalytics; } });
@@ -23,6 +23,7 @@ Object.defineProperty(exports, "captureScreenshot", { enumerable: true, get: fun
23
23
  var NativeScreenCapture_1 = require("./snapshot/NativeScreenCapture");
24
24
  Object.defineProperty(exports, "isNativeScreenCaptureAvailable", { enumerable: true, get: function () { return NativeScreenCapture_1.isNativeScreenCaptureAvailable; } });
25
25
  Object.defineProperty(exports, "captureNativeFrame", { enumerable: true, get: function () { return NativeScreenCapture_1.captureNativeFrame; } });
26
+ Object.defineProperty(exports, "captureFullContent", { enumerable: true, get: function () { return NativeScreenCapture_1.captureFullContent; } });
26
27
  // Components
27
28
  var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
28
29
  Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
@@ -15,3 +15,29 @@ export declare function isNativeScreenCaptureAvailable(): boolean;
15
15
  * @returns NativeFrameResult or null if capture failed / not available
16
16
  */
17
17
  export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;
18
+ /**
19
+ * Capture the FULL content of the primary ScrollView on screen,
20
+ * including content scrolled off-screen. Used for heatmap backgrounds.
21
+ *
22
+ * Falls back to a viewport capture if no ScrollView is found.
23
+ * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
24
+ *
25
+ * @returns NativeFrameResult or null if capture failed / not available
26
+ */
27
+ export interface FullContentResult extends NativeFrameResult {
28
+ /** True if the capture includes full scrollable content. False if viewport-only fallback. */
29
+ isFullContent: boolean;
30
+ }
31
+ /**
32
+ * Capture the FULL content of the primary ScrollView on screen,
33
+ * including content scrolled off-screen. Used for heatmap backgrounds.
34
+ *
35
+ * Falls back to a viewport capture if no ScrollView is found.
36
+ * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
37
+ *
38
+ * Known limitation: FlatList/SectionList use RecyclerView which only
39
+ * renders visible items — full-content capture falls back to viewport.
40
+ *
41
+ * @returns FullContentResult with isFullContent flag, or null
42
+ */
43
+ export declare function captureFullContent(): Promise<FullContentResult | null>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isNativeScreenCaptureAvailable = isNativeScreenCaptureAvailable;
4
4
  exports.captureNativeFrame = captureNativeFrame;
5
+ exports.captureFullContent = captureFullContent;
5
6
  const react_native_1 = require("react-native");
6
7
  const NativeModule = react_native_1.Platform.OS === 'android'
7
8
  ? react_native_1.NativeModules.ScreenCaptureModule ?? null
@@ -36,3 +37,34 @@ async function captureNativeFrame() {
36
37
  return null;
37
38
  }
38
39
  }
40
+ /**
41
+ * Capture the FULL content of the primary ScrollView on screen,
42
+ * including content scrolled off-screen. Used for heatmap backgrounds.
43
+ *
44
+ * Falls back to a viewport capture if no ScrollView is found.
45
+ * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
46
+ *
47
+ * Known limitation: FlatList/SectionList use RecyclerView which only
48
+ * renders visible items — full-content capture falls back to viewport.
49
+ *
50
+ * @returns FullContentResult with isFullContent flag, or null
51
+ */
52
+ async function captureFullContent() {
53
+ if (!NativeModule)
54
+ return null;
55
+ try {
56
+ const result = await NativeModule.captureFullContent();
57
+ if (!result || !result.image)
58
+ return null;
59
+ return {
60
+ image: result.image,
61
+ width: result.width,
62
+ height: result.height,
63
+ format: 'jpeg',
64
+ isFullContent: !!result.isFullContent,
65
+ };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
@@ -0,0 +1,30 @@
1
+ export type UploadFn = (screenName: string, image: string, width: number, height: number, isFullContent: boolean) => Promise<void>;
2
+ /**
3
+ * Manages full-content screen captures for heatmap backgrounds.
4
+ *
5
+ * - Captures the full ScrollView content on screen enter
6
+ * - Local cache with 24h TTL to avoid redundant captures
7
+ * - Cancels pending captures when navigating away (prevents cross-screen bugs)
8
+ * - Async and non-blocking: never delays navigation or crashes the app
9
+ */
10
+ export declare class ScreenCaptureManager {
11
+ private cache;
12
+ private pending;
13
+ private pendingTimer;
14
+ private currentScreen;
15
+ private uploadFn;
16
+ private debug;
17
+ constructor(uploadFn: UploadFn, debug?: boolean);
18
+ /**
19
+ * Called when the user enters a screen. Schedules a full-content
20
+ * capture after a delay if no recent capture exists.
21
+ *
22
+ * Cancels any pending capture from a previous screen to prevent
23
+ * cross-screen data corruption (capturing screen B but labeling as A).
24
+ */
25
+ onScreenEnter(screenName: string): void;
26
+ private shouldCapture;
27
+ private captureAndUpload;
28
+ private cancelPending;
29
+ reset(): void;
30
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ScreenCaptureManager = void 0;
4
+ const NativeScreenCapture_1 = require("./NativeScreenCapture");
5
+ /** 24 hours in milliseconds. */
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ /** Wait for screen to render before capturing. */
8
+ const RENDER_DELAY_MS = 800;
9
+ /** Max base64 size to upload (3MB base64 ≈ ~2.2MB binary). */
10
+ const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
11
+ /**
12
+ * Manages full-content screen captures for heatmap backgrounds.
13
+ *
14
+ * - Captures the full ScrollView content on screen enter
15
+ * - Local cache with 24h TTL to avoid redundant captures
16
+ * - Cancels pending captures when navigating away (prevents cross-screen bugs)
17
+ * - Async and non-blocking: never delays navigation or crashes the app
18
+ */
19
+ class ScreenCaptureManager {
20
+ constructor(uploadFn, debug = false) {
21
+ this.cache = new Map();
22
+ this.pending = new Set();
23
+ this.pendingTimer = null;
24
+ this.currentScreen = null;
25
+ this.uploadFn = uploadFn;
26
+ this.debug = debug;
27
+ }
28
+ /**
29
+ * Called when the user enters a screen. Schedules a full-content
30
+ * capture after a delay if no recent capture exists.
31
+ *
32
+ * Cancels any pending capture from a previous screen to prevent
33
+ * cross-screen data corruption (capturing screen B but labeling as A).
34
+ */
35
+ onScreenEnter(screenName) {
36
+ // Cancel any pending capture from the previous screen
37
+ this.cancelPending();
38
+ this.currentScreen = screenName;
39
+ if (!this.shouldCapture(screenName))
40
+ return;
41
+ this.pending.add(screenName);
42
+ this.pendingTimer = setTimeout(() => {
43
+ this.pendingTimer = null;
44
+ // Verify the user is STILL on this screen before capturing
45
+ if (this.currentScreen !== screenName) {
46
+ this.pending.delete(screenName);
47
+ if (this.debug) {
48
+ console.log(`[Repliqo] Screen capture skipped: navigated away from "${screenName}"`);
49
+ }
50
+ return;
51
+ }
52
+ this.captureAndUpload(screenName).catch(() => { });
53
+ }, RENDER_DELAY_MS);
54
+ }
55
+ shouldCapture(screenName) {
56
+ if (this.pending.has(screenName))
57
+ return false;
58
+ const lastCapture = this.cache.get(screenName);
59
+ if (lastCapture && Date.now() - lastCapture < CACHE_TTL_MS) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+ async captureAndUpload(screenName) {
65
+ try {
66
+ const result = await (0, NativeScreenCapture_1.captureFullContent)();
67
+ if (!result) {
68
+ if (this.debug) {
69
+ console.log(`[Repliqo] Screen capture returned null for "${screenName}"`);
70
+ }
71
+ return;
72
+ }
73
+ // Guard: check we're still on the same screen after the async capture
74
+ if (this.currentScreen !== screenName) {
75
+ if (this.debug) {
76
+ console.log(`[Repliqo] Screen capture discarded: user left "${screenName}" during capture`);
77
+ }
78
+ return;
79
+ }
80
+ // Guard: reject oversized images to prevent OOM during upload
81
+ if (result.image.length > MAX_IMAGE_SIZE) {
82
+ if (this.debug) {
83
+ console.warn(`[Repliqo] Screen capture too large (${Math.round(result.image.length / 1024)}KB), skipping "${screenName}"`);
84
+ }
85
+ return;
86
+ }
87
+ if (this.debug) {
88
+ console.log(`[Repliqo] Screen captured: "${screenName}" ${result.width}x${result.height} ` +
89
+ `(full=${result.isFullContent}, ${Math.round(result.image.length / 1024)}KB)`);
90
+ }
91
+ await this.uploadFn(screenName, result.image, result.width, result.height, result.isFullContent);
92
+ this.cache.set(screenName, Date.now());
93
+ if (this.debug) {
94
+ console.log(`[Repliqo] Screen capture uploaded: "${screenName}"`);
95
+ }
96
+ }
97
+ catch (err) {
98
+ if (this.debug) {
99
+ console.warn(`[Repliqo] Screen capture failed for "${screenName}":`, err);
100
+ }
101
+ }
102
+ finally {
103
+ this.pending.delete(screenName);
104
+ }
105
+ }
106
+ cancelPending() {
107
+ if (this.pendingTimer !== null) {
108
+ clearTimeout(this.pendingTimer);
109
+ this.pendingTimer = null;
110
+ }
111
+ }
112
+ reset() {
113
+ this.cancelPending();
114
+ this.cache.clear();
115
+ this.pending.clear();
116
+ this.currentScreen = null;
117
+ }
118
+ }
119
+ exports.ScreenCaptureManager = ScreenCaptureManager;
@@ -26,4 +26,9 @@ export declare class ApiClient {
26
26
  sendCrashesBatch(crashes: CrashReport[]): Promise<{
27
27
  count: number;
28
28
  }>;
29
+ /**
30
+ * Upload a full-content screen capture for heatmap backgrounds.
31
+ * Fire-and-forget: errors are logged but never thrown.
32
+ */
33
+ uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
29
34
  }
@@ -152,5 +152,31 @@ class ApiClient {
152
152
  throw error;
153
153
  }
154
154
  }
155
+ /**
156
+ * Upload a full-content screen capture for heatmap backgrounds.
157
+ * Fire-and-forget: errors are logged but never thrown.
158
+ */
159
+ async uploadScreenCapture(screenName, image, width, height, isFullContent) {
160
+ try {
161
+ const response = await fetch(`${this.baseUrl}/api/screen-captures`, {
162
+ method: 'POST',
163
+ headers: this.getHeaders(),
164
+ body: JSON.stringify({
165
+ screenName,
166
+ image,
167
+ width,
168
+ height,
169
+ isFullContent,
170
+ }),
171
+ });
172
+ if (!response.ok) {
173
+ const text = await response.text();
174
+ this.logger.warn('Screen capture upload failed:', response.status, text?.substring(0, 200));
175
+ }
176
+ }
177
+ catch (error) {
178
+ this.logger.warn('Error uploading screen capture:', error);
179
+ }
180
+ }
155
181
  }
156
182
  exports.ApiClient = ApiClient;
@@ -47,6 +47,8 @@ export interface SDKConfig {
47
47
  snapshotFlushInterval?: number;
48
48
  /** Max snapshots kept in memory if the backend is unreachable. Defaults to 32. */
49
49
  snapshotMaxBufferSize?: number;
50
+ /** Enable full-content screen captures for heatmap backgrounds. Defaults to true when enableSnapshots is true. */
51
+ enableHeatmapCapture?: boolean;
50
52
  enableCrashTracking?: boolean;
51
53
  debug?: boolean;
52
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -7,6 +7,7 @@ import {
7
7
  } from '../types/events';
8
8
  import { SnapshotPayload } from '../types/snapshot';
9
9
  import { SnapshotCapture } from '../snapshot/capture';
10
+ import { ScreenCaptureManager } from '../snapshot/ScreenCaptureManager';
10
11
  import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
11
12
  import { Logger } from './logger';
12
13
  import { ApiClient } from '../transport/api.client';
@@ -24,6 +25,7 @@ export class AppAnalytics {
24
25
  private screenVisitQueue: BatchQueue<ScreenVisit>;
25
26
  private snapshotQueue: BatchQueue<SnapshotPayload>;
26
27
  private snapshotCapture: SnapshotCapture | null = null;
28
+ private screenCaptureManager: ScreenCaptureManager | null = null;
27
29
  private errorTracker: ErrorTracker | null = null;
28
30
  private sessionId: string | null = null;
29
31
  private currentScreen: string | null = null;
@@ -94,6 +96,19 @@ export class AppAnalytics {
94
96
  );
95
97
  }
96
98
 
99
+ // Screen capture manager for heatmap backgrounds.
100
+ // Captures full ScrollView content on screen enter (24h cache).
101
+ // Independent of session replay snapshots.
102
+ if (this.config.enableHeatmapCapture) {
103
+ this.screenCaptureManager = new ScreenCaptureManager(
104
+ (screenName, image, width, height, isFullContent) =>
105
+ this.apiClient.uploadScreenCapture(
106
+ screenName, image, width, height, isFullContent,
107
+ ),
108
+ this.config.debug,
109
+ );
110
+ }
111
+
97
112
  if (this.config.enableCrashTracking) {
98
113
  this.errorTracker = new ErrorTracker((crash) => {
99
114
  // Crash reports are sent immediately (fire-and-forget), not batched
@@ -151,6 +166,8 @@ export class AppAnalytics {
151
166
  instance.snapshotCapture = null;
152
167
  }
153
168
 
169
+ instance.screenCaptureManager?.reset();
170
+ instance.screenCaptureManager = null;
154
171
 
155
172
  instance.eventQueue.stopAutoFlush();
156
173
  instance.screenVisitQueue.stopAutoFlush();
@@ -325,9 +342,11 @@ export class AppAnalytics {
325
342
  onScreenEnter(screenName: string): void {
326
343
  this.currentScreen = screenName;
327
344
  this.currentScreenEnteredAt = new Date().toISOString();
328
- this.scrollOffsetY = 0; // Reset scroll on screen change
345
+ this.scrollOffsetY = 0;
329
346
  this.errorTracker?.setCurrentScreen(screenName);
330
347
  this.snapshotCapture?.setCurrentScreen(screenName);
348
+ // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
349
+ this.screenCaptureManager?.onScreenEnter(screenName);
331
350
  this.logger.log('Screen entered:', screenName);
332
351
  }
333
352
 
@@ -18,6 +18,7 @@ export const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId'>> = {
18
18
  snapshotFlushInterval: 15000,
19
19
  // ~32 * 30KB ≈ 1 MB cap on in-memory buffer if the backend is down.
20
20
  snapshotMaxBufferSize: 32,
21
+ enableHeatmapCapture: true,
21
22
  enableCrashTracking: true,
22
23
  debug: false,
23
24
  };
package/src/index.ts CHANGED
@@ -19,7 +19,9 @@ export { captureScreenshot } from './snapshot/captureScreenshot';
19
19
  export {
20
20
  isNativeScreenCaptureAvailable,
21
21
  captureNativeFrame,
22
+ captureFullContent,
22
23
  } from './snapshot/NativeScreenCapture';
24
+ export type { FullContentResult } from './snapshot/NativeScreenCapture';
23
25
 
24
26
  // Types
25
27
  export type {
@@ -7,13 +7,20 @@ export interface NativeFrameResult {
7
7
  format: 'jpeg';
8
8
  }
9
9
 
10
+ interface NativeCaptureResult {
11
+ image: string;
12
+ width: number;
13
+ height: number;
14
+ format: string;
15
+ }
16
+
17
+ interface NativeFullContentResult extends NativeCaptureResult {
18
+ isFullContent: boolean;
19
+ }
20
+
10
21
  interface NativeScreenCaptureModule {
11
- captureFrame(): Promise<{
12
- image: string;
13
- width: number;
14
- height: number;
15
- format: string;
16
- } | null>;
22
+ captureFrame(): Promise<NativeCaptureResult | null>;
23
+ captureFullContent(): Promise<NativeFullContentResult | null>;
17
24
  isAvailable(): Promise<boolean>;
18
25
  }
19
26
 
@@ -52,3 +59,48 @@ export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
52
59
  return null;
53
60
  }
54
61
  }
62
+
63
+ /**
64
+ * Capture the FULL content of the primary ScrollView on screen,
65
+ * including content scrolled off-screen. Used for heatmap backgrounds.
66
+ *
67
+ * Falls back to a viewport capture if no ScrollView is found.
68
+ * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
69
+ *
70
+ * @returns NativeFrameResult or null if capture failed / not available
71
+ */
72
+ export interface FullContentResult extends NativeFrameResult {
73
+ /** True if the capture includes full scrollable content. False if viewport-only fallback. */
74
+ isFullContent: boolean;
75
+ }
76
+
77
+ /**
78
+ * Capture the FULL content of the primary ScrollView on screen,
79
+ * including content scrolled off-screen. Used for heatmap backgrounds.
80
+ *
81
+ * Falls back to a viewport capture if no ScrollView is found.
82
+ * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
83
+ *
84
+ * Known limitation: FlatList/SectionList use RecyclerView which only
85
+ * renders visible items — full-content capture falls back to viewport.
86
+ *
87
+ * @returns FullContentResult with isFullContent flag, or null
88
+ */
89
+ export async function captureFullContent(): Promise<FullContentResult | null> {
90
+ if (!NativeModule) return null;
91
+
92
+ try {
93
+ const result = await NativeModule.captureFullContent();
94
+ if (!result || !result.image) return null;
95
+
96
+ return {
97
+ image: result.image,
98
+ width: result.width,
99
+ height: result.height,
100
+ format: 'jpeg',
101
+ isFullContent: !!result.isFullContent,
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
@@ -0,0 +1,156 @@
1
+ import { captureFullContent } from './NativeScreenCapture';
2
+
3
+ /** 24 hours in milliseconds. */
4
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
5
+
6
+ /** Wait for screen to render before capturing. */
7
+ const RENDER_DELAY_MS = 800;
8
+
9
+ /** Max base64 size to upload (3MB base64 ≈ ~2.2MB binary). */
10
+ const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
11
+
12
+ export type UploadFn = (
13
+ screenName: string,
14
+ image: string,
15
+ width: number,
16
+ height: number,
17
+ isFullContent: boolean,
18
+ ) => Promise<void>;
19
+
20
+ /**
21
+ * Manages full-content screen captures for heatmap backgrounds.
22
+ *
23
+ * - Captures the full ScrollView content on screen enter
24
+ * - Local cache with 24h TTL to avoid redundant captures
25
+ * - Cancels pending captures when navigating away (prevents cross-screen bugs)
26
+ * - Async and non-blocking: never delays navigation or crashes the app
27
+ */
28
+ export class ScreenCaptureManager {
29
+ private cache = new Map<string, number>();
30
+ private pending = new Set<string>();
31
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
32
+ private currentScreen: string | null = null;
33
+ private uploadFn: UploadFn;
34
+ private debug: boolean;
35
+
36
+ constructor(uploadFn: UploadFn, debug = false) {
37
+ this.uploadFn = uploadFn;
38
+ this.debug = debug;
39
+ }
40
+
41
+ /**
42
+ * Called when the user enters a screen. Schedules a full-content
43
+ * capture after a delay if no recent capture exists.
44
+ *
45
+ * Cancels any pending capture from a previous screen to prevent
46
+ * cross-screen data corruption (capturing screen B but labeling as A).
47
+ */
48
+ onScreenEnter(screenName: string): void {
49
+ // Cancel any pending capture from the previous screen
50
+ this.cancelPending();
51
+ this.currentScreen = screenName;
52
+
53
+ if (!this.shouldCapture(screenName)) return;
54
+
55
+ this.pending.add(screenName);
56
+
57
+ this.pendingTimer = setTimeout(() => {
58
+ this.pendingTimer = null;
59
+ // Verify the user is STILL on this screen before capturing
60
+ if (this.currentScreen !== screenName) {
61
+ this.pending.delete(screenName);
62
+ if (this.debug) {
63
+ console.log(
64
+ `[Repliqo] Screen capture skipped: navigated away from "${screenName}"`,
65
+ );
66
+ }
67
+ return;
68
+ }
69
+ this.captureAndUpload(screenName).catch(() => {});
70
+ }, RENDER_DELAY_MS);
71
+ }
72
+
73
+ private shouldCapture(screenName: string): boolean {
74
+ if (this.pending.has(screenName)) return false;
75
+
76
+ const lastCapture = this.cache.get(screenName);
77
+ if (lastCapture && Date.now() - lastCapture < CACHE_TTL_MS) {
78
+ return false;
79
+ }
80
+
81
+ return true;
82
+ }
83
+
84
+ private async captureAndUpload(screenName: string): Promise<void> {
85
+ try {
86
+ const result = await captureFullContent();
87
+ if (!result) {
88
+ if (this.debug) {
89
+ console.log(`[Repliqo] Screen capture returned null for "${screenName}"`);
90
+ }
91
+ return;
92
+ }
93
+
94
+ // Guard: check we're still on the same screen after the async capture
95
+ if (this.currentScreen !== screenName) {
96
+ if (this.debug) {
97
+ console.log(
98
+ `[Repliqo] Screen capture discarded: user left "${screenName}" during capture`,
99
+ );
100
+ }
101
+ return;
102
+ }
103
+
104
+ // Guard: reject oversized images to prevent OOM during upload
105
+ if (result.image.length > MAX_IMAGE_SIZE) {
106
+ if (this.debug) {
107
+ console.warn(
108
+ `[Repliqo] Screen capture too large (${Math.round(result.image.length / 1024)}KB), skipping "${screenName}"`,
109
+ );
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (this.debug) {
115
+ console.log(
116
+ `[Repliqo] Screen captured: "${screenName}" ${result.width}x${result.height} ` +
117
+ `(full=${result.isFullContent}, ${Math.round(result.image.length / 1024)}KB)`,
118
+ );
119
+ }
120
+
121
+ await this.uploadFn(
122
+ screenName,
123
+ result.image,
124
+ result.width,
125
+ result.height,
126
+ result.isFullContent,
127
+ );
128
+
129
+ this.cache.set(screenName, Date.now());
130
+
131
+ if (this.debug) {
132
+ console.log(`[Repliqo] Screen capture uploaded: "${screenName}"`);
133
+ }
134
+ } catch (err) {
135
+ if (this.debug) {
136
+ console.warn(`[Repliqo] Screen capture failed for "${screenName}":`, err);
137
+ }
138
+ } finally {
139
+ this.pending.delete(screenName);
140
+ }
141
+ }
142
+
143
+ private cancelPending(): void {
144
+ if (this.pendingTimer !== null) {
145
+ clearTimeout(this.pendingTimer);
146
+ this.pendingTimer = null;
147
+ }
148
+ }
149
+
150
+ reset(): void {
151
+ this.cancelPending();
152
+ this.cache.clear();
153
+ this.pending.clear();
154
+ this.currentScreen = null;
155
+ }
156
+ }
@@ -222,4 +222,44 @@ export class ApiClient {
222
222
  throw error;
223
223
  }
224
224
  }
225
+
226
+ /**
227
+ * Upload a full-content screen capture for heatmap backgrounds.
228
+ * Fire-and-forget: errors are logged but never thrown.
229
+ */
230
+ async uploadScreenCapture(
231
+ screenName: string,
232
+ image: string,
233
+ width: number,
234
+ height: number,
235
+ isFullContent: boolean,
236
+ ): Promise<void> {
237
+ try {
238
+ const response = await fetch(
239
+ `${this.baseUrl}/api/screen-captures`,
240
+ {
241
+ method: 'POST',
242
+ headers: this.getHeaders(),
243
+ body: JSON.stringify({
244
+ screenName,
245
+ image,
246
+ width,
247
+ height,
248
+ isFullContent,
249
+ }),
250
+ },
251
+ );
252
+
253
+ if (!response.ok) {
254
+ const text = await response.text();
255
+ this.logger.warn(
256
+ 'Screen capture upload failed:',
257
+ response.status,
258
+ text?.substring(0, 200),
259
+ );
260
+ }
261
+ } catch (error) {
262
+ this.logger.warn('Error uploading screen capture:', error);
263
+ }
264
+ }
225
265
  }
@@ -53,6 +53,8 @@ export interface SDKConfig {
53
53
  snapshotFlushInterval?: number;
54
54
  /** Max snapshots kept in memory if the backend is unreachable. Defaults to 32. */
55
55
  snapshotMaxBufferSize?: number;
56
+ /** Enable full-content screen captures for heatmap backgrounds. Defaults to true when enableSnapshots is true. */
57
+ enableHeatmapCapture?: boolean;
56
58
  enableCrashTracking?: boolean;
57
59
  debug?: boolean;
58
60
  }