@repliqo/sdk-react-native 0.3.2 → 0.3.4

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,279 @@
1
+ package com.repliqo.screencapture;
2
+
3
+ import android.app.Activity;
4
+ import android.graphics.Bitmap;
5
+ import android.graphics.Rect;
6
+ import android.os.Build;
7
+ import android.os.Handler;
8
+ import android.os.HandlerThread;
9
+ import android.util.Base64;
10
+ import android.util.Log;
11
+ import android.view.PixelCopy;
12
+ import android.view.View;
13
+ import android.view.ViewGroup;
14
+ import android.view.Window;
15
+ import android.widget.ScrollView;
16
+
17
+ import com.facebook.react.bridge.Arguments;
18
+ import com.facebook.react.bridge.WritableArray;
19
+ import com.facebook.react.bridge.WritableMap;
20
+
21
+ import java.io.ByteArrayOutputStream;
22
+ import java.lang.reflect.Field;
23
+ import java.util.ArrayList;
24
+ import java.util.concurrent.CountDownLatch;
25
+ import java.util.concurrent.TimeUnit;
26
+
27
+ /**
28
+ * Captures the FULL content of a ScrollView using PixelCopy API +
29
+ * programmatic scrolling.
30
+ *
31
+ * Unlike View.draw() which only renders what the view tree has laid out,
32
+ * PixelCopy captures directly from the GPU's Surface — it sees exactly
33
+ * what's on screen, including content that React Native has rendered
34
+ * but that View.draw() misses.
35
+ *
36
+ * Flow:
37
+ * 1. Find the main ScrollView
38
+ * 2. Save scroll position
39
+ * 3. For each viewport-sized step:
40
+ * a. scrollTo(y) — instant
41
+ * b. Wait 2 frames for RN to render
42
+ * c. PixelCopy the Window surface (captures what's actually visible)
43
+ * d. Crop to the ScrollView's bounds on screen
44
+ * 4. Restore scroll position
45
+ * 5. Return all tiles
46
+ *
47
+ * Requires API 26+ (Android 8.0). Falls back to MultiWindowCapture on older.
48
+ */
49
+ public class PixelCopyScan {
50
+ private static final String TAG = "RepliqoCapture";
51
+ private static final int TARGET_WIDTH = 390;
52
+ private static final int JPEG_QUALITY = 45;
53
+ private static final int FRAME_WAIT_MS = 50; // Wait for RN to render
54
+
55
+ /**
56
+ * Scan the ScrollView content using PixelCopy + programmatic scroll.
57
+ * Must be called on the UI thread. Blocks for ~100ms per tile.
58
+ *
59
+ * @return WritableArray of tiles, or null on failure
60
+ */
61
+ public static WritableArray scan(Activity activity) {
62
+ if (activity == null || activity.isFinishing()) return null;
63
+
64
+ // PixelCopy requires API 26+
65
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
66
+ Log.d(TAG, "PixelCopy not available (API < 26), using fallback");
67
+ return null;
68
+ }
69
+
70
+ try {
71
+ View scrollView = findVerticalScrollView(activity);
72
+ if (scrollView == null) {
73
+ Log.d(TAG, "No ScrollView found for PixelCopy scan");
74
+ return null;
75
+ }
76
+
77
+ View contentView = getContentChild(scrollView);
78
+ if (contentView == null) return null;
79
+
80
+ int viewportHeight = scrollView.getHeight();
81
+ int contentHeight = contentView.getHeight();
82
+ int viewportWidth = scrollView.getWidth();
83
+
84
+ if (viewportWidth <= 0 || viewportHeight <= 0 || contentHeight <= 0) {
85
+ return null;
86
+ }
87
+
88
+ // Get ScrollView position on screen (for cropping)
89
+ int[] scrollViewLoc = new int[2];
90
+ scrollView.getLocationOnScreen(scrollViewLoc);
91
+ Rect scrollViewBounds = new Rect(
92
+ scrollViewLoc[0], scrollViewLoc[1],
93
+ scrollViewLoc[0] + viewportWidth,
94
+ scrollViewLoc[1] + viewportHeight
95
+ );
96
+
97
+ Window window = activity.getWindow();
98
+ int originalScrollY = scrollView.getScrollY();
99
+
100
+ WritableArray tiles = Arguments.createArray();
101
+ int step = (int) (viewportHeight * 0.75); // 25% overlap
102
+
103
+ // Create a handler thread for PixelCopy callbacks
104
+ HandlerThread handlerThread = new HandlerThread("PixelCopyThread");
105
+ handlerThread.start();
106
+ Handler handler = new Handler(handlerThread.getLooper());
107
+
108
+ try {
109
+ int y = 0;
110
+ while (y < contentHeight) {
111
+ int scrollTarget = Math.min(y, contentHeight - viewportHeight);
112
+ if (scrollTarget < 0) scrollTarget = 0;
113
+
114
+ // Scroll to position
115
+ scrollView.scrollTo(0, scrollTarget);
116
+
117
+ // Wait for React Native to render the new content
118
+ Thread.sleep(FRAME_WAIT_MS);
119
+
120
+ // Capture using PixelCopy
121
+ WritableMap tile = captureWithPixelCopy(
122
+ window, scrollViewBounds, handler,
123
+ scrollTarget, viewportWidth, viewportHeight, contentHeight
124
+ );
125
+
126
+ if (tile != null) {
127
+ tiles.pushMap(tile);
128
+ }
129
+
130
+ if (y >= contentHeight - viewportHeight) break;
131
+ y += step;
132
+ if (y > contentHeight - viewportHeight) {
133
+ y = contentHeight - viewportHeight;
134
+ }
135
+ }
136
+ } finally {
137
+ // Restore scroll position
138
+ scrollView.scrollTo(0, originalScrollY);
139
+ handlerThread.quitSafely();
140
+ }
141
+
142
+ Log.d(TAG, "PixelCopy scan: " + tiles.size() + " tiles, " +
143
+ "content=" + contentHeight + "px");
144
+
145
+ return tiles;
146
+
147
+ } catch (Exception e) {
148
+ Log.w(TAG, "PixelCopyScan failed: " + e.getMessage());
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Capture a tile using PixelCopy, cropping to the ScrollView's bounds.
155
+ */
156
+ private static WritableMap captureWithPixelCopy(
157
+ Window window, Rect cropBounds, Handler handler,
158
+ int scrollY, int viewportWidth, int viewportHeight, int contentHeight) {
159
+
160
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null;
161
+
162
+ try {
163
+ // Create bitmap for the crop area
164
+ Bitmap bitmap = Bitmap.createBitmap(
165
+ cropBounds.width(), cropBounds.height(),
166
+ Bitmap.Config.ARGB_8888
167
+ );
168
+
169
+ CountDownLatch latch = new CountDownLatch(1);
170
+ final int[] resultCode = new int[]{-1};
171
+
172
+ PixelCopy.request(window, cropBounds, bitmap, (result) -> {
173
+ resultCode[0] = result;
174
+ latch.countDown();
175
+ }, handler);
176
+
177
+ // Wait for PixelCopy to complete (max 1 second)
178
+ if (!latch.await(1, TimeUnit.SECONDS)) {
179
+ bitmap.recycle();
180
+ return null;
181
+ }
182
+
183
+ if (resultCode[0] != PixelCopy.SUCCESS) {
184
+ bitmap.recycle();
185
+ return null;
186
+ }
187
+
188
+ // Scale to target width
189
+ float scale = (float) TARGET_WIDTH / viewportWidth;
190
+ int outWidth = TARGET_WIDTH;
191
+ int outHeight = Math.round(viewportHeight * scale);
192
+
193
+ Bitmap scaled = Bitmap.createScaledBitmap(bitmap, outWidth, outHeight, true);
194
+ bitmap.recycle();
195
+
196
+ // Compress to JPEG
197
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
198
+ scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
199
+ String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
200
+ scaled.recycle();
201
+
202
+ WritableMap tile = Arguments.createMap();
203
+ tile.putString("image", base64);
204
+ tile.putInt("width", outWidth);
205
+ tile.putInt("height", outHeight);
206
+ tile.putInt("scrollOffsetY", scrollY);
207
+ tile.putInt("viewportHeight", viewportHeight);
208
+ tile.putInt("contentHeight", contentHeight);
209
+ tile.putString("format", "jpeg");
210
+ return tile;
211
+
212
+ } catch (Exception e) {
213
+ Log.w(TAG, "PixelCopy at Y=" + scrollY + " failed: " + e.getMessage());
214
+ return null;
215
+ }
216
+ }
217
+
218
+ // --- View finders (same as ScrollScanCapture) ---
219
+
220
+ @SuppressWarnings("unchecked")
221
+ private static View findVerticalScrollView(Activity activity) {
222
+ View decorView = activity.getWindow().getDecorView();
223
+ View found = findInTree(decorView);
224
+ if (found != null) return found;
225
+ try {
226
+ Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
227
+ Object wmg = wmgClass.getMethod("getInstance").invoke(null);
228
+ Field viewsField = wmgClass.getDeclaredField("mViews");
229
+ viewsField.setAccessible(true);
230
+ ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
231
+ if (views != null) {
232
+ for (View root : views) {
233
+ if (root == decorView) continue;
234
+ found = findInTree(root);
235
+ if (found != null) return found;
236
+ }
237
+ }
238
+ } catch (Exception e) { }
239
+ return null;
240
+ }
241
+
242
+ private static View findInTree(View view) {
243
+ if (view == null) return null;
244
+ if (isVerticalScroll(view) && view instanceof ViewGroup
245
+ && ((ViewGroup) view).getChildCount() > 0) {
246
+ return view;
247
+ }
248
+ if (view instanceof ViewGroup) {
249
+ ViewGroup group = (ViewGroup) view;
250
+ for (int i = 0; i < group.getChildCount(); i++) {
251
+ View found = findInTree(group.getChildAt(i));
252
+ if (found != null) return found;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+
258
+ private static boolean isVerticalScroll(View view) {
259
+ if (view instanceof ScrollView) return true;
260
+ Class<?> clazz = view.getClass();
261
+ while (clazz != null && clazz != View.class) {
262
+ String name = clazz.getName();
263
+ if (name.contains("Horizontal")) return false;
264
+ if (name.contains("NestedScrollView") ||
265
+ name.contains("ReactScrollView") ||
266
+ name.endsWith("ScrollView")) return true;
267
+ clazz = clazz.getSuperclass();
268
+ }
269
+ return false;
270
+ }
271
+
272
+ private static View getContentChild(View scrollView) {
273
+ if (scrollView instanceof ViewGroup) {
274
+ ViewGroup group = (ViewGroup) scrollView;
275
+ if (group.getChildCount() > 0) return group.getChildAt(0);
276
+ }
277
+ return null;
278
+ }
279
+ }
@@ -152,7 +152,14 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
152
152
 
153
153
  mainHandler.post(() -> {
154
154
  try {
155
- WritableArray tiles = ScrollScanCapture.scan(activity);
155
+ // Try PixelCopy (API 26+, captures from GPU framebuffer)
156
+ WritableArray tiles = PixelCopyScan.scan(activity);
157
+
158
+ // Fallback to View.draw scan
159
+ if (tiles == null || tiles.size() == 0) {
160
+ tiles = ScrollScanCapture.scan(activity);
161
+ }
162
+
156
163
  promise.resolve(tiles);
157
164
  } catch (Exception e) {
158
165
  Log.w(TAG, "scanFullContent failed: " + e.getMessage());
@@ -4,8 +4,8 @@ import { ScrollView, type ScrollViewProps } from 'react-native';
4
4
  * Drop-in replacement for React Native's ScrollView that:
5
5
  *
6
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
7
+ * 2. Captures viewport tiles on scroll stops AND during continuous
8
+ * scroll for collaborative screen building — the backend composites
9
+ * these into a full-content image over time
10
10
  */
11
11
  export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
@@ -40,41 +40,54 @@ const client_1 = require("../core/client");
40
40
  /** Minimum scroll delta (px) to trigger a new tile capture. */
41
41
  const MIN_DELTA_PX = 20;
42
42
  /** Debounce: wait this long after scroll stops before capturing. */
43
- const DEBOUNCE_MS = 300;
43
+ const DEBOUNCE_MS = 250;
44
44
  /** Throttle: minimum time between tile captures. */
45
- const THROTTLE_MS = 500;
45
+ const THROTTLE_MS = 400;
46
+ /**
47
+ * During continuous scroll, force a capture every this many ms.
48
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
49
+ * where the user never pauses long enough for the debounce to fire.
50
+ */
51
+ const SCROLL_INTERVAL_MS = 1200;
46
52
  /**
47
53
  * Drop-in replacement for React Native's ScrollView that:
48
54
  *
49
55
  * 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
56
+ * 2. Captures viewport tiles on scroll stops AND during continuous
57
+ * scroll for collaborative screen building — the backend composites
58
+ * these into a full-content image over time
53
59
  */
54
- exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
60
+ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
55
61
  const debounceTimer = (0, react_1.useRef)(null);
62
+ const scrollIntervalTimer = (0, react_1.useRef)(null);
56
63
  const lastCaptureTime = (0, react_1.useRef)(0);
57
64
  const lastCaptureY = (0, react_1.useRef)(-999);
58
65
  const initialCaptured = (0, react_1.useRef)(false);
66
+ const realContentHeight = (0, react_1.useRef)(0);
59
67
  const scrollState = (0, react_1.useRef)(null);
60
- // Cleanup debounce timer on unmount
68
+ // Cleanup timers on unmount
61
69
  (0, react_1.useEffect)(() => {
62
70
  return () => {
63
71
  if (debounceTimer.current)
64
72
  clearTimeout(debounceTimer.current);
73
+ if (scrollIntervalTimer.current)
74
+ clearInterval(scrollIntervalTimer.current);
65
75
  };
66
76
  }, []);
67
77
  /**
68
78
  * Core tile capture logic. Reads from refs (stable across renders).
69
79
  * Checks throttle + delta before capturing.
80
+ *
81
+ * @param force Skip throttle check (used for interval captures)
70
82
  */
71
- const doCapture = () => {
83
+ const doCapture = (force = false) => {
72
84
  const state = scrollState.current;
73
85
  if (!state || state.viewportHeight <= 0)
74
86
  return;
75
87
  const now = Date.now();
76
- if (now - lastCaptureTime.current < THROTTLE_MS)
88
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS)
77
89
  return;
90
+ // Always check delta — no point re-capturing the same position
78
91
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
79
92
  initialCaptured.current)
80
93
  return;
@@ -86,6 +99,21 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
86
99
  }
87
100
  catch { }
88
101
  };
102
+ /** Start the interval timer for continuous scroll captures. */
103
+ const startScrollInterval = () => {
104
+ if (scrollIntervalTimer.current !== null)
105
+ return;
106
+ scrollIntervalTimer.current = setInterval(() => {
107
+ doCapture(true); // force=true skips throttle
108
+ }, SCROLL_INTERVAL_MS);
109
+ };
110
+ /** Stop the interval timer. */
111
+ const stopScrollInterval = () => {
112
+ if (scrollIntervalTimer.current !== null) {
113
+ clearInterval(scrollIntervalTimer.current);
114
+ scrollIntervalTimer.current = null;
115
+ }
116
+ };
89
117
  const handleScroll = (0, react_1.useCallback)((event) => {
90
118
  const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
91
119
  // Report scroll offset for touch coords
@@ -93,25 +121,31 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
93
121
  client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
94
122
  }
95
123
  catch { }
96
- // Update scroll state
124
+ // Update scroll state — use realContentHeight if available
125
+ const bestContentHeight = realContentHeight.current > 0
126
+ ? realContentHeight.current
127
+ : contentSize.height;
97
128
  scrollState.current = {
98
129
  offsetY: contentOffset.y,
99
130
  viewportHeight: layoutMeasurement.height,
100
- contentHeight: contentSize.height,
131
+ contentHeight: bestContentHeight,
101
132
  };
102
133
  // Debounce: capture after scroll stops
103
134
  if (debounceTimer.current)
104
135
  clearTimeout(debounceTimer.current);
105
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
106
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
136
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
137
+ // Start interval for continuous scroll captures
138
+ startScrollInterval();
139
+ // Capture initial tile (Y~0) on first scroll event with real dims
107
140
  if (!initialCaptured.current && contentOffset.y < 10) {
108
- setTimeout(doCapture, 200);
141
+ setTimeout(() => doCapture(), 200);
109
142
  }
110
143
  onScroll?.(event);
111
144
  }, [onScroll]);
112
145
  const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
113
146
  // User lifted finger — capture immediately
114
- doCapture();
147
+ stopScrollInterval();
148
+ doCapture(true);
115
149
  onScrollEndDrag?.(event);
116
150
  }, [onScrollEndDrag]);
117
151
  const handleMomentumEnd = (0, react_1.useCallback)((event) => {
@@ -119,7 +153,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
119
153
  clearTimeout(debounceTimer.current);
120
154
  debounceTimer.current = null;
121
155
  }
122
- doCapture();
156
+ stopScrollInterval();
157
+ doCapture(true);
123
158
  onMomentumScrollEnd?.(event);
124
159
  }, [onMomentumScrollEnd]);
125
160
  const handleLayout = (0, react_1.useCallback)((event) => {
@@ -129,12 +164,25 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
129
164
  scrollState.current = {
130
165
  offsetY: 0,
131
166
  viewportHeight: height,
132
- contentHeight: height, // Will be updated on first scroll
167
+ contentHeight: realContentHeight.current || height,
133
168
  };
134
- setTimeout(doCapture, 1000);
169
+ setTimeout(() => doCapture(), 1000);
135
170
  }
136
171
  onLayout?.(event);
137
172
  }, [onLayout]);
