@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.
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +27 -0
- package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +248 -0
- package/dist/components/RepliqoScrollView.d.ts +5 -20
- package/dist/components/RepliqoScrollView.js +85 -28
- package/dist/core/client.d.ts +14 -1
- package/dist/core/client.js +65 -14
- package/dist/snapshot/NativeScreenCapture.d.ts +18 -0
- package/dist/snapshot/NativeScreenCapture.js +32 -0
- package/dist/transport/api.client.d.ts +4 -0
- package/dist/transport/api.client.js +28 -0
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +110 -28
- package/src/core/client.ts +99 -21
- package/src/snapshot/NativeScreenCapture.ts +51 -0
- package/src/transport/api.client.ts +45 -0
|
@@ -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
|
|
5
|
-
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
4
|
+
* Drop-in replacement for React Native's ScrollView that:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
42
|
-
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
47
|
+
* Drop-in replacement for React Native's ScrollView that:
|
|
43
48
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
90
|
+
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
|
|
91
|
+
// Report scroll offset for touch coords
|
|
66
92
|
try {
|
|
67
|
-
|
|
68
|
-
client_1.AppAnalytics.getInstance().setScrollOffset(offsetY);
|
|
93
|
+
client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
|
|
69
94
|
}
|
|
70
|
-
catch {
|
|
71
|
-
|
|
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
|
-
|
|
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';
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
244
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
73
|
+
const { contentOffset, layoutMeasurement, contentSize } =
|
|
74
|
+
event.nativeEvent;
|
|
75
|
+
|
|
76
|
+
// Report scroll offset for touch coords
|
|
38
77
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
);
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
349
|
-
|
|
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,
|