@repliqo/sdk-react-native 0.2.2 → 0.3.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.
@@ -132,4 +132,31 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
132
132
  }
133
133
  });
134
134
  }
135
+
136
+ /**
137
+ * Perform a fast programmatic scroll-scan of the main ScrollView.
138
+ * Captures the entire content as viewport tiles in ~300-500ms.
139
+ * Invisible to the user (scroll is restored to original position).
140
+ *
141
+ * Returns: Array<{ image, width, height, scrollOffsetY, viewportHeight,
142
+ * contentHeight, format }>
143
+ */
144
+ @ReactMethod
145
+ public void scanFullContent(Promise promise) {
146
+ Activity activity = getCurrentActivity();
147
+ if (activity == null) {
148
+ promise.resolve(null);
149
+ return;
150
+ }
151
+
152
+ mainHandler.post(() -> {
153
+ try {
154
+ WritableArray tiles = ScrollScanCapture.scan(activity);
155
+ promise.resolve(tiles);
156
+ } catch (Exception e) {
157
+ Log.w(TAG, "scanFullContent failed: " + e.getMessage());
158
+ promise.resolve(null);
159
+ }
160
+ });
161
+ }
135
162
  }
@@ -0,0 +1,248 @@
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 com.facebook.react.bridge.Arguments;
13
+ import com.facebook.react.bridge.WritableArray;
14
+ import com.facebook.react.bridge.WritableMap;
15
+
16
+ import java.io.ByteArrayOutputStream;
17
+ import java.lang.reflect.Field;
18
+ import java.util.ArrayList;
19
+
20
+ /**
21
+ * Performs a fast programmatic scroll-scan of the main ScrollView to
22
+ * capture the ENTIRE content as a series of viewport tiles.
23
+ *
24
+ * The scan is invisible to the user:
25
+ * 1. Save current scroll position
26
+ * 2. scrollTo(0, 0) — instant, no animation
27
+ * 3. Capture viewport at Y=0
28
+ * 4. scrollTo(0, viewportHeight) — instant
29
+ * 5. Capture viewport at Y=viewportHeight
30
+ * 6. ... repeat until end of content
31
+ * 7. scrollTo(0, originalY) — restore position
32
+ *
33
+ * Total time: ~300-500ms for a typical screen. The user sees a brief
34
+ * flicker at most (usually nothing because it happens within one frame).
35
+ *
36
+ * Must run on the UI thread.
37
+ */
38
+ public class ScrollScanCapture {
39
+ private static final String TAG = "RepliqoCapture";
40
+ private static final int TARGET_WIDTH = 390;
41
+ private static final int JPEG_QUALITY = 40;
42
+
43
+ /**
44
+ * Scan the ScrollView and return all viewport tiles.
45
+ *
46
+ * @return WritableArray of tile objects, or null on failure
47
+ */
48
+ public static WritableArray scan(Activity activity) {
49
+ if (activity == null || activity.isFinishing()) return null;
50
+
51
+ try {
52
+ View scrollView = findVerticalScrollView(activity);
53
+ if (scrollView == null) {
54
+ Log.d(TAG, "No ScrollView found for scan");
55
+ return null;
56
+ }
57
+
58
+ View contentView = getContentChild(scrollView);
59
+ if (contentView == null) return null;
60
+
61
+ int viewportWidth = scrollView.getWidth();
62
+ int viewportHeight = scrollView.getHeight();
63
+ int contentHeight = contentView.getHeight();
64
+
65
+ if (viewportWidth <= 0 || viewportHeight <= 0 || contentHeight <= 0) {
66
+ return null;
67
+ }
68
+
69
+ // If content fits in viewport, just capture once
70
+ if (contentHeight <= viewportHeight) {
71
+ WritableArray tiles = Arguments.createArray();
72
+ WritableMap tile = captureSingleTile(
73
+ scrollView, 0, viewportWidth, viewportHeight, contentHeight
74
+ );
75
+ if (tile != null) tiles.pushMap(tile);
76
+ return tiles;
77
+ }
78
+
79
+ // Save original scroll position
80
+ int originalScrollY = scrollView.getScrollY();
81
+
82
+ WritableArray tiles = Arguments.createArray();
83
+
84
+ // Scan from top to bottom in viewport-sized steps
85
+ // Use 80% overlap to avoid gaps from scroll imprecision
86
+ int step = (int) (viewportHeight * 0.8);
87
+ int y = 0;
88
+
89
+ while (y < contentHeight) {
90
+ // Scroll to position — instant, no animation
91
+ scrollView.scrollTo(0, y);
92
+
93
+ // Force a layout pass so the views at this position render
94
+ scrollView.invalidate();
95
+ contentView.invalidate();
96
+
97
+ // Capture the viewport at this position
98
+ WritableMap tile = captureSingleTile(
99
+ scrollView, y, viewportWidth, viewportHeight, contentHeight
100
+ );
101
+ if (tile != null) {
102
+ tiles.pushMap(tile);
103
+ }
104
+
105
+ y += step;
106
+
107
+ // Last step: capture the very bottom
108
+ if (y >= contentHeight && y - step < contentHeight - viewportHeight) {
109
+ y = contentHeight - viewportHeight;
110
+ scrollView.scrollTo(0, y);
111
+ scrollView.invalidate();
112
+ contentView.invalidate();
113
+
114
+ WritableMap lastTile = captureSingleTile(
115
+ scrollView, y, viewportWidth, viewportHeight, contentHeight
116
+ );
117
+ if (lastTile != null) {
118
+ tiles.pushMap(lastTile);
119
+ }
120
+ break;
121
+ }
122
+ }
123
+
124
+ // Restore original scroll position — instant
125
+ scrollView.scrollTo(0, originalScrollY);
126
+
127
+ Log.d(TAG, "Scroll scan complete: " + tiles.size() +
128
+ " tiles, content=" + contentHeight + "px");
129
+
130
+ return tiles;
131
+
132
+ } catch (Exception e) {
133
+ Log.w(TAG, "ScrollScanCapture failed: " + e.getMessage());
134
+ return null;
135
+ }
136
+ }
137
+
138
+ private static WritableMap captureSingleTile(
139
+ View scrollView, int scrollY,
140
+ int viewportWidth, int viewportHeight, int contentHeight) {
141
+ try {
142
+ // Scale to target width
143
+ float scale = (float) TARGET_WIDTH / viewportWidth;
144
+ int outWidth = TARGET_WIDTH;
145
+ int outHeight = Math.round(viewportHeight * scale);
146
+
147
+ if (outWidth <= 0 || outHeight <= 0) return null;
148
+
149
+ Bitmap bitmap = Bitmap.createBitmap(outWidth, outHeight,
150
+ Bitmap.Config.ARGB_8888);
151
+ Canvas canvas = new Canvas(bitmap);
152
+ canvas.scale(scale, scale);
153
+
154
+ // Translate canvas to account for scroll position within the view
155
+ canvas.translate(0, -scrollView.getScrollY());
156
+
157
+ // Draw the scrollview's content
158
+ View content = getContentChild(scrollView);
159
+ if (content != null) {
160
+ content.draw(canvas);
161
+ } else {
162
+ scrollView.draw(canvas);
163
+ }
164
+
165
+ // Compress to JPEG
166
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
167
+ bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
168
+ String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
169
+ bitmap.recycle();
170
+
171
+ WritableMap tile = Arguments.createMap();
172
+ tile.putString("image", base64);
173
+ tile.putInt("width", outWidth);
174
+ tile.putInt("height", outHeight);
175
+ tile.putInt("scrollOffsetY", scrollY);
176
+ tile.putInt("viewportHeight", viewportHeight);
177
+ tile.putInt("contentHeight", contentHeight);
178
+ tile.putString("format", "jpeg");
179
+ return tile;
180
+
181
+ } catch (Exception e) {
182
+ Log.w(TAG, "Tile capture at Y=" + scrollY + " failed: " + e.getMessage());
183
+ return null;
184
+ }
185
+ }
186
+
187
+ @SuppressWarnings("unchecked")
188
+ private static View findVerticalScrollView(Activity activity) {
189
+ View decorView = activity.getWindow().getDecorView();
190
+ View found = findInTree(decorView);
191
+ if (found != null) return found;
192
+
193
+ try {
194
+ Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
195
+ Object wmg = wmgClass.getMethod("getInstance").invoke(null);
196
+ Field viewsField = wmgClass.getDeclaredField("mViews");
197
+ viewsField.setAccessible(true);
198
+ ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
199
+ if (views != null) {
200
+ for (View root : views) {
201
+ if (root == decorView) continue;
202
+ found = findInTree(root);
203
+ if (found != null) return found;
204
+ }
205
+ }
206
+ } catch (Exception e) { }
207
+
208
+ return null;
209
+ }
210
+
211
+ private static View findInTree(View view) {
212
+ if (view == null) return null;
213
+ if (isVerticalScroll(view) && view instanceof ViewGroup
214
+ && ((ViewGroup) view).getChildCount() > 0) {
215
+ return view;
216
+ }
217
+ if (view instanceof ViewGroup) {
218
+ ViewGroup group = (ViewGroup) view;
219
+ for (int i = 0; i < group.getChildCount(); i++) {
220
+ View found = findInTree(group.getChildAt(i));
221
+ if (found != null) return found;
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+
227
+ private static boolean isVerticalScroll(View view) {
228
+ if (view instanceof ScrollView) return true;
229
+ Class<?> clazz = view.getClass();
230
+ while (clazz != null && clazz != View.class) {
231
+ String name = clazz.getName();
232
+ if (name.contains("Horizontal")) return false;
233
+ if (name.contains("NestedScrollView") ||
234
+ name.contains("ReactScrollView") ||
235
+ name.endsWith("ScrollView")) return true;
236
+ clazz = clazz.getSuperclass();
237
+ }
238
+ return false;
239
+ }
240
+
241
+ private static View getContentChild(View scrollView) {
242
+ if (scrollView instanceof ViewGroup) {
243
+ ViewGroup group = (ViewGroup) scrollView;
244
+ if (group.getChildCount() > 0) return group.getChildAt(0);
245
+ }
246
+ return null;
247
+ }
248
+ }
@@ -1,26 +1,11 @@
1
1
  import React from 'react';
2
2
  import { ScrollView, type ScrollViewProps } from 'react-native';
3
3
  /**
4
- * Drop-in replacement for React Native's ScrollView that automatically
5
- * reports the scroll offset to Repliqo for accurate heatmaps.
4
+ * Drop-in replacement for React Native's ScrollView that:
6
5
  *
7
- * Without this, touch coordinates on scrollable screens are relative to
8
- * the visible viewport. With this, they're relative to the full content,
9
- * so heatmaps accurately show which content elements users touch.
10
- *
11
- * Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
12
- *
13
- * ```tsx
14
- * import { RepliqoScrollView } from '@repliqo/sdk-react-native';
15
- *
16
- * // Before:
17
- * <ScrollView>...</ScrollView>
18
- *
19
- * // After:
20
- * <RepliqoScrollView>...</RepliqoScrollView>
21
- * ```
22
- *
23
- * All ScrollView props are forwarded. Your existing `onScroll` handler
24
- * still fires normally.
6
+ * 1. Reports scroll offset for accurate heatmap touch coords
7
+ * 2. Captures viewport tiles on scroll stops for collaborative
8
+ * screen building the backend composites these into a
9
+ * full-content image over time from multiple user sessions
25
10
  */
26
11
  export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
@@ -37,42 +37,99 @@ exports.RepliqoScrollView = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
39
  const client_1 = require("../core/client");
40
+ /** Minimum scroll delta (px) to trigger a new tile capture. */
41
+ const MIN_DELTA_PX = 50;
42
+ /** Debounce: wait this long after scroll stops before capturing. */
43
+ const DEBOUNCE_MS = 500;
44
+ /** Throttle: minimum time between tile captures. */
45
+ const THROTTLE_MS = 3000;
40
46
  /**
41
- * Drop-in replacement for React Native's ScrollView that automatically
42
- * reports the scroll offset to Repliqo for accurate heatmaps.
47
+ * Drop-in replacement for React Native's ScrollView that:
43
48
  *
44
- * Without this, touch coordinates on scrollable screens are relative to
45
- * the visible viewport. With this, they're relative to the full content,
46
- * so heatmaps accurately show which content elements users touch.
47
- *
48
- * Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
49
- *
50
- * ```tsx
51
- * import { RepliqoScrollView } from '@repliqo/sdk-react-native';
52
- *
53
- * // Before:
54
- * <ScrollView>...</ScrollView>
55
- *
56
- * // After:
57
- * <RepliqoScrollView>...</RepliqoScrollView>
58
- * ```
59
- *
60
- * All ScrollView props are forwarded. Your existing `onScroll` handler
61
- * still fires normally.
49
+ * 1. Reports scroll offset for accurate heatmap touch coords
50
+ * 2. Captures viewport tiles on scroll stops for collaborative
51
+ * screen building the backend composites these into a
52
+ * full-content image over time from multiple user sessions
62
53
  */
63
- exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, ...props }, ref) => {
54
+ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onLayout, ...props }, ref) => {
55
+ const debounceTimer = (0, react_1.useRef)(null);
56
+ const lastCaptureTime = (0, react_1.useRef)(0);
57
+ const lastCaptureY = (0, react_1.useRef)(-999);
58
+ const initialCaptured = (0, react_1.useRef)(false);
59
+ const scrollState = (0, react_1.useRef)(null);
60
+ // Cleanup debounce timer on unmount
61
+ (0, react_1.useEffect)(() => {
62
+ return () => {
63
+ if (debounceTimer.current)
64
+ clearTimeout(debounceTimer.current);
65
+ };
66
+ }, []);
67
+ /**
68
+ * Core tile capture logic. Reads from refs (stable across renders).
69
+ * Checks throttle + delta before capturing.
70
+ */
71
+ const doCapture = () => {
72
+ const state = scrollState.current;
73
+ if (!state || state.viewportHeight <= 0)
74
+ return;
75
+ const now = Date.now();
76
+ if (now - lastCaptureTime.current < THROTTLE_MS)
77
+ return;
78
+ if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
79
+ initialCaptured.current)
80
+ return;
81
+ lastCaptureTime.current = now;
82
+ lastCaptureY.current = state.offsetY;
83
+ initialCaptured.current = true;
84
+ try {
85
+ client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight);
86
+ }
87
+ catch { }
88
+ };
64
89
  const handleScroll = (0, react_1.useCallback)((event) => {
65
- // Report scroll offset to the SDK
90
+ const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
91
+ // Report scroll offset for touch coords
66
92
  try {
67
- const offsetY = event.nativeEvent.contentOffset.y;
68
- client_1.AppAnalytics.getInstance().setScrollOffset(offsetY);
93
+ client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
69
94
  }
70
- catch {
71
- // SDK not initialized, ignore
95
+ catch { }
96
+ // Update scroll state
97
+ scrollState.current = {
98
+ offsetY: contentOffset.y,
99
+ viewportHeight: layoutMeasurement.height,
100
+ contentHeight: contentSize.height,
101
+ };
102
+ // Debounce: capture after scroll stops
103
+ if (debounceTimer.current)
104
+ clearTimeout(debounceTimer.current);
105
+ debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
106
+ // Capture initial tile (Y≈0) on first scroll event with real dimensions
107
+ if (!initialCaptured.current && contentOffset.y < 10) {
108
+ setTimeout(doCapture, 200);
72
109
  }
73
- // Forward to the original onScroll handler
74
110
  onScroll?.(event);
75
111
  }, [onScroll]);
76
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
112
+ const handleMomentumEnd = (0, react_1.useCallback)((event) => {
113
+ if (debounceTimer.current) {
114
+ clearTimeout(debounceTimer.current);
115
+ debounceTimer.current = null;
116
+ }
117
+ setTimeout(doCapture, 200);
118
+ onMomentumScrollEnd?.(event);
119
+ }, [onMomentumScrollEnd]);
120
+ const handleLayout = (0, react_1.useCallback)((event) => {
121
+ // Capture initial tile after layout (gets real viewportHeight)
122
+ const { height } = event.nativeEvent.layout;
123
+ if (!initialCaptured.current && height > 0) {
124
+ scrollState.current = {
125
+ offsetY: 0,
126
+ viewportHeight: height,
127
+ contentHeight: height, // Will be updated on first scroll
128
+ };
129
+ setTimeout(doCapture, 1000);
130
+ }
131
+ onLayout?.(event);
132
+ }, [onLayout]);
133
+ return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
77
134
  });