138
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
173
+ /**
174
+ * Track the real content height from onContentSizeChange.
175
+ * This is more reliable than the first scroll event and ensures
176
+ * the initial tile at Y=0 has correct contentHeight metadata.
177
+ */
178
+ const handleContentSizeChange = (0, react_1.useCallback)((w, h) => {
179
+ realContentHeight.current = h;
180
+ // Update scroll state if it exists
181
+ if (scrollState.current) {
182
+ scrollState.current.contentHeight = h;
183
+ }
184
+ onContentSizeChange?.(w, h);
185
+ }, [onContentSizeChange]);
186
+ return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
139
187
  });
140
188
  exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
@@ -284,8 +284,14 @@ class AppAnalytics {
284
284
  this.scrollOffsetY = 0;
285
285
  this.errorTracker?.setCurrentScreen(screenName);
286
286
  this.snapshotCapture?.setCurrentScreen(screenName);
287
- // Tile capture is handled by RepliqoScrollView (on scroll stops,
288
- // finger lift, momentum end, and initial layout).
287
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
288
+ // React Native does NOT render off-screen content to the GPU or view
289
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
290
+ // tiles. Worse, those blank tiles poison the dedup cache and block
291
+ // real tiles captured during natural user scroll.
292
+ //
293
+ // Full-content capture relies entirely on RepliqoScrollView capturing
294
+ // viewport tiles as the user naturally scrolls through the content.
289
295
  this.logger.log('Screen entered:', screenName);
290
296
  }
