@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.
- package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +279 -0
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +8 -1
- package/dist/components/RepliqoScrollView.d.ts +3 -3
- package/dist/components/RepliqoScrollView.js +67 -19
- package/dist/core/client.js +8 -2
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +73 -18
- package/src/core/client.ts +8 -2
|
@@ -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
|
-
|
|
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
|
|
8
|
-
* screen building — the backend composites
|
|
9
|
-
* full-content image over time
|
|
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 =
|
|
43
|
+
const DEBOUNCE_MS = 250;
|
|
44
44
|
/** Throttle: minimum time between tile captures. */
|
|
45
|
-
const THROTTLE_MS =
|
|
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
|
|
51
|
-
* screen building — the backend composites
|
|
52
|
-
* full-content image over time
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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';
|
package/dist/core/client.js
CHANGED
|
@@ -284,8 +284,14 @@ class AppAnalytics {
|
|
|
284
284
|
this.scrollOffsetY = 0;
|
|
285
285
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
286
286
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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.
|
|
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 =
|
|
14
|
+
const DEBOUNCE_MS = 250;
|
|
15
15
|
/** Throttle: minimum time between tile captures. */
|
|
16
|
-
const THROTTLE_MS =
|
|
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
|
|
23
|
-
* screen building — the backend composites
|
|
24
|
-
* full-content image over time
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
);
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
//
|
|
420
|
-
//
|
|
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
|
}
|