78
135
  exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
@@ -7,7 +7,6 @@ export declare class AppAnalytics {
7
7
  private screenVisitQueue;
8
8
  private snapshotQueue;
9
9
  private snapshotCapture;
10
- private screenCaptureManager;
11
10
  private errorTracker;
12
11
  private sessionId;
13
12
  private currentScreen;
@@ -32,6 +31,20 @@ export declare class AppAnalytics {
32
31
  * on scrollable screens.
33
32
  */
34
33
  setScrollOffset(y: number): void;
34
+ /**
35
+ * Capture the current viewport as a tile for collaborative screen
36
+ * building. Called by RepliqoScrollView on scroll stops.
37
+ *
38
+ * The tile includes the scrollOffsetY so the backend knows where
39
+ * in the full content this viewport belongs. Multiple users/sessions
40
+ * contribute tiles until the full content is covered.
41
+ */
42
+ captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number): void;
43
+ /**
44
+ * Programmatically scroll-scan the current ScrollView and upload all
45
+ * tiles to the backend. Invisible to the user (~300-500ms).
46
+ */
47
+ private autoScanScreen;
35
48
  onScreenEnter(screenName: string): void;
36
49
  onScreenExit(screenName: string): void;
37
50
  flush(): Promise<void>;
@@ -3,18 +3,15 @@ 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");
7
6
  const config_1 = require("./config");
8
7
  const logger_1 = require("./logger");
9
8
  const api_client_1 = require("../transport/api.client");
10
9
  const batch_queue_1 = require("../transport/batch-queue");
11
10
  const error_tracker_1 = require("../trackers/error.tracker");
12
- // Native multi-window capture is used automatically via captureScreenshot()
13
- // when the ScreenCaptureModule is linked. No permission or service needed.
11
+ const NativeScreenCapture_1 = require("../snapshot/NativeScreenCapture");
14
12
  class AppAnalytics {
15
13
  constructor(config) {
16
14
  this.snapshotCapture = null;
17
- this.screenCaptureManager = null;
18
15
  this.errorTracker = null;
19
16
  this.sessionId = null;
20
17
  this.currentScreen = null;
@@ -52,12 +49,6 @@ class AppAnalytics {
52
49
  }, undefined, // default captureScreenshot provider
53
50
  (...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
54
51
  }
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
- }
61
52
  if (this.config.enableCrashTracking) {
62
53
  this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
63
54
  // Crash reports are sent immediately (fire-and-forget), not batched
@@ -102,8 +93,6 @@ class AppAnalytics {
102
93
  instance.snapshotCapture.stop();
103
94
  instance.snapshotCapture = null;
104
95
  }
105
- instance.screenCaptureManager?.reset();
106
- instance.screenCaptureManager = null;
107
96
  instance.eventQueue.stopAutoFlush();
108
97
  instance.screenVisitQueue.stopAutoFlush();
109
98
  instance.snapshotQueue.stopAutoFlush();
@@ -234,14 +223,76 @@ class AppAnalytics {
234
223
  setScrollOffset(y) {
235
224
  this.scrollOffsetY = y;
236
225
  }
226
+ /**
227
+ * Capture the current viewport as a tile for collaborative screen
228
+ * building. Called by RepliqoScrollView on scroll stops.
229
+ *
230
+ * The tile includes the scrollOffsetY so the backend knows where
231
+ * in the full content this viewport belongs. Multiple users/sessions
232
+ * contribute tiles until the full content is covered.
233
+ */
234
+ captureScrollTile(scrollOffsetY, viewportHeight, contentHeight) {
235
+ if (!this.sessionId || !this.currentScreen)
236
+ return;
237
+ if (viewportHeight <= 0)
238
+ return; // No real dimensions yet
239
+ const screenName = this.currentScreen;
240
+ const sessionId = this.sessionId;
241
+ // Fire-and-forget: capture + upload in background
242
+ (async () => {
243
+ try {
244
+ const frame = await (0, NativeScreenCapture_1.captureNativeFrame)();
245
+ if (!frame)
246
+ return;
247
+ this.logger.log(`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
248
+ `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
249
+ `${Math.round(frame.image.length / 1024)}KB`);
250
+ await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight);
251
+ }
252
+ catch (err) {
253
+ this.logger.warn('Scroll tile failed:', err);
254
+ }
255
+ })();
256
+ }
257
+ /**
258
+ * Programmatically scroll-scan the current ScrollView and upload all
259
+ * tiles to the backend. Invisible to the user (~300-500ms).
260
+ */
261
+ autoScanScreen(screenName) {
262
+ (async () => {
263
+ try {
264
+ const tiles = await (0, NativeScreenCapture_1.scanFullContent)();
265
+ if (!tiles || tiles.length === 0) {
266
+ this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
267
+ return;
268
+ }
269
+ this.logger.log(`Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
270
+ `(content=${tiles[0].contentHeight}px)`);
271
+ // Upload all tiles in parallel
272
+ const sessionId = this.sessionId;
273
+ await Promise.all(tiles.map((tile) => this.apiClient.uploadScreenTile(sessionId, screenName, tile.image, tile.width, tile.height, tile.scrollOffsetY, tile.viewportHeight, tile.contentHeight).catch(() => { })));
274
+ this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
275
+ }
276
+ catch (err) {
277
+ this.logger.warn('Auto-scan failed:', err);
278
+ }
279
+ })();
280
+ }
237
281
  onScreenEnter(screenName) {
238
282
  this.currentScreen = screenName;
239
283
  this.currentScreenEnteredAt = new Date().toISOString();
240
284
  this.scrollOffsetY = 0;
241
285
  this.errorTracker?.setCurrentScreen(screenName);
242
286
  this.snapshotCapture?.setCurrentScreen(screenName);
243
- // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
244
- this.screenCaptureManager?.onScreenEnter(screenName);
287
+ // Auto-scan: programmatically scroll the ScrollView and capture
288
+ // all viewport tiles in ~300ms. Fire-and-forget, invisible to user.
289
+ if (this.config.enableHeatmapCapture) {
290
+ setTimeout(() => {
291
+ if (this.currentScreen !== screenName || !this.sessionId)
292
+ return;
293
+ this.autoScanScreen(screenName);
294
+ }, 1500); // Wait for screen to fully render
295
+ }
245
296
  this.logger.log('Screen entered:', screenName);
246
297
  }
247
298
  onScreenExit(screenName) {
@@ -41,3 +41,21 @@ export interface FullContentResult extends NativeFrameResult {
41
41
  * @returns FullContentResult with isFullContent flag, or null
42
42
  */
43
43
  export declare function captureFullContent(): Promise<FullContentResult | null>;
44
+ export interface ScanTile {
45
+ image: string;
46
+ width: number;
47
+ height: number;
48
+ scrollOffsetY: number;
49
+ viewportHeight: number;
50
+ contentHeight: number;
51
+ }
52
+ /**
53
+ * Perform a fast programmatic scroll-scan of the main ScrollView.
54
+ * Captures the entire content as viewport tiles in ~300-500ms.
55
+ *
56
+ * The scan is invisible to the user — scroll position is saved and
57
+ * restored. Returns an array of tiles covering the full content.
58
+ *
59
+ * @returns Array of tiles, or null if no ScrollView or not available
60
+ */
61
+ export declare function scanFullContent(): Promise<ScanTile[] | null>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isNativeScreenCaptureAvailable = isNativeScreenCaptureAvailable;
4
4
  exports.captureNativeFrame = captureNativeFrame;
5
5
  exports.captureFullContent = captureFullContent;
6
+ exports.scanFullContent = scanFullContent;
6
7
  const react_native_1 = require("react-native");
7
8
  const NativeModule = react_native_1.Platform.OS === 'android'
8
9
  ? react_native_1.NativeModules.ScreenCaptureModule ?? null
@@ -68,3 +69,34 @@ async function captureFullContent() {
68
69
  return null;
69
70
  }
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
+ }
@@ -30,5 +30,9 @@ export declare class ApiClient {
30
30
  * Upload a full-content screen capture for heatmap backgrounds.
31
31
  * Fire-and-forget: errors are logged but never thrown.
32
32
  */
33
+ /**
34
+ * Upload a scroll tile for collaborative screen building.
35
+ */
36
+ uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number): Promise<void>;
33
37
  uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
34
38
  }
@@ -156,6 +156,34 @@ class ApiClient {
156
156
  * Upload a full-content screen capture for heatmap backgrounds.
157
157
  * Fire-and-forget: errors are logged but never thrown.
158
158
  */
159
+ /**
160
+ * Upload a scroll tile for collaborative screen building.
161
+ */
162
+ async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight) {
163
+ try {
164
+ const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
165
+ method: 'POST',
166
+ headers: this.getHeaders(),
167
+ body: JSON.stringify({
168
+ sessionId,
169
+ screenName,
170
+ image,
171
+ width,
172
+ height,
173
+ scrollOffsetY: Math.round(scrollOffsetY),
174
+ viewportHeight: Math.round(viewportHeight),
175
+ contentHeight: Math.round(contentHeight),
176
+ }),
177
+ });
178
+ if (!response.ok) {
179
+ const text = await response.text();
180
+ this.logger.warn('Screen tile upload failed:', response.status, text?.substring(0, 200));
181
+ }
182
+ }
183
+ catch (error) {
184
+ this.logger.warn('Error uploading screen tile:', error);
185
+ }
186
+ }
159
187
  async uploadScreenCapture(screenName, image, width, height, isFullContent) {
160
188
  try {
161
189
  const response = await fetch(`${this.baseUrl}/api/screen-captures`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",
@@ -1,58 +1,140 @@
1
- import React, { useCallback } from 'react';
1
+ import React, { useCallback, useRef, useEffect } from 'react';
2
2
  import {
3
3
  ScrollView,
4
4
  type ScrollViewProps,
5
5
  type NativeSyntheticEvent,
6
6
  type NativeScrollEvent,
7
+ type LayoutChangeEvent,
7
8
  } from 'react-native';
8
9
  import { AppAnalytics } from '../core/client';
9
10
 
11
+ /** Minimum scroll delta (px) to trigger a new tile capture. */
12
+ const MIN_DELTA_PX = 50;
13
+ /** Debounce: wait this long after scroll stops before capturing. */
14
+ const DEBOUNCE_MS = 500;
15
+ /** Throttle: minimum time between tile captures. */
16
+ const THROTTLE_MS = 3000;
17
+
10
18
  /**
11
- * Drop-in replacement for React Native's ScrollView that automatically
12
- * reports the scroll offset to Repliqo for accurate heatmaps.
13
- *
14
- * Without this, touch coordinates on scrollable screens are relative to
15
- * the visible viewport. With this, they're relative to the full content,
16
- * so heatmaps accurately show which content elements users touch.
17
- *
18
- * Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
19
- *
20
- * ```tsx
21
- * import { RepliqoScrollView } from '@repliqo/sdk-react-native';
19
+ * Drop-in replacement for React Native's ScrollView that:
22
20
  *
23
- * // Before:
24
- * <ScrollView>...</ScrollView>
25
- *
26
- * // After:
27
- * <RepliqoScrollView>...</RepliqoScrollView>
28
- * ```
29
- *
30
- * All ScrollView props are forwarded. Your existing `onScroll` handler
31
- * still fires normally.
21
+ * 1. Reports scroll offset for accurate heatmap touch coords
22
+ * 2. Captures viewport tiles on scroll stops for collaborative
23
+ * screen building — the backend composites these into a
24
+ * full-content image over time from multiple user sessions
32
25
  */
33
26
  export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
34
- ({ onScroll, ...props }, ref) => {
27
+ ({ onScroll, onMomentumScrollEnd, onLayout, ...props }, ref) => {
28
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const lastCaptureTime = useRef(0);
30
+ const lastCaptureY = useRef(-999);
31
+ const initialCaptured = useRef(false);
32
+ const scrollState = useRef<{
33
+ offsetY: number;
34
+ viewportHeight: number;
35
+ contentHeight: number;
36
+ } | null>(null);
37
+
38
+ // Cleanup debounce timer on unmount
39
+ useEffect(() => {
40
+ return () => {
41
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
42
+ };
43
+ }, []);
44
+
45
+ /**
46
+ * Core tile capture logic. Reads from refs (stable across renders).
47
+ * Checks throttle + delta before capturing.
48
+ */
49
+ const doCapture = () => {
50
+ const state = scrollState.current;
51
+ if (!state || state.viewportHeight <= 0) return;
52
+
53
+ const now = Date.now();
54
+ if (now - lastCaptureTime.current < THROTTLE_MS) return;
55
+ if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
56
+ initialCaptured.current) return;
57
+
58
+ lastCaptureTime.current = now;
59
+ lastCaptureY.current = state.offsetY;
60
+ initialCaptured.current = true;
61
+
62
+ try {
63
+ AppAnalytics.getInstance().captureScrollTile(
64
+ state.offsetY,
65
+ state.viewportHeight,
66
+ state.contentHeight,
67
+ );
68
+ } catch {}
69
+ };
70
+
35
71
  const handleScroll = useCallback(
36
72
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
37
- // Report scroll offset to the SDK
73
+ const { contentOffset, layoutMeasurement, contentSize } =
74
+ event.nativeEvent;
75
+
76
+ // Report scroll offset for touch coords
38
77
  try {
39
- const offsetY = event.nativeEvent.contentOffset.y;
40
- AppAnalytics.getInstance().setScrollOffset(offsetY);
41
- } catch {
42
- // SDK not initialized, ignore
78
+ AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
79
+ } catch {}
80
+
81
+ // Update scroll state
82
+ scrollState.current = {
83
+ offsetY: contentOffset.y,
84
+ viewportHeight: layoutMeasurement.height,
85
+ contentHeight: contentSize.height,
86
+ };
87
+
88
+ // Debounce: capture after scroll stops
89
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
90
+ debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
91
+
92
+ // Capture initial tile (Y≈0) on first scroll event with real dimensions
93
+ if (!initialCaptured.current && contentOffset.y < 10) {
94
+ setTimeout(doCapture, 200);
43
95
  }
44
96
 
45
- // Forward to the original onScroll handler
46
97
  onScroll?.(event);
47
98
  },
48
99
  [onScroll],
49
100
  );
50
101
 
102
+ const handleMomentumEnd = useCallback(
103
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
104
+ if (debounceTimer.current) {
105
+ clearTimeout(debounceTimer.current);
106
+ debounceTimer.current = null;
107
+ }
108
+ setTimeout(doCapture, 200);
109
+ onMomentumScrollEnd?.(event);
110
+ },
111
+ [onMomentumScrollEnd],
112
+ );
113
+
114
+ const handleLayout = useCallback(
115
+ (event: LayoutChangeEvent) => {
116
+ // Capture initial tile after layout (gets real viewportHeight)
117
+ const { height } = event.nativeEvent.layout;
118
+ if (!initialCaptured.current && height > 0) {
119
+ scrollState.current = {
120
+ offsetY: 0,
121
+ viewportHeight: height,
122
+ contentHeight: height, // Will be updated on first scroll
123
+ };
124
+ setTimeout(doCapture, 1000);
125
+ }
126
+ onLayout?.(event);
127
+ },
128
+ [onLayout],
129
+ );
130
+
51
131
  return (
52
132
  <ScrollView
53
133
  ref={ref}
54
134
  {...props}
55
135
  onScroll={handleScroll}
136
+ onMomentumScrollEnd={handleMomentumEnd}
137
+ onLayout={handleLayout}
56
138
  scrollEventThrottle={props.scrollEventThrottle ?? 16}
57
139
  />
58
140
  );
@@ -7,14 +7,12 @@ 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';
11
10
  import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
12
11
  import { Logger } from './logger';
13
12
  import { ApiClient } from '../transport/api.client';
14
13
  import { BatchQueue } from '../transport/batch-queue';
15
14
  import { ErrorTracker } from '../trackers/error.tracker';
16
- // Native multi-window capture is used automatically via captureScreenshot()
17
- // when the ScreenCaptureModule is linked. No permission or service needed.
15
+ import { captureNativeFrame, scanFullContent } from '../snapshot/NativeScreenCapture';
18
16
 
19
17
  export class AppAnalytics {
20
18
  private static instance: AppAnalytics | null = null;
@@ -25,7 +23,6 @@ export class AppAnalytics {
25
23
  private screenVisitQueue: BatchQueue<ScreenVisit>;
26
24
  private snapshotQueue: BatchQueue<SnapshotPayload>;
27
25
  private snapshotCapture: SnapshotCapture | null = null;
28
- private screenCaptureManager: ScreenCaptureManager | null = null;
29
26
  private errorTracker: ErrorTracker | null = null;
30
27
  private sessionId: string | null = null;
31
28
  private currentScreen: string | null = null;
@@ -96,19 +93,6 @@ export class AppAnalytics {
96
93
  );
97
94
  }
98
95
 
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
-
112
96
  if (this.config.enableCrashTracking) {
113
97
  this.errorTracker = new ErrorTracker((crash) => {
114
98
  // Crash reports are sent immediately (fire-and-forget), not batched
@@ -166,8 +150,6 @@ export class AppAnalytics {
166
150
  instance.snapshotCapture = null;
167
151
  }
168
152
 
169
- instance.screenCaptureManager?.reset();
170
- instance.screenCaptureManager = null;
171
153
 
172
154
  instance.eventQueue.stopAutoFlush();
173
155
  instance.screenVisitQueue.stopAutoFlush();
@@ -339,14 +321,110 @@ export class AppAnalytics {
339
321
  this.scrollOffsetY = y;
340
322
  }
341
323
 
324
+ /**
325
+ * Capture the current viewport as a tile for collaborative screen
326
+ * building. Called by RepliqoScrollView on scroll stops.
327
+ *
328
+ * The tile includes the scrollOffsetY so the backend knows where
329
+ * in the full content this viewport belongs. Multiple users/sessions
330
+ * contribute tiles until the full content is covered.
331
+ */
332
+ captureScrollTile(
333
+ scrollOffsetY: number,
334
+ viewportHeight: number,
335
+ contentHeight: number,
336
+ ): void {
337
+ if (!this.sessionId || !this.currentScreen) return;
338
+ if (viewportHeight <= 0) return; // No real dimensions yet
339
+
340
+ const screenName = this.currentScreen;
341
+ const sessionId = this.sessionId;
342
+
343
+ // Fire-and-forget: capture + upload in background
344
+ (async () => {
345
+ try {
346
+ const frame = await captureNativeFrame();
347
+ if (!frame) return;
348
+
349
+ this.logger.log(
350
+ `Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
351
+ `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
352
+ `${Math.round(frame.image.length / 1024)}KB`,
353
+ );
354
+
355
+ await this.apiClient.uploadScreenTile(
356
+ sessionId,
357
+ screenName,
358
+ frame.image,
359
+ frame.width,
360
+ frame.height,
361
+ scrollOffsetY,
362
+ viewportHeight,
363
+ contentHeight,
364
+ );
365
+ } catch (err) {
366
+ this.logger.warn('Scroll tile failed:', err);
367
+ }
368
+ })();
369
+ }
370
+
371
+ /**
372
+ * Programmatically scroll-scan the current ScrollView and upload all
373
+ * tiles to the backend. Invisible to the user (~300-500ms).
374
+ */
375
+ private autoScanScreen(screenName: string): void {
376
+ (async () => {
377
+ try {
378
+ const tiles = await scanFullContent();
379
+ if (!tiles || tiles.length === 0) {
380
+ this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
381
+ return;
382
+ }
383
+
384
+ this.logger.log(
385
+ `Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
386
+ `(content=${tiles[0].contentHeight}px)`,
387
+ );
388
+
389
+ // Upload all tiles in parallel
390
+ const sessionId = this.sessionId!;
391
+ await Promise.all(
392
+ tiles.map((tile) =>
393
+ this.apiClient.uploadScreenTile(
394
+ sessionId,
395
+ screenName,
396
+ tile.image,
397
+ tile.width,
398
+ tile.height,
399
+ tile.scrollOffsetY,
400
+ tile.viewportHeight,
401
+ tile.contentHeight,
402
+ ).catch(() => {}),
403
+ ),
404
+ );
405
+
406
+ this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
407
+ } catch (err) {
408
+ this.logger.warn('Auto-scan failed:', err);
409
+ }
410
+ })();
411
+ }
412
+
342
413
  onScreenEnter(screenName: string): void {
343
414
  this.currentScreen = screenName;
344
415
  this.currentScreenEnteredAt = new Date().toISOString();
345
416
  this.scrollOffsetY = 0;
346
417
  this.errorTracker?.setCurrentScreen(screenName);
347
418
  this.snapshotCapture?.setCurrentScreen(screenName);
348
- // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
349
- this.screenCaptureManager?.onScreenEnter(screenName);
419
+ // Auto-scan: programmatically scroll the ScrollView and capture
420
+ // all viewport tiles in ~300ms. Fire-and-forget, invisible to user.
421
+ if (this.config.enableHeatmapCapture) {
422
+ setTimeout(() => {
423
+ if (this.currentScreen !== screenName || !this.sessionId) return;
424
+ this.autoScanScreen(screenName);
425
+ }, 1500); // Wait for screen to fully render
426
+ }
427
+
350
428
  this.logger.log('Screen entered:', screenName);
351
429
  }
352
430
 
@@ -18,9 +18,20 @@ interface NativeFullContentResult extends NativeCaptureResult {
18
18
  isFullContent: boolean;
19
19
  }
20
20
 
21
+ interface NativeTile {
22
+ image: string;
23
+ width: number;
24
+ height: number;
25
+ scrollOffsetY: number;
26
+ viewportHeight: number;
27
+ contentHeight: number;
28
+ format: string;
29
+ }
30
+
21
31
  interface NativeScreenCaptureModule {
22
32
  captureFrame(): Promise<NativeCaptureResult | null>;
23
33
  captureFullContent(): Promise<NativeFullContentResult | null>;
34
+ scanFullContent(): Promise<NativeTile[] | null>;
24
35
  isAvailable(): Promise<boolean>;
25
36
  }
26
37
 
@@ -104,3 +115,43 @@ export async function captureFullContent(): Promise<FullContentResult | null> {
104
115
  return null;
105
116
  }
106
117
  }
118
+
119
+ export interface ScanTile {
120
+ image: string;
121
+ width: number;
122
+ height: number;
123
+ scrollOffsetY: number;
124
+ viewportHeight: number;
125
+ contentHeight: number;
126
+ }
127
+
128
+ /**
129
+ * Perform a fast programmatic scroll-scan of the main ScrollView.
130
+ * Captures the entire content as viewport tiles in ~300-500ms.
131
+ *
132
+ * The scan is invisible to the user — scroll position is saved and
133
+ * restored. Returns an array of tiles covering the full content.
134
+ *
135
+ * @returns Array of tiles, or null if no ScrollView or not available
136
+ */
137
+ export async function scanFullContent(): Promise<ScanTile[] | null> {
138
+ if (!NativeModule) return null;
139
+
140
+ try {
141
+ const tiles = await NativeModule.scanFullContent();
142
+ if (!tiles || !Array.isArray(tiles) || tiles.length === 0) return null;
143
+
144
+ return tiles
145
+ .filter((t) => t && t.image)
146
+ .map((t) => ({
147
+ image: t.image,
148
+ width: t.width,
149
+ height: t.height,
150
+ scrollOffsetY: t.scrollOffsetY,
151
+ viewportHeight: t.viewportHeight,
152
+ contentHeight: t.contentHeight,
153
+ }));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
@@ -227,6 +227,51 @@ export class ApiClient {
227
227
  * Upload a full-content screen capture for heatmap backgrounds.
228
228
  * Fire-and-forget: errors are logged but never thrown.
229
229
  */
230
+ /**
231
+ * Upload a scroll tile for collaborative screen building.
232
+ */
233
+ async uploadScreenTile(
234
+ sessionId: string,
235
+ screenName: string,
236
+ image: string,
237
+ width: number,
238
+ height: number,
239
+ scrollOffsetY: number,
240
+ viewportHeight: number,
241
+ contentHeight: number,
242
+ ): Promise<void> {
243
+ try {
244
+ const response = await fetch(
245
+ `${this.baseUrl}/api/screen-tiles`,
246
+ {
247
+ method: 'POST',
248
+ headers: this.getHeaders(),
249
+ body: JSON.stringify({
250
+ sessionId,
251
+ screenName,
252
+ image,
253
+ width,
254
+ height,
255
+ scrollOffsetY: Math.round(scrollOffsetY),
256
+ viewportHeight: Math.round(viewportHeight),
257
+ contentHeight: Math.round(contentHeight),
258
+ }),
259
+ },
260
+ );
261
+
262
+ if (!response.ok) {
263
+ const text = await response.text();
264
+ this.logger.warn(
265
+ 'Screen tile upload failed:',
266
+ response.status,
267
+ text?.substring(0, 200),
268
+ );
269
+ }
270
+ } catch (error) {
271
+ this.logger.warn('Error uploading screen tile:', error);
272
+ }
273
+ }
274
+
230
275
  async uploadScreenCapture(
231
276
  screenName: string,
232
277
  image: string,