291
297
  onScreenExit(screenName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -11,47 +11,60 @@ import { AppAnalytics } from '../core/client';
11
11
  /** Minimum scroll delta (px) to trigger a new tile capture. */
12
12
  const MIN_DELTA_PX = 20;
13
13
  /** Debounce: wait this long after scroll stops before capturing. */
14
- const DEBOUNCE_MS = 300;
14
+ const DEBOUNCE_MS = 250;
15
15
  /** Throttle: minimum time between tile captures. */
16
- const THROTTLE_MS = 500;
16
+ const THROTTLE_MS = 400;
17
+ /**
18
+ * During continuous scroll, force a capture every this many ms.
19
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
20
+ * where the user never pauses long enough for the debounce to fire.
21
+ */
22
+ const SCROLL_INTERVAL_MS = 1200;
17
23
 
18
24
  /**
19
25
  * Drop-in replacement for React Native's ScrollView that:
20
26
  *
21
27
  * 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
28
+ * 2. Captures viewport tiles on scroll stops AND during continuous
29
+ * scroll for collaborative screen building — the backend composites
30
+ * these into a full-content image over time
25
31
  */
26
32
  export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
27
- ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
33
+ ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
28
34
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
35
+ const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
29
36
  const lastCaptureTime = useRef(0);
30
37
  const lastCaptureY = useRef(-999);
31
38
  const initialCaptured = useRef(false);
39
+ const realContentHeight = useRef(0);
32
40
  const scrollState = useRef<{
33
41
  offsetY: number;
34
42
  viewportHeight: number;
35
43
  contentHeight: number;
36
44
  } | null>(null);
37
45
 
38
- // Cleanup debounce timer on unmount
46
+ // Cleanup timers on unmount
39
47
  useEffect(() => {
40
48
  return () => {
41
49
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
50
+ if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
42
51
  };
43
52
  }, []);
44
53
 
45
54
  /**
46
55
  * Core tile capture logic. Reads from refs (stable across renders).
47
56
  * Checks throttle + delta before capturing.
57
+ *
58
+ * @param force Skip throttle check (used for interval captures)
48
59
  */
49
- const doCapture = () => {
60
+ const doCapture = (force = false) => {
50
61
  const state = scrollState.current;
51
62
  if (!state || state.viewportHeight <= 0) return;
52
63
 
53
64
  const now = Date.now();
54
- if (now - lastCaptureTime.current < THROTTLE_MS) return;
65
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS) return;
66
+
67
+ // Always check delta — no point re-capturing the same position
55
68
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
56
69
  initialCaptured.current) return;
57
70
 
@@ -68,6 +81,22 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
68
81
  } catch {}
69
82
  };
70
83
 
84
+ /** Start the interval timer for continuous scroll captures. */
85
+ const startScrollInterval = () => {
86
+ if (scrollIntervalTimer.current !== null) return;
87
+ scrollIntervalTimer.current = setInterval(() => {
88
+ doCapture(true); // force=true skips throttle
89
+ }, SCROLL_INTERVAL_MS);
90
+ };
91
+
92
+ /** Stop the interval timer. */
93
+ const stopScrollInterval = () => {
94
+ if (scrollIntervalTimer.current !== null) {
95
+ clearInterval(scrollIntervalTimer.current);
96
+ scrollIntervalTimer.current = null;
97
+ }
98
+ };
99
+
71
100
  const handleScroll = useCallback(
72
101
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
73
102
  const { contentOffset, layoutMeasurement, contentSize } =
@@ -78,20 +107,26 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
78
107
  AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
79
108
  } catch {}
80
109
 
81
- // Update scroll state
110
+ // Update scroll state — use realContentHeight if available
111
+ const bestContentHeight = realContentHeight.current > 0
112
+ ? realContentHeight.current
113
+ : contentSize.height;
82
114
  scrollState.current = {
83
115
  offsetY: contentOffset.y,
84
116
  viewportHeight: layoutMeasurement.height,
85
- contentHeight: contentSize.height,
117
+ contentHeight: bestContentHeight,
86
118
  };
87
119
 
88
120
  // Debounce: capture after scroll stops
89
121
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
90
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
122
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
91
123
 
92
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
124
+ // Start interval for continuous scroll captures
125
+ startScrollInterval();
126
+
127
+ // Capture initial tile (Y~0) on first scroll event with real dims
93
128
  if (!initialCaptured.current && contentOffset.y < 10) {
94
- setTimeout(doCapture, 200);
129
+ setTimeout(() => doCapture(), 200);
95
130
  }
96
131
 
97
132
  onScroll?.(event);
@@ -102,7 +137,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
102
137
  const handleScrollEndDrag = useCallback(
103
138
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
104
139
  // User lifted finger — capture immediately
105
- doCapture();
140
+ stopScrollInterval();
141
+ doCapture(true);
106
142
  onScrollEndDrag?.(event);
107
143
  },
108
144
  [onScrollEndDrag],
@@ -114,7 +150,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
114
150
  clearTimeout(debounceTimer.current);
115
151
  debounceTimer.current = null;
116
152
  }
117
- doCapture();
153
+ stopScrollInterval();
154
+ doCapture(true);
118
155
  onMomentumScrollEnd?.(event);
119
156
  },
120
157
  [onMomentumScrollEnd],
@@ -128,15 +165,32 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
128
165
  scrollState.current = {
129
166
  offsetY: 0,
130
167
  viewportHeight: height,
131
- contentHeight: height, // Will be updated on first scroll
168
+ contentHeight: realContentHeight.current || height,
132
169
  };
133
- setTimeout(doCapture, 1000);
170
+ setTimeout(() => doCapture(), 1000);
134
171
  }
135
172
  onLayout?.(event);
136
173
  },
137
174
  [onLayout],
138
175
  );
139
176
 
177
+ /**
178
+ * Track the real content height from onContentSizeChange.
179
+ * This is more reliable than the first scroll event and ensures
180
+ * the initial tile at Y=0 has correct contentHeight metadata.
181
+ */
182
+ const handleContentSizeChange = useCallback(
183
+ (w: number, h: number) => {
184
+ realContentHeight.current = h;
185
+ // Update scroll state if it exists
186
+ if (scrollState.current) {
187
+ scrollState.current.contentHeight = h;
188
+ }
189
+ onContentSizeChange?.(w, h);
190
+ },
191
+ [onContentSizeChange],
192
+ );
193
+
140
194
  return (
141
195
  <ScrollView
142
196
  ref={ref}
@@ -145,6 +199,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
145
199
  onScrollEndDrag={handleScrollEndDrag}
146
200
  onMomentumScrollEnd={handleMomentumEnd}
147
201
  onLayout={handleLayout}
202
+ onContentSizeChange={handleContentSizeChange}
148
203
  scrollEventThrottle={props.scrollEventThrottle ?? 16}
149
204
  />
150
205
  );
@@ -416,8 +416,14 @@ export class AppAnalytics {
416
416
  this.scrollOffsetY = 0;
417
417
  this.errorTracker?.setCurrentScreen(screenName);
418
418
  this.snapshotCapture?.setCurrentScreen(screenName);
419
- // Tile capture is handled by RepliqoScrollView (on scroll stops,
420
- // finger lift, momentum end, and initial layout).
419
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
420
+ // React Native does NOT render off-screen content to the GPU or view
421
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
422
+ // tiles. Worse, those blank tiles poison the dedup cache and block
423
+ // real tiles captured during natural user scroll.
424
+ //
425
+ // Full-content capture relies entirely on RepliqoScrollView capturing
426
+ // viewport tiles as the user naturally scrolls through the content.
421
427
 
422
428
  this.logger.log('Screen entered:', screenName);
423
429
  }