@repliqo/sdk-react-native 0.1.2 → 0.2.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/FullContentCapture.java +258 -0
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +41 -0
- package/dist/components/RepliqoScrollView.d.ts +26 -0
- package/dist/components/RepliqoScrollView.js +78 -0
- package/dist/core/client.d.ts +9 -0
- package/dist/core/client.js +27 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/snapshot/NativeScreenCapture.d.ts +26 -0
- package/dist/snapshot/NativeScreenCapture.js +32 -0
- package/dist/snapshot/ScreenCaptureManager.d.ts +31 -0
- package/dist/snapshot/ScreenCaptureManager.js +90 -0
- package/dist/transport/api.client.d.ts +5 -0
- package/dist/transport/api.client.js +26 -0
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +62 -0
- package/src/core/client.ts +36 -1
- package/src/index.ts +5 -0
- package/src/snapshot/NativeScreenCapture.ts +58 -6
- package/src/snapshot/ScreenCaptureManager.ts +116 -0
- package/src/transport/api.client.ts +40 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
package com.repliqo.screencapture;
|
|
2
|
+
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.graphics.Bitmap;
|
|
5
|
+
import android.graphics.Canvas;
|
|
6
|
+
import android.util.Base64;
|
|
7
|
+
import android.util.Log;
|
|
8
|
+
import android.view.View;
|
|
9
|
+
import android.view.ViewGroup;
|
|
10
|
+
import android.widget.ScrollView;
|
|
11
|
+
|
|
12
|
+
import java.io.ByteArrayOutputStream;
|
|
13
|
+
import java.lang.reflect.Field;
|
|
14
|
+
import java.util.ArrayList;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Captures the FULL content of the primary vertical ScrollView found in
|
|
18
|
+
* the app's view hierarchy — including content scrolled off-screen.
|
|
19
|
+
*
|
|
20
|
+
* Used for heatmap backgrounds where we need the complete screen layout,
|
|
21
|
+
* not just the visible viewport.
|
|
22
|
+
*
|
|
23
|
+
* Supports:
|
|
24
|
+
* - Android ScrollView
|
|
25
|
+
* - AndroidX NestedScrollView
|
|
26
|
+
* - React Native ReactScrollView (old arch)
|
|
27
|
+
* - React Native ReactScrollViewManager (Fabric / new arch)
|
|
28
|
+
*
|
|
29
|
+
* Known limitation: FlatList / SectionList use RecyclerView which only
|
|
30
|
+
* renders visible items. Full-content capture is not possible for those.
|
|
31
|
+
* The method falls back to a viewport capture in that case.
|
|
32
|
+
*
|
|
33
|
+
* No permissions needed. Runs on UI thread.
|
|
34
|
+
*/
|
|
35
|
+
public class FullContentCapture {
|
|
36
|
+
private static final String TAG = "RepliqoCapture";
|
|
37
|
+
private static final int TARGET_WIDTH = 500;
|
|
38
|
+
private static final int JPEG_QUALITY = 60;
|
|
39
|
+
private static final int MAX_HEIGHT = 5000;
|
|
40
|
+
|
|
41
|
+
/** Result that includes whether this is a full-content or viewport capture. */
|
|
42
|
+
public static class FullCaptureResult {
|
|
43
|
+
public final String image;
|
|
44
|
+
public final int width;
|
|
45
|
+
public final int height;
|
|
46
|
+
public final boolean isFullContent;
|
|
47
|
+
|
|
48
|
+
FullCaptureResult(String image, int width, int height, boolean isFullContent) {
|
|
49
|
+
this.image = image;
|
|
50
|
+
this.width = width;
|
|
51
|
+
this.height = height;
|
|
52
|
+
this.isFullContent = isFullContent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Capture the full content of the primary vertical ScrollView.
|
|
58
|
+
* Falls back to viewport capture if no suitable ScrollView is found.
|
|
59
|
+
*
|
|
60
|
+
* @param activity Current activity
|
|
61
|
+
* @return FullCaptureResult with isFullContent flag
|
|
62
|
+
*/
|
|
63
|
+
public static FullCaptureResult capture(Activity activity) {
|
|
64
|
+
if (activity == null || activity.isFinishing()) return null;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Find the primary vertical ScrollView
|
|
68
|
+
View scrollView = findVerticalScrollView(activity);
|
|
69
|
+
if (scrollView == null) {
|
|
70
|
+
Log.d(TAG, "No vertical ScrollView found, falling back to viewport");
|
|
71
|
+
return viewportFallback(activity);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Get the content child
|
|
75
|
+
View contentView = getContentChild(scrollView);
|
|
76
|
+
if (contentView == null) {
|
|
77
|
+
return viewportFallback(activity);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
int contentWidth = contentView.getWidth();
|
|
81
|
+
int contentHeight = contentView.getHeight();
|
|
82
|
+
|
|
83
|
+
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
84
|
+
return viewportFallback(activity);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If content height is same as viewport, it's not scrollable
|
|
88
|
+
int viewportHeight = scrollView.getHeight();
|
|
89
|
+
if (contentHeight <= viewportHeight) {
|
|
90
|
+
Log.d(TAG, "Content fits in viewport, no scroll needed");
|
|
91
|
+
return viewportFallback(activity);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cap height to avoid OOM
|
|
95
|
+
int captureHeight = Math.min(contentHeight, MAX_HEIGHT);
|
|
96
|
+
|
|
97
|
+
// Scale to output dimensions
|
|
98
|
+
float scale = (float) TARGET_WIDTH / contentWidth;
|
|
99
|
+
int outWidth = TARGET_WIDTH & ~1;
|
|
100
|
+
int outHeight = Math.round(captureHeight * scale) & ~1;
|
|
101
|
+
|
|
102
|
+
if (outWidth <= 0 || outHeight <= 0) {
|
|
103
|
+
return viewportFallback(activity);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create bitmap — wrapped in OOM catch
|
|
107
|
+
Bitmap bitmap;
|
|
108
|
+
try {
|
|
109
|
+
bitmap = Bitmap.createBitmap(outWidth, outHeight,
|
|
110
|
+
Bitmap.Config.ARGB_8888);
|
|
111
|
+
} catch (OutOfMemoryError oom) {
|
|
112
|
+
Log.w(TAG, "OOM creating bitmap " + outWidth + "x" + outHeight +
|
|
113
|
+
", falling back to viewport");
|
|
114
|
+
return viewportFallback(activity);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Canvas canvas = new Canvas(bitmap);
|
|
118
|
+
canvas.scale(scale, scale);
|
|
119
|
+
|
|
120
|
+
// Draw the full content
|
|
121
|
+
contentView.draw(canvas);
|
|
122
|
+
|
|
123
|
+
// Compress to JPEG
|
|
124
|
+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
125
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
|
|
126
|
+
String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
|
|
127
|
+
bitmap.recycle();
|
|
128
|
+
|
|
129
|
+
Log.d(TAG, "Full content captured: " + outWidth + "x" + outHeight +
|
|
130
|
+
" (content: " + contentWidth + "x" + contentHeight +
|
|
131
|
+
", viewport: " + contentWidth + "x" + viewportHeight + ")");
|
|
132
|
+
|
|
133
|
+
return new FullCaptureResult(base64, outWidth, outHeight, true);
|
|
134
|
+
|
|
135
|
+
} catch (Exception e) {
|
|
136
|
+
Log.w(TAG, "FullContentCapture failed: " + e.getMessage());
|
|
137
|
+
return viewportFallback(activity);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fallback: capture just the visible viewport via MultiWindowCapture.
|
|
143
|
+
*/
|
|
144
|
+
private static FullCaptureResult viewportFallback(Activity activity) {
|
|
145
|
+
MultiWindowCapture.CaptureResult vp = MultiWindowCapture.capture(activity);
|
|
146
|
+
if (vp == null) return null;
|
|
147
|
+
return new FullCaptureResult(vp.image, vp.width, vp.height, false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Find the first VERTICAL ScrollView in the view hierarchy.
|
|
152
|
+
* Searches all root views (including modals).
|
|
153
|
+
*
|
|
154
|
+
* Matches by walking the class hierarchy for known scroll classes:
|
|
155
|
+
* - android.widget.ScrollView
|
|
156
|
+
* - androidx.core.widget.NestedScrollView
|
|
157
|
+
* - com.facebook.react.views.scroll.ReactScrollView (old arch)
|
|
158
|
+
* - Any class with "ScrollView" in the name (catches Fabric variants)
|
|
159
|
+
*
|
|
160
|
+
* Explicitly SKIPS HorizontalScrollView and classes containing
|
|
161
|
+
* "Horizontal" to avoid capturing carousels.
|
|
162
|
+
*/
|
|
163
|
+
private static View findVerticalScrollView(Activity activity) {
|
|
164
|
+
View decorView = activity.getWindow().getDecorView();
|
|
165
|
+
View found = findVerticalScrollInTree(decorView);
|
|
166
|
+
if (found != null) return found;
|
|
167
|
+
|
|
168
|
+
// Check other windows (modals)
|
|
169
|
+
try {
|
|
170
|
+
Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
|
|
171
|
+
Object wmg = wmgClass.getMethod("getInstance").invoke(null);
|
|
172
|
+
Field viewsField = wmgClass.getDeclaredField("mViews");
|
|
173
|
+
viewsField.setAccessible(true);
|
|
174
|
+
@SuppressWarnings("unchecked")
|
|
175
|
+
ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
|
|
176
|
+
if (views != null) {
|
|
177
|
+
for (View root : views) {
|
|
178
|
+
if (root == decorView) continue;
|
|
179
|
+
found = findVerticalScrollInTree(root);
|
|
180
|
+
if (found != null) return found;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (Exception e) {
|
|
184
|
+
// Reflection failed
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* DFS for a vertical scroll view in the tree.
|
|
192
|
+
*/
|
|
193
|
+
private static View findVerticalScrollInTree(View view) {
|
|
194
|
+
if (view == null) return null;
|
|
195
|
+
|
|
196
|
+
if (isVerticalScrollView(view)) {
|
|
197
|
+
if (view instanceof ViewGroup && ((ViewGroup) view).getChildCount() > 0) {
|
|
198
|
+
return view;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (view instanceof ViewGroup) {
|
|
203
|
+
ViewGroup group = (ViewGroup) view;
|
|
204
|
+
for (int i = 0; i < group.getChildCount(); i++) {
|
|
205
|
+
View found = findVerticalScrollInTree(group.getChildAt(i));
|
|
206
|
+
if (found != null) return found;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if a view is a vertical scroll view.
|
|
215
|
+
* Walks the class hierarchy to catch subclasses.
|
|
216
|
+
*/
|
|
217
|
+
private static boolean isVerticalScrollView(View view) {
|
|
218
|
+
// Direct instanceof for standard Android ScrollView
|
|
219
|
+
if (view instanceof ScrollView) return true;
|
|
220
|
+
|
|
221
|
+
// Walk class hierarchy for name-based matching
|
|
222
|
+
Class<?> clazz = view.getClass();
|
|
223
|
+
while (clazz != null && clazz != View.class) {
|
|
224
|
+
String name = clazz.getName();
|
|
225
|
+
|
|
226
|
+
// Skip horizontal variants
|
|
227
|
+
if (name.contains("Horizontal")) return false;
|
|
228
|
+
|
|
229
|
+
// Match known vertical scroll classes:
|
|
230
|
+
// - androidx.core.widget.NestedScrollView
|
|
231
|
+
// - com.facebook.react.views.scroll.ReactScrollView (old arch)
|
|
232
|
+
// - com.facebook.react.views.scroll.ReactScrollViewManager$... (Fabric)
|
|
233
|
+
// - Any other class ending in "ScrollView" that isn't horizontal
|
|
234
|
+
if (name.contains("NestedScrollView") ||
|
|
235
|
+
name.contains("ReactScrollView") ||
|
|
236
|
+
(name.endsWith("ScrollView") && !name.contains("Horizontal"))) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
clazz = clazz.getSuperclass();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get the content child of a ScrollView.
|
|
248
|
+
*/
|
|
249
|
+
private static View getContentChild(View scrollView) {
|
|
250
|
+
if (scrollView instanceof ViewGroup) {
|
|
251
|
+
ViewGroup group = (ViewGroup) scrollView;
|
|
252
|
+
if (group.getChildCount() > 0) {
|
|
253
|
+
return group.getChildAt(0);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -91,4 +91,45 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
|
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
97
|
+
* including content that is scrolled off-screen.
|
|
98
|
+
*
|
|
99
|
+
* Used for heatmap backgrounds. Falls back to a viewport capture
|
|
100
|
+
* if no ScrollView is found.
|
|
101
|
+
*
|
|
102
|
+
* Returns: { image: base64, width: int, height: int, format: "jpeg" }
|
|
103
|
+
*/
|
|
104
|
+
@ReactMethod
|
|
105
|
+
public void captureFullContent(Promise promise) {
|
|
106
|
+
Activity activity = getCurrentActivity();
|
|
107
|
+
if (activity == null) {
|
|
108
|
+
promise.resolve(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
mainHandler.post(() -> {
|
|
113
|
+
try {
|
|
114
|
+
FullContentCapture.FullCaptureResult result =
|
|
115
|
+
FullContentCapture.capture(activity);
|
|
116
|
+
|
|
117
|
+
if (result == null) {
|
|
118
|
+
promise.resolve(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
WritableMap map = Arguments.createMap();
|
|
123
|
+
map.putString("image", result.image);
|
|
124
|
+
map.putInt("width", result.width);
|
|
125
|
+
map.putInt("height", result.height);
|
|
126
|
+
map.putString("format", "jpeg");
|
|
127
|
+
map.putBoolean("isFullContent", result.isFullContent);
|
|
128
|
+
promise.resolve(map);
|
|
129
|
+
} catch (Exception e) {
|
|
130
|
+
Log.w(TAG, "captureFullContent failed: " + e.getMessage());
|
|
131
|
+
promise.resolve(null);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
94
135
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ScrollView, type ScrollViewProps } from 'react-native';
|
|
3
|
+
/**
|
|
4
|
+
* Drop-in replacement for React Native's ScrollView that automatically
|
|
5
|
+
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
6
|
+
*
|
|
7
|
+
* Without this, touch coordinates on scrollable screens are relative to
|
|
8
|
+
* the visible viewport. With this, they're relative to the full content,
|
|
9
|
+
* so heatmaps accurately show which content elements users touch.
|
|
10
|
+
*
|
|
11
|
+
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
15
|
+
*
|
|
16
|
+
* // Before:
|
|
17
|
+
* <ScrollView>...</ScrollView>
|
|
18
|
+
*
|
|
19
|
+
* // After:
|
|
20
|
+
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
24
|
+
* still fires normally.
|
|
25
|
+
*/
|
|
26
|
+
export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RepliqoScrollView = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const react_native_1 = require("react-native");
|
|
39
|
+
const client_1 = require("../core/client");
|
|
40
|
+
/**
|
|
41
|
+
* Drop-in replacement for React Native's ScrollView that automatically
|
|
42
|
+
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
43
|
+
*
|
|
44
|
+
* Without this, touch coordinates on scrollable screens are relative to
|
|
45
|
+
* the visible viewport. With this, they're relative to the full content,
|
|
46
|
+
* so heatmaps accurately show which content elements users touch.
|
|
47
|
+
*
|
|
48
|
+
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
49
|
+
*
|
|
50
|
+
* ```tsx
|
|
51
|
+
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
52
|
+
*
|
|
53
|
+
* // Before:
|
|
54
|
+
* <ScrollView>...</ScrollView>
|
|
55
|
+
*
|
|
56
|
+
* // After:
|
|
57
|
+
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
61
|
+
* still fires normally.
|
|
62
|
+
*/
|
|
63
|
+
exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, ...props }, ref) => {
|
|
64
|
+
const handleScroll = (0, react_1.useCallback)((event) => {
|
|
65
|
+
// Report scroll offset to the SDK
|
|
66
|
+
try {
|
|
67
|
+
const offsetY = event.nativeEvent.contentOffset.y;
|
|
68
|
+
client_1.AppAnalytics.getInstance().setScrollOffset(offsetY);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// SDK not initialized, ignore
|
|
72
|
+
}
|
|
73
|
+
// Forward to the original onScroll handler
|
|
74
|
+
onScroll?.(event);
|
|
75
|
+
}, [onScroll]);
|
|
76
|
+
return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
|
|
77
|
+
});
|
|
78
|
+
exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
|
package/dist/core/client.d.ts
CHANGED
|
@@ -7,10 +7,12 @@ export declare class AppAnalytics {
|
|
|
7
7
|
private screenVisitQueue;
|
|
8
8
|
private snapshotQueue;
|
|
9
9
|
private snapshotCapture;
|
|
10
|
+
private screenCaptureManager;
|
|
10
11
|
private errorTracker;
|
|
11
12
|
private sessionId;
|
|
12
13
|
private currentScreen;
|
|
13
14
|
private currentScreenEnteredAt;
|
|
15
|
+
private scrollOffsetY;
|
|
14
16
|
private logger;
|
|
15
17
|
private appStateSubscription;
|
|
16
18
|
private constructor();
|
|
@@ -23,6 +25,13 @@ export declare class AppAnalytics {
|
|
|
23
25
|
trackNavigation(fromScreen: string, toScreen: string): void;
|
|
24
26
|
trackCustomEvent(eventName: string, data?: Record<string, any>): void;
|
|
25
27
|
reportError(error: Error, metadata?: Record<string, any>): void;
|
|
28
|
+
/**
|
|
29
|
+
* Update the current scroll offset. Called by RepliqoScrollView
|
|
30
|
+
* or manually from the host app's ScrollView onScroll handler.
|
|
31
|
+
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
32
|
+
* on scrollable screens.
|
|
33
|
+
*/
|
|
34
|
+
setScrollOffset(y: number): void;
|
|
26
35
|
onScreenEnter(screenName: string): void;
|
|
27
36
|
onScreenExit(screenName: string): void;
|
|
28
37
|
flush(): Promise<void>;
|
package/dist/core/client.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AppAnalytics = void 0;
|
|
4
4
|
const react_native_1 = require("react-native");
|
|
5
5
|
const capture_1 = require("../snapshot/capture");
|
|
6
|
+
const ScreenCaptureManager_1 = require("../snapshot/ScreenCaptureManager");
|
|
6
7
|
const config_1 = require("./config");
|
|
7
8
|
const logger_1 = require("./logger");
|
|
8
9
|
const api_client_1 = require("../transport/api.client");
|
|
@@ -13,10 +14,12 @@ const error_tracker_1 = require("../trackers/error.tracker");
|
|
|
13
14
|
class AppAnalytics {
|
|
14
15
|
constructor(config) {
|
|
15
16
|
this.snapshotCapture = null;
|
|
17
|
+
this.screenCaptureManager = null;
|
|
16
18
|
this.errorTracker = null;
|
|
17
19
|
this.sessionId = null;
|
|
18
20
|
this.currentScreen = null;
|
|
19
21
|
this.currentScreenEnteredAt = null;
|
|
22
|
+
this.scrollOffsetY = 0;
|
|
20
23
|
this.appStateSubscription = null;
|
|
21
24
|
this.config = (0, config_1.resolveConfig)(config);
|
|
22
25
|
this.logger = new logger_1.Logger(this.config.debug);
|
|
@@ -49,6 +52,11 @@ class AppAnalytics {
|
|
|
49
52
|
}, undefined, // default captureScreenshot provider
|
|
50
53
|
(...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
|
|
51
54
|
}
|
|
55
|
+
// Screen capture manager for heatmap backgrounds.
|
|
56
|
+
// Captures full ScrollView content on screen enter (24h cache).
|
|
57
|
+
if (this.config.enableSnapshots) {
|
|
58
|
+
this.screenCaptureManager = new ScreenCaptureManager_1.ScreenCaptureManager((screenName, image, width, height, isFullContent) => this.apiClient.uploadScreenCapture(screenName, image, width, height, isFullContent), this.config.debug);
|
|
59
|
+
}
|
|
52
60
|
if (this.config.enableCrashTracking) {
|
|
53
61
|
this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
|
|
54
62
|
// Crash reports are sent immediately (fire-and-forget), not batched
|
|
@@ -93,6 +101,8 @@ class AppAnalytics {
|
|
|
93
101
|
instance.snapshotCapture.stop();
|
|
94
102
|
instance.snapshotCapture = null;
|
|
95
103
|
}
|
|
104
|
+
instance.screenCaptureManager?.reset();
|
|
105
|
+
instance.screenCaptureManager = null;
|
|
96
106
|
instance.eventQueue.stopAutoFlush();
|
|
97
107
|
instance.screenVisitQueue.stopAutoFlush();
|
|
98
108
|
instance.snapshotQueue.stopAutoFlush();
|
|
@@ -163,10 +173,14 @@ class AppAnalytics {
|
|
|
163
173
|
if (!this.config.enableTouchTracking) {
|
|
164
174
|
return;
|
|
165
175
|
}
|
|
176
|
+
// Adjust Y by the current scroll offset so touches map to content
|
|
177
|
+
// position, not just viewport position. This makes heatmaps accurate
|
|
178
|
+
// for scrollable screens.
|
|
179
|
+
const adjustedY = y + this.scrollOffsetY;
|
|
166
180
|
const event = {
|
|
167
181
|
type: 'touch',
|
|
168
182
|
screenName: screenName || this.currentScreen || undefined,
|
|
169
|
-
data: { x, y, ...extra },
|
|
183
|
+
data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
|
|
170
184
|
timestamp: new Date().toISOString(),
|
|
171
185
|
};
|
|
172
186
|
this.eventQueue.add(event);
|
|
@@ -210,11 +224,23 @@ class AppAnalytics {
|
|
|
210
224
|
}
|
|
211
225
|
this.errorTracker.reportError(error, metadata);
|
|
212
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Update the current scroll offset. Called by RepliqoScrollView
|
|
229
|
+
* or manually from the host app's ScrollView onScroll handler.
|
|
230
|
+
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
231
|
+
* on scrollable screens.
|
|
232
|
+
*/
|
|
233
|
+
setScrollOffset(y) {
|
|
234
|
+
this.scrollOffsetY = y;
|
|
235
|
+
}
|
|
213
236
|
onScreenEnter(screenName) {
|
|
214
237
|
this.currentScreen = screenName;
|
|
215
238
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
239
|
+
this.scrollOffsetY = 0;
|
|
216
240
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
217
241
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
242
|
+
// Trigger full-content capture for heatmap backgrounds (async, non-blocking)
|
|
243
|
+
this.screenCaptureManager?.onScreenEnter(screenName);
|
|
218
244
|
this.logger.log('Screen entered:', screenName);
|
|
219
245
|
}
|
|
220
246
|
onScreenExit(screenName) {
|
package/dist/index.d.ts
CHANGED
|
@@ -5,11 +5,13 @@ export { trackScreenEnter, trackScreenExit, } from './trackers/screen.tracker';
|
|
|
5
5
|
export { ErrorTracker } from './trackers/error.tracker';
|
|
6
6
|
export { SnapshotCapture } from './snapshot/capture';
|
|
7
7
|
export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
8
|
-
export { isNativeScreenCaptureAvailable, captureNativeFrame, } from './snapshot/NativeScreenCapture';
|
|
8
|
+
export { isNativeScreenCaptureAvailable, captureNativeFrame, captureFullContent, } from './snapshot/NativeScreenCapture';
|
|
9
|
+
export type { FullContentResult } from './snapshot/NativeScreenCapture';
|
|
9
10
|
export type { EventType, AnalyticsEvent, TouchEventData, NavigationEventData, ScreenVisit, DeviceInfo, SDKConfig, } from './types/events';
|
|
10
11
|
export type { ScreenshotData, SnapshotPayload, } from './types/snapshot';
|
|
11
12
|
export type { CrashReport } from './types/crash';
|
|
12
13
|
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
13
14
|
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
15
|
+
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
14
16
|
export { DEFAULT_CONFIG } from './core/config';
|
|
15
17
|
export type { ResolvedConfig } from './core/config';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_CONFIG = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
|
|
3
|
+
exports.DEFAULT_CONFIG = exports.RepliqoScrollView = exports.captureFullContent = exports.captureNativeFrame = exports.isNativeScreenCaptureAvailable = exports.captureScreenshot = exports.SnapshotCapture = exports.ErrorTracker = exports.trackScreenExit = exports.trackScreenEnter = exports.trackScreenChange = exports.useNavigationTracker = exports.TouchTracker = exports.AppAnalytics = void 0;
|
|
4
4
|
// Main class
|
|
5
5
|
var client_1 = require("./core/client");
|
|
6
6
|
Object.defineProperty(exports, "AppAnalytics", { enumerable: true, get: function () { return client_1.AppAnalytics; } });
|
|
@@ -23,6 +23,10 @@ Object.defineProperty(exports, "captureScreenshot", { enumerable: true, get: fun
|
|
|
23
23
|
var NativeScreenCapture_1 = require("./snapshot/NativeScreenCapture");
|
|
24
24
|
Object.defineProperty(exports, "isNativeScreenCaptureAvailable", { enumerable: true, get: function () { return NativeScreenCapture_1.isNativeScreenCaptureAvailable; } });
|
|
25
25
|
Object.defineProperty(exports, "captureNativeFrame", { enumerable: true, get: function () { return NativeScreenCapture_1.captureNativeFrame; } });
|
|
26
|
+
Object.defineProperty(exports, "captureFullContent", { enumerable: true, get: function () { return NativeScreenCapture_1.captureFullContent; } });
|
|
27
|
+
// Components
|
|
28
|
+
var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
|
|
29
|
+
Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
|
|
26
30
|
// Config
|
|
27
31
|
var config_1 = require("./core/config");
|
|
28
32
|
Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
|
|
@@ -15,3 +15,29 @@ export declare function isNativeScreenCaptureAvailable(): boolean;
|
|
|
15
15
|
* @returns NativeFrameResult or null if capture failed / not available
|
|
16
16
|
*/
|
|
17
17
|
export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
20
|
+
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
21
|
+
*
|
|
22
|
+
* Falls back to a viewport capture if no ScrollView is found.
|
|
23
|
+
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
24
|
+
*
|
|
25
|
+
* @returns NativeFrameResult or null if capture failed / not available
|
|
26
|
+
*/
|
|
27
|
+
export interface FullContentResult extends NativeFrameResult {
|
|
28
|
+
/** True if the capture includes full scrollable content. False if viewport-only fallback. */
|
|
29
|
+
isFullContent: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
33
|
+
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
34
|
+
*
|
|
35
|
+
* Falls back to a viewport capture if no ScrollView is found.
|
|
36
|
+
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
37
|
+
*
|
|
38
|
+
* Known limitation: FlatList/SectionList use RecyclerView which only
|
|
39
|
+
* renders visible items — full-content capture falls back to viewport.
|
|
40
|
+
*
|
|
41
|
+
* @returns FullContentResult with isFullContent flag, or null
|
|
42
|
+
*/
|
|
43
|
+
export declare function captureFullContent(): Promise<FullContentResult | null>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isNativeScreenCaptureAvailable = isNativeScreenCaptureAvailable;
|
|
4
4
|
exports.captureNativeFrame = captureNativeFrame;
|
|
5
|
+
exports.captureFullContent = captureFullContent;
|
|
5
6
|
const react_native_1 = require("react-native");
|
|
6
7
|
const NativeModule = react_native_1.Platform.OS === 'android'
|
|
7
8
|
? react_native_1.NativeModules.ScreenCaptureModule ?? null
|
|
@@ -36,3 +37,34 @@ async function captureNativeFrame() {
|
|
|
36
37
|
return null;
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
42
|
+
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
43
|
+
*
|
|
44
|
+
* Falls back to a viewport capture if no ScrollView is found.
|
|
45
|
+
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
46
|
+
*
|
|
47
|
+
* Known limitation: FlatList/SectionList use RecyclerView which only
|
|
48
|
+
* renders visible items — full-content capture falls back to viewport.
|
|
49
|
+
*
|
|
50
|
+
* @returns FullContentResult with isFullContent flag, or null
|
|
51
|
+
*/
|
|
52
|
+
async function captureFullContent() {
|
|
53
|
+
if (!NativeModule)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const result = await NativeModule.captureFullContent();
|
|
57
|
+
if (!result || !result.image)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
image: result.image,
|
|
61
|
+
width: result.width,
|
|
62
|
+
height: result.height,
|
|
63
|
+
format: 'jpeg',
|
|
64
|
+
isFullContent: !!result.isFullContent,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type UploadFn = (screenName: string, image: string, width: number, height: number, isFullContent: boolean) => Promise<void>;
|
|
2
|
+
/**
|
|
3
|
+
* Manages full-content screen captures for heatmap backgrounds.
|
|
4
|
+
*
|
|
5
|
+
* - Captures the full ScrollView content on screen enter
|
|
6
|
+
* - Local cache with 24h TTL to avoid redundant captures
|
|
7
|
+
* - Async and non-blocking: never delays navigation or crashes the app
|
|
8
|
+
* - Independent of the session replay snapshot system
|
|
9
|
+
*/
|
|
10
|
+
export declare class ScreenCaptureManager {
|
|
11
|
+
private cache;
|
|
12
|
+
private pending;
|
|
13
|
+
private uploadFn;
|
|
14
|
+
private debug;
|
|
15
|
+
constructor(uploadFn: UploadFn, debug?: boolean);
|
|
16
|
+
/**
|
|
17
|
+
* Called when the user enters a screen. Schedules a full-content
|
|
18
|
+
* capture after a delay if no recent capture exists.
|
|
19
|
+
*
|
|
20
|
+
* Fire-and-forget: never awaited by the caller, never throws.
|
|
21
|
+
*/
|
|
22
|
+
onScreenEnter(screenName: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Check if a capture is needed for this screen.
|
|
25
|
+
*/
|
|
26
|
+
private shouldCapture;
|
|
27
|
+
private captureAndUpload;
|
|
28
|
+
/** Reset the cache (e.g. on session end). */
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ScreenCaptureManager = void 0;
|
|
4
|
+
const NativeScreenCapture_1 = require("./NativeScreenCapture");
|
|
5
|
+
/** 24 hours in milliseconds. */
|
|
6
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Wait for screen to render before capturing. */
|
|
8
|
+
const RENDER_DELAY_MS = 800;
|
|
9
|
+
/**
|
|
10
|
+
* Manages full-content screen captures for heatmap backgrounds.
|
|
11
|
+
*
|
|
12
|
+
* - Captures the full ScrollView content on screen enter
|
|
13
|
+
* - Local cache with 24h TTL to avoid redundant captures
|
|
14
|
+
* - Async and non-blocking: never delays navigation or crashes the app
|
|
15
|
+
* - Independent of the session replay snapshot system
|
|
16
|
+
*/
|
|
17
|
+
class ScreenCaptureManager {
|
|
18
|
+
constructor(uploadFn, debug = false) {
|
|
19
|
+
this.cache = new Map();
|
|
20
|
+
this.pending = new Set();
|
|
21
|
+
this.uploadFn = uploadFn;
|
|
22
|
+
this.debug = debug;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Called when the user enters a screen. Schedules a full-content
|
|
26
|
+
* capture after a delay if no recent capture exists.
|
|
27
|
+
*
|
|
28
|
+
* Fire-and-forget: never awaited by the caller, never throws.
|
|
29
|
+
*/
|
|
30
|
+
onScreenEnter(screenName) {
|
|
31
|
+
if (!this.shouldCapture(screenName))
|
|
32
|
+
return;
|
|
33
|
+
// Mark as pending to avoid duplicate captures from rapid navigation
|
|
34
|
+
this.pending.add(screenName);
|
|
35
|
+
// Delay to let the screen render completely (animations, data loading)
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
this.captureAndUpload(screenName).catch(() => { });
|
|
38
|
+
}, RENDER_DELAY_MS);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if a capture is needed for this screen.
|
|
42
|
+
*/
|
|
43
|
+
shouldCapture(screenName) {
|
|
44
|
+
// Already capturing this screen
|
|
45
|
+
if (this.pending.has(screenName))
|
|
46
|
+
return false;
|
|
47
|
+
// Check local cache TTL
|
|
48
|
+
const lastCapture = this.cache.get(screenName);
|
|
49
|
+
if (lastCapture && Date.now() - lastCapture < CACHE_TTL_MS) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
async captureAndUpload(screenName) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await (0, NativeScreenCapture_1.captureFullContent)();
|
|
57
|
+
if (!result) {
|
|
58
|
+
if (this.debug) {
|
|
59
|
+
console.log(`[Repliqo] Screen capture returned null for "${screenName}"`);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.debug) {
|
|
64
|
+
console.log(`[Repliqo] Screen captured: "${screenName}" ${result.width}x${result.height} ` +
|
|
65
|
+
`(fullContent=${result.isFullContent}, ${Math.round(result.image.length / 1024)}KB)`);
|
|
66
|
+
}
|
|
67
|
+
// Upload to backend (fire-and-forget, errors are swallowed)
|
|
68
|
+
await this.uploadFn(screenName, result.image, result.width, result.height, result.isFullContent);
|
|
69
|
+
// Update local cache
|
|
70
|
+
this.cache.set(screenName, Date.now());
|
|
71
|
+
if (this.debug) {
|
|
72
|
+
console.log(`[Repliqo] Screen capture uploaded: "${screenName}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (this.debug) {
|
|
77
|
+
console.warn(`[Repliqo] Screen capture failed for "${screenName}":`, err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
this.pending.delete(screenName);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Reset the cache (e.g. on session end). */
|
|
85
|
+
reset() {
|
|
86
|
+
this.cache.clear();
|
|
87
|
+
this.pending.clear();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.ScreenCaptureManager = ScreenCaptureManager;
|
|
@@ -26,4 +26,9 @@ export declare class ApiClient {
|
|
|
26
26
|
sendCrashesBatch(crashes: CrashReport[]): Promise<{
|
|
27
27
|
count: number;
|
|
28
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Upload a full-content screen capture for heatmap backgrounds.
|
|
31
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
32
|
+
*/
|
|
33
|
+
uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
|
|
29
34
|
}
|
|
@@ -152,5 +152,31 @@ class ApiClient {
|
|
|
152
152
|
throw error;
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Upload a full-content screen capture for heatmap backgrounds.
|
|
157
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
158
|
+
*/
|
|
159
|
+
async uploadScreenCapture(screenName, image, width, height, isFullContent) {
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${this.baseUrl}/api/screen-captures`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: this.getHeaders(),
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
screenName,
|
|
166
|
+
image,
|
|
167
|
+
width,
|
|
168
|
+
height,
|
|
169
|
+
isFullContent,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const text = await response.text();
|
|
174
|
+
this.logger.warn('Screen capture upload failed:', response.status, text?.substring(0, 200));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
this.logger.warn('Error uploading screen capture:', error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
155
181
|
}
|
|
156
182
|
exports.ApiClient = ApiClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repliqo/sdk-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ScrollView,
|
|
4
|
+
type ScrollViewProps,
|
|
5
|
+
type NativeSyntheticEvent,
|
|
6
|
+
type NativeScrollEvent,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
import { AppAnalytics } from '../core/client';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drop-in replacement for React Native's ScrollView that automatically
|
|
12
|
+
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
13
|
+
*
|
|
14
|
+
* Without this, touch coordinates on scrollable screens are relative to
|
|
15
|
+
* the visible viewport. With this, they're relative to the full content,
|
|
16
|
+
* so heatmaps accurately show which content elements users touch.
|
|
17
|
+
*
|
|
18
|
+
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
22
|
+
*
|
|
23
|
+
* // Before:
|
|
24
|
+
* <ScrollView>...</ScrollView>
|
|
25
|
+
*
|
|
26
|
+
* // After:
|
|
27
|
+
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
31
|
+
* still fires normally.
|
|
32
|
+
*/
|
|
33
|
+
export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
34
|
+
({ onScroll, ...props }, ref) => {
|
|
35
|
+
const handleScroll = useCallback(
|
|
36
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
37
|
+
// Report scroll offset to the SDK
|
|
38
|
+
try {
|
|
39
|
+
const offsetY = event.nativeEvent.contentOffset.y;
|
|
40
|
+
AppAnalytics.getInstance().setScrollOffset(offsetY);
|
|
41
|
+
} catch {
|
|
42
|
+
// SDK not initialized, ignore
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Forward to the original onScroll handler
|
|
46
|
+
onScroll?.(event);
|
|
47
|
+
},
|
|
48
|
+
[onScroll],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ScrollView
|
|
53
|
+
ref={ref}
|
|
54
|
+
{...props}
|
|
55
|
+
onScroll={handleScroll}
|
|
56
|
+
scrollEventThrottle={props.scrollEventThrottle ?? 16}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
RepliqoScrollView.displayName = 'RepliqoScrollView';
|
package/src/core/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '../types/events';
|
|
8
8
|
import { SnapshotPayload } from '../types/snapshot';
|
|
9
9
|
import { SnapshotCapture } from '../snapshot/capture';
|
|
10
|
+
import { ScreenCaptureManager } from '../snapshot/ScreenCaptureManager';
|
|
10
11
|
import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
|
|
11
12
|
import { Logger } from './logger';
|
|
12
13
|
import { ApiClient } from '../transport/api.client';
|
|
@@ -24,10 +25,12 @@ export class AppAnalytics {
|
|
|
24
25
|
private screenVisitQueue: BatchQueue<ScreenVisit>;
|
|
25
26
|
private snapshotQueue: BatchQueue<SnapshotPayload>;
|
|
26
27
|
private snapshotCapture: SnapshotCapture | null = null;
|
|
28
|
+
private screenCaptureManager: ScreenCaptureManager | null = null;
|
|
27
29
|
private errorTracker: ErrorTracker | null = null;
|
|
28
30
|
private sessionId: string | null = null;
|
|
29
31
|
private currentScreen: string | null = null;
|
|
30
32
|
private currentScreenEnteredAt: string | null = null;
|
|
33
|
+
private scrollOffsetY: number = 0;
|
|
31
34
|
private logger: Logger;
|
|
32
35
|
private appStateSubscription: { remove: () => void } | null = null;
|
|
33
36
|
|
|
@@ -93,6 +96,18 @@ export class AppAnalytics {
|
|
|
93
96
|
);
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
// Screen capture manager for heatmap backgrounds.
|
|
100
|
+
// Captures full ScrollView content on screen enter (24h cache).
|
|
101
|
+
if (this.config.enableSnapshots) {
|
|
102
|
+
this.screenCaptureManager = new ScreenCaptureManager(
|
|
103
|
+
(screenName, image, width, height, isFullContent) =>
|
|
104
|
+
this.apiClient.uploadScreenCapture(
|
|
105
|
+
screenName, image, width, height, isFullContent,
|
|
106
|
+
),
|
|
107
|
+
this.config.debug,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
if (this.config.enableCrashTracking) {
|
|
97
112
|
this.errorTracker = new ErrorTracker((crash) => {
|
|
98
113
|
// Crash reports are sent immediately (fire-and-forget), not batched
|
|
@@ -150,6 +165,8 @@ export class AppAnalytics {
|
|
|
150
165
|
instance.snapshotCapture = null;
|
|
151
166
|
}
|
|
152
167
|
|
|
168
|
+
instance.screenCaptureManager?.reset();
|
|
169
|
+
instance.screenCaptureManager = null;
|
|
153
170
|
|
|
154
171
|
instance.eventQueue.stopAutoFlush();
|
|
155
172
|
instance.screenVisitQueue.stopAutoFlush();
|
|
@@ -246,10 +263,15 @@ export class AppAnalytics {
|
|
|
246
263
|
return;
|
|
247
264
|
}
|
|
248
265
|
|
|
266
|
+
// Adjust Y by the current scroll offset so touches map to content
|
|
267
|
+
// position, not just viewport position. This makes heatmaps accurate
|
|
268
|
+
// for scrollable screens.
|
|
269
|
+
const adjustedY = y + this.scrollOffsetY;
|
|
270
|
+
|
|
249
271
|
const event: AnalyticsEvent = {
|
|
250
272
|
type: 'touch',
|
|
251
273
|
screenName: screenName || this.currentScreen || undefined,
|
|
252
|
-
data: { x, y, ...extra },
|
|
274
|
+
data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
|
|
253
275
|
timestamp: new Date().toISOString(),
|
|
254
276
|
};
|
|
255
277
|
|
|
@@ -306,11 +328,24 @@ export class AppAnalytics {
|
|
|
306
328
|
this.errorTracker.reportError(error, metadata);
|
|
307
329
|
}
|
|
308
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Update the current scroll offset. Called by RepliqoScrollView
|
|
333
|
+
* or manually from the host app's ScrollView onScroll handler.
|
|
334
|
+
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
335
|
+
* on scrollable screens.
|
|
336
|
+
*/
|
|
337
|
+
setScrollOffset(y: number): void {
|
|
338
|
+
this.scrollOffsetY = y;
|
|
339
|
+
}
|
|
340
|
+
|
|
309
341
|
onScreenEnter(screenName: string): void {
|
|
310
342
|
this.currentScreen = screenName;
|
|
311
343
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
344
|
+
this.scrollOffsetY = 0;
|
|
312
345
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
313
346
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
347
|
+
// Trigger full-content capture for heatmap backgrounds (async, non-blocking)
|
|
348
|
+
this.screenCaptureManager?.onScreenEnter(screenName);
|
|
314
349
|
this.logger.log('Screen entered:', screenName);
|
|
315
350
|
}
|
|
316
351
|
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,9 @@ export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
|
19
19
|
export {
|
|
20
20
|
isNativeScreenCaptureAvailable,
|
|
21
21
|
captureNativeFrame,
|
|
22
|
+
captureFullContent,
|
|
22
23
|
} from './snapshot/NativeScreenCapture';
|
|
24
|
+
export type { FullContentResult } from './snapshot/NativeScreenCapture';
|
|
23
25
|
|
|
24
26
|
// Types
|
|
25
27
|
export type {
|
|
@@ -42,6 +44,9 @@ export type { CrashReport } from './types/crash';
|
|
|
42
44
|
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
43
45
|
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
44
46
|
|
|
47
|
+
// Components
|
|
48
|
+
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
49
|
+
|
|
45
50
|
// Config
|
|
46
51
|
export { DEFAULT_CONFIG } from './core/config';
|
|
47
52
|
export type { ResolvedConfig } from './core/config';
|
|
@@ -7,13 +7,20 @@ export interface NativeFrameResult {
|
|
|
7
7
|
format: 'jpeg';
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
interface NativeCaptureResult {
|
|
11
|
+
image: string;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
format: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface NativeFullContentResult extends NativeCaptureResult {
|
|
18
|
+
isFullContent: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
interface NativeScreenCaptureModule {
|
|
11
|
-
captureFrame(): Promise<
|
|
12
|
-
|
|
13
|
-
width: number;
|
|
14
|
-
height: number;
|
|
15
|
-
format: string;
|
|
16
|
-
} | null>;
|
|
22
|
+
captureFrame(): Promise<NativeCaptureResult | null>;
|
|
23
|
+
captureFullContent(): Promise<NativeFullContentResult | null>;
|
|
17
24
|
isAvailable(): Promise<boolean>;
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -52,3 +59,48 @@ export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
|
|
|
52
59
|
return null;
|
|
53
60
|
}
|
|
54
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
65
|
+
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
66
|
+
*
|
|
67
|
+
* Falls back to a viewport capture if no ScrollView is found.
|
|
68
|
+
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
69
|
+
*
|
|
70
|
+
* @returns NativeFrameResult or null if capture failed / not available
|
|
71
|
+
*/
|
|
72
|
+
export interface FullContentResult extends NativeFrameResult {
|
|
73
|
+
/** True if the capture includes full scrollable content. False if viewport-only fallback. */
|
|
74
|
+
isFullContent: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Capture the FULL content of the primary ScrollView on screen,
|
|
79
|
+
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
80
|
+
*
|
|
81
|
+
* Falls back to a viewport capture if no ScrollView is found.
|
|
82
|
+
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
83
|
+
*
|
|
84
|
+
* Known limitation: FlatList/SectionList use RecyclerView which only
|
|
85
|
+
* renders visible items — full-content capture falls back to viewport.
|
|
86
|
+
*
|
|
87
|
+
* @returns FullContentResult with isFullContent flag, or null
|
|
88
|
+
*/
|
|
89
|
+
export async function captureFullContent(): Promise<FullContentResult | null> {
|
|
90
|
+
if (!NativeModule) return null;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = await NativeModule.captureFullContent();
|
|
94
|
+
if (!result || !result.image) return null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
image: result.image,
|
|
98
|
+
width: result.width,
|
|
99
|
+
height: result.height,
|
|
100
|
+
format: 'jpeg',
|
|
101
|
+
isFullContent: !!result.isFullContent,
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { captureFullContent, type FullContentResult } from './NativeScreenCapture';
|
|
2
|
+
|
|
3
|
+
/** 24 hours in milliseconds. */
|
|
4
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
/** Wait for screen to render before capturing. */
|
|
7
|
+
const RENDER_DELAY_MS = 800;
|
|
8
|
+
|
|
9
|
+
type UploadFn = (
|
|
10
|
+
screenName: string,
|
|
11
|
+
image: string,
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
isFullContent: boolean,
|
|
15
|
+
) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages full-content screen captures for heatmap backgrounds.
|
|
19
|
+
*
|
|
20
|
+
* - Captures the full ScrollView content on screen enter
|
|
21
|
+
* - Local cache with 24h TTL to avoid redundant captures
|
|
22
|
+
* - Async and non-blocking: never delays navigation or crashes the app
|
|
23
|
+
* - Independent of the session replay snapshot system
|
|
24
|
+
*/
|
|
25
|
+
export class ScreenCaptureManager {
|
|
26
|
+
private cache = new Map<string, number>();
|
|
27
|
+
private pending = new Set<string>();
|
|
28
|
+
private uploadFn: UploadFn;
|
|
29
|
+
private debug: boolean;
|
|
30
|
+
|
|
31
|
+
constructor(uploadFn: UploadFn, debug = false) {
|
|
32
|
+
this.uploadFn = uploadFn;
|
|
33
|
+
this.debug = debug;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Called when the user enters a screen. Schedules a full-content
|
|
38
|
+
* capture after a delay if no recent capture exists.
|
|
39
|
+
*
|
|
40
|
+
* Fire-and-forget: never awaited by the caller, never throws.
|
|
41
|
+
*/
|
|
42
|
+
onScreenEnter(screenName: string): void {
|
|
43
|
+
if (!this.shouldCapture(screenName)) return;
|
|
44
|
+
|
|
45
|
+
// Mark as pending to avoid duplicate captures from rapid navigation
|
|
46
|
+
this.pending.add(screenName);
|
|
47
|
+
|
|
48
|
+
// Delay to let the screen render completely (animations, data loading)
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
this.captureAndUpload(screenName).catch(() => {});
|
|
51
|
+
}, RENDER_DELAY_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a capture is needed for this screen.
|
|
56
|
+
*/
|
|
57
|
+
private shouldCapture(screenName: string): boolean {
|
|
58
|
+
// Already capturing this screen
|
|
59
|
+
if (this.pending.has(screenName)) return false;
|
|
60
|
+
|
|
61
|
+
// Check local cache TTL
|
|
62
|
+
const lastCapture = this.cache.get(screenName);
|
|
63
|
+
if (lastCapture && Date.now() - lastCapture < CACHE_TTL_MS) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async captureAndUpload(screenName: string): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const result = await captureFullContent();
|
|
73
|
+
if (!result) {
|
|
74
|
+
if (this.debug) {
|
|
75
|
+
console.log(`[Repliqo] Screen capture returned null for "${screenName}"`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.debug) {
|
|
81
|
+
console.log(
|
|
82
|
+
`[Repliqo] Screen captured: "${screenName}" ${result.width}x${result.height} ` +
|
|
83
|
+
`(fullContent=${result.isFullContent}, ${Math.round(result.image.length / 1024)}KB)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Upload to backend (fire-and-forget, errors are swallowed)
|
|
88
|
+
await this.uploadFn(
|
|
89
|
+
screenName,
|
|
90
|
+
result.image,
|
|
91
|
+
result.width,
|
|
92
|
+
result.height,
|
|
93
|
+
result.isFullContent,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Update local cache
|
|
97
|
+
this.cache.set(screenName, Date.now());
|
|
98
|
+
|
|
99
|
+
if (this.debug) {
|
|
100
|
+
console.log(`[Repliqo] Screen capture uploaded: "${screenName}"`);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (this.debug) {
|
|
104
|
+
console.warn(`[Repliqo] Screen capture failed for "${screenName}":`, err);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
this.pending.delete(screenName);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reset the cache (e.g. on session end). */
|
|
112
|
+
reset(): void {
|
|
113
|
+
this.cache.clear();
|
|
114
|
+
this.pending.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -222,4 +222,44 @@ export class ApiClient {
|
|
|
222
222
|
throw error;
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Upload a full-content screen capture for heatmap backgrounds.
|
|
228
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
229
|
+
*/
|
|
230
|
+
async uploadScreenCapture(
|
|
231
|
+
screenName: string,
|
|
232
|
+
image: string,
|
|
233
|
+
width: number,
|
|
234
|
+
height: number,
|
|
235
|
+
isFullContent: boolean,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
try {
|
|
238
|
+
const response = await fetch(
|
|
239
|
+
`${this.baseUrl}/api/screen-captures`,
|
|
240
|
+
{
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: this.getHeaders(),
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
screenName,
|
|
245
|
+
image,
|
|
246
|
+
width,
|
|
247
|
+
height,
|
|
248
|
+
isFullContent,
|
|
249
|
+
}),
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (!response.ok) {
|
|
254
|
+
const text = await response.text();
|
|
255
|
+
this.logger.warn(
|
|
256
|
+
'Screen capture upload failed:',
|
|
257
|
+
response.status,
|
|
258
|
+
text?.substring(0, 200),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.logger.warn('Error uploading screen capture:', error);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
225
265
|
}
|