@repliqo/sdk-react-native 0.2.3 → 0.3.1

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.
@@ -12,6 +12,7 @@ import com.facebook.react.bridge.Promise;
12
12
  import com.facebook.react.bridge.ReactApplicationContext;
13
13
  import com.facebook.react.bridge.ReactContextBaseJavaModule;
14
14
  import com.facebook.react.bridge.ReactMethod;
15
+ import com.facebook.react.bridge.WritableArray;
15
16
  import com.facebook.react.bridge.WritableMap;
16
17
 
17
18
  /**
@@ -132,4 +133,31 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
132
133
  }
133
134
  });
134
135
  }
136
+
137
+ /**
138
+ * Perform a fast programmatic scroll-scan of the main ScrollView.
139
+ * Captures the entire content as viewport tiles in ~300-500ms.
140
+ * Invisible to the user (scroll is restored to original position).
141
+ *
142
+ * Returns: Array<{ image, width, height, scrollOffsetY, viewportHeight,
143
+ * contentHeight, format }>
144
+ */
145
+ @ReactMethod
146
+ public void scanFullContent(Promise promise) {
147
+ Activity activity = getCurrentActivity();
148
+ if (activity == null) {
149
+ promise.resolve(null);
150
+ return;
151
+ }
152
+
153
+ mainHandler.post(() -> {
154
+ try {
155
+ WritableArray tiles = ScrollScanCapture.scan(activity);
156
+ promise.resolve(tiles);
157
+ } catch (Exception e) {
158
+ Log.w(TAG, "scanFullContent failed: " + e.getMessage());
159
+ promise.resolve(null);
160
+ }
161
+ });
162
+ }
135
163
  }
@@ -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
+ }
@@ -40,6 +40,11 @@ export declare class AppAnalytics {
40
40
  * contribute tiles until the full content is covered.
41
41
  */
42
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;
43
48
  onScreenEnter(screenName: string): void;
44
49
  onScreenExit(screenName: string): void;
45
50
  flush(): Promise<void>;
@@ -254,14 +254,45 @@ class AppAnalytics {
254
254
  }
255
255
  })();
256
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
+ }
257
281
  onScreenEnter(screenName) {
258
282
  this.currentScreen = screenName;
259
283
  this.currentScreenEnteredAt = new Date().toISOString();
260
284
  this.scrollOffsetY = 0;
261
285
  this.errorTracker?.setCurrentScreen(screenName);
262
286
  this.snapshotCapture?.setCurrentScreen(screenName);
263
- // Initial tile capture (Y=0) is handled by RepliqoScrollView's
264
- // onLayout event, which provides real viewport dimensions.
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
+ }
265
296
  this.logger.log('Screen entered:', screenName);
266
297
  }
267
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
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",
@@ -12,7 +12,7 @@ import { Logger } from './logger';
12
12
  import { ApiClient } from '../transport/api.client';
13
13
  import { BatchQueue } from '../transport/batch-queue';
14
14
  import { ErrorTracker } from '../trackers/error.tracker';
15
- import { captureNativeFrame } from '../snapshot/NativeScreenCapture';
15
+ import { captureNativeFrame, scanFullContent } from '../snapshot/NativeScreenCapture';
16
16
 
17
17
  export class AppAnalytics {
18
18
  private static instance: AppAnalytics | null = null;
@@ -368,14 +368,63 @@ export class AppAnalytics {
368
368
  })();
369
369
  }
370
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
+
371
413
  onScreenEnter(screenName: string): void {
372
414
  this.currentScreen = screenName;
373
415
  this.currentScreenEnteredAt = new Date().toISOString();
374
416
  this.scrollOffsetY = 0;
375
417
  this.errorTracker?.setCurrentScreen(screenName);
376
418
  this.snapshotCapture?.setCurrentScreen(screenName);
377
- // Initial tile capture (Y=0) is handled by RepliqoScrollView's
378
- // onLayout event, which provides real viewport dimensions.
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
+
379
428
  this.logger.log('Screen entered:', screenName);
380
429
  }
381
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
+ }