@repliqo/sdk-react-native 0.3.6 → 0.3.8

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.
@@ -57,6 +57,10 @@ public class MultiWindowCapture {
57
57
  int screenHeight = decorView.getHeight();
58
58
 
59
59
  if (screenWidth <= 0 || screenHeight <= 0) {
60
+ // Happens during layout transitions / before first layout.
61
+ // Log so a persistent capture failure is diagnosable.
62
+ Log.w(TAG, "capture skipped: invalid decor dimensions "
63
+ + screenWidth + "x" + screenHeight);
60
64
  return null;
61
65
  }
62
66
 
@@ -77,38 +81,41 @@ public class MultiWindowCapture {
77
81
  return captureDecorView(decorView, outWidth, outHeight, scale);
78
82
  }
79
83
 
80
- // Create the composite bitmap
84
+ // Create the composite bitmap. Recycled in finally so it never
85
+ // leaks if compression (bitmapToBase64) or anything else throws.
81
86
  Bitmap composite = Bitmap.createBitmap(outWidth, outHeight,
82
87
  Bitmap.Config.ARGB_8888);
83
- Canvas canvas = new Canvas(composite);
84
- canvas.scale(scale, scale);
88
+ try {
89
+ Canvas canvas = new Canvas(composite);
90
+ canvas.scale(scale, scale);
85
91
 
86
- // Draw each visible root view in order (bottom to top)
87
- for (ViewInfo info : views) {
88
- if (info.view.getVisibility() != View.VISIBLE) continue;
89
- if (info.view.getWidth() <= 0 || info.view.getHeight() <= 0) continue;
92
+ // Draw each visible root view in order (bottom to top)
93
+ for (ViewInfo info : views) {
94
+ if (info.view.getVisibility() != View.VISIBLE) continue;
95
+ if (info.view.getWidth() <= 0 || info.view.getHeight() <= 0) continue;
90
96
 
91
- canvas.save();
97
+ canvas.save();
92
98
 
93
- // Apply the window's position offset
94
- canvas.translate(info.left, info.top);
99
+ // Apply the window's position offset
100
+ canvas.translate(info.left, info.top);
95
101
 
96
- try {
97
- info.view.draw(canvas);
98
- } catch (Exception e) {
99
- Log.w(TAG, "Failed to draw view: " + e.getMessage());
100
- }
102
+ try {
103
+ info.view.draw(canvas);
104
+ } catch (Exception e) {
105
+ Log.w(TAG, "Failed to draw view: " + e.getMessage());
106
+ }
101
107
 
102
- canvas.restore();
103
- }
104
-
105
- // Compress to JPEG
106
- String base64 = bitmapToBase64(composite);
107
- composite.recycle();
108
+ canvas.restore();
109
+ }
108
110
 
109
- if (base64 == null) return null;
111
+ // Compress to JPEG
112
+ String base64 = bitmapToBase64(composite);
113
+ if (base64 == null) return null;
110
114
 
111
- return new CaptureResult(base64, outWidth, outHeight);
115
+ return new CaptureResult(base64, outWidth, outHeight);
116
+ } finally {
117
+ composite.recycle();
118
+ }
112
119
 
113
120
  } catch (Exception e) {
114
121
  Log.w(TAG, "MultiWindowCapture failed: " + e.getMessage());
@@ -121,21 +128,23 @@ public class MultiWindowCapture {
121
128
  */
122
129
  private static CaptureResult captureDecorView(
123
130
  View decorView, int outWidth, int outHeight, float scale) {
131
+ Bitmap bitmap = null;
124
132
  try {
125
- Bitmap bitmap = Bitmap.createBitmap(outWidth, outHeight,
133
+ bitmap = Bitmap.createBitmap(outWidth, outHeight,
126
134
  Bitmap.Config.ARGB_8888);
127
135
  Canvas canvas = new Canvas(bitmap);
128
136
  canvas.scale(scale, scale);
129
137
  decorView.draw(canvas);
130
138
 
131
139
  String base64 = bitmapToBase64(bitmap);
132
- bitmap.recycle();
133
-
134
140
  if (base64 == null) return null;
135
141
  return new CaptureResult(base64, outWidth, outHeight);
136
142
  } catch (Exception e) {
137
143
  Log.w(TAG, "DecorView capture failed: " + e.getMessage());
138
144
  return null;
145
+ } finally {
146
+ // Always recycle, even if bitmapToBase64 / draw threw.
147
+ if (bitmap != null) bitmap.recycle();
139
148
  }
140
149
  }
141
150
 
@@ -197,7 +206,10 @@ public class MultiWindowCapture {
197
206
  ByteArrayOutputStream baos = new ByteArrayOutputStream();
198
207
  bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
199
208
  return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
200
- } catch (Exception e) {
209
+ } catch (Throwable e) {
210
+ // Catch Throwable so OutOfMemoryError (not an Exception) is also
211
+ // surfaced rather than silently producing a null capture.
212
+ Log.w(TAG, "JPEG compression failed: " + e.getMessage());
201
213
  return null;
202
214
  }
203
215
  }
@@ -12,7 +12,6 @@ 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;
16
15
  import com.facebook.react.bridge.WritableMap;
17
16
 
18
17
  /**
@@ -92,79 +91,4 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
92
91
  }
93
92
  });
94
93
  }
95
-
96
- /**
97
- * Capture the FULL content of the primary ScrollView on screen,
98
- * including content that is scrolled off-screen.
99
- *
100
- * Used for heatmap backgrounds. Falls back to a viewport capture
101
- * if no ScrollView is found.
102
- *
103
- * Returns: { image: base64, width: int, height: int, format: "jpeg" }
104
- */
105
- @ReactMethod
106
- public void captureFullContent(Promise promise) {
107
- Activity activity = getCurrentActivity();
108
- if (activity == null) {
109
- promise.resolve(null);
110
- return;
111
- }
112
-
113
- mainHandler.post(() -> {
114
- try {
115
- FullContentCapture.FullCaptureResult result =
116
- FullContentCapture.capture(activity);
117
-
118
- if (result == null) {
119
- promise.resolve(null);
120
- return;
121
- }
122
-
123
- WritableMap map = Arguments.createMap();
124
- map.putString("image", result.image);
125
- map.putInt("width", result.width);
126
- map.putInt("height", result.height);
127
- map.putString("format", "jpeg");
128
- map.putBoolean("isFullContent", result.isFullContent);
129
- promise.resolve(map);
130
- } catch (Exception e) {
131
- Log.w(TAG, "captureFullContent failed: " + e.getMessage());
132
- promise.resolve(null);
133
- }
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
- // Try PixelCopy (API 26+, captures from GPU framebuffer)
156
- WritableArray tiles = PixelCopyScan.scan(activity);
157
-
158
- // Fallback to View.draw scan
159
- if (tiles == null || tiles.size() == 0) {
160
- tiles = ScrollScanCapture.scan(activity);
161
- }
162
-
163
- promise.resolve(tiles);
164
- } catch (Exception e) {
165
- Log.w(TAG, "scanFullContent failed: " + e.getMessage());
166
- promise.resolve(null);
167
- }
168
- });
169
- }
170
94
  }
@@ -79,6 +79,10 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
79
79
  const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
80
80
  const innerRef = (0, react_1.useRef)(null);
81
81
  const isMounted = (0, react_1.useRef)(true);
82
+ // Last measured window-relative top Y of the ScrollView — passed to
83
+ // captureScrollTile so the backend can crop each tile to just the
84
+ // scroll viewport area (no fixed header/footer duplication).
85
+ const lastViewportTop = (0, react_1.useRef)(null);
82
86
  const scrollState = (0, react_1.useRef)(null);
83
87
  // Forward the inner ref to the parent's ref (if provided).
84
88
  // IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
@@ -112,6 +116,7 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
112
116
  // ignore those, the next onLayout/onScroll will re-measure.
113
117
  if (width <= 0 || height <= 0)
114
118
  return;
119
+ lastViewportTop.current = y;
115
120
  try {
116
121
  client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
117
122
  x, y, width, height,
@@ -166,7 +171,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
166
171
  lastCaptureY.current = state.offsetY;
167
172
  initialCaptured.current = true;
168
173
  try {
169
- client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight);
174
+ const windowHeight = react_native_1.Dimensions.get('window').height;
175
+ client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight, lastViewportTop.current ?? undefined, windowHeight);
170
176
  }
171
177
  catch { }
172
178
  };
@@ -85,16 +85,16 @@ export declare class AppAnalytics {
85
85
  * Capture the current viewport as a tile for collaborative screen
86
86
  * building. Called by RepliqoScrollView on scroll stops.
87
87
  *
88
- * The tile includes the scrollOffsetY so the backend knows where
89
- * in the full content this viewport belongs. Multiple users/sessions
90
- * contribute tiles until the full content is covered.
88
+ * The tile includes:
89
+ * - `scrollOffsetY`: where in the full content this viewport belongs
90
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
91
+ * top edge. Used by the backend to crop each tile to just the
92
+ * scrollable area, removing fixed headers/footers that would
93
+ * otherwise duplicate in the composite.
94
+ * - `windowHeight`: window height in logical px, for image-to-logical
95
+ * scale computation during backend cropping.
91
96
  */
92
- captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number): void;
93
- /**
94
- * Programmatically scroll-scan the current ScrollView and upload all
95
- * tiles to the backend. Invisible to the user (~300-500ms).
96
- */
97
- private autoScanScreen;
97
+ captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): void;
98
98
  onScreenEnter(screenName: string): void;
99
99
  onScreenExit(screenName: string): void;
100
100
  flush(): Promise<void>;
@@ -331,11 +331,16 @@ class AppAnalytics {
331
331
  * Capture the current viewport as a tile for collaborative screen
332
332
  * building. Called by RepliqoScrollView on scroll stops.
333
333
  *
334
- * The tile includes the scrollOffsetY so the backend knows where
335
- * in the full content this viewport belongs. Multiple users/sessions
336
- * contribute tiles until the full content is covered.
334
+ * The tile includes:
335
+ * - `scrollOffsetY`: where in the full content this viewport belongs
336
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
337
+ * top edge. Used by the backend to crop each tile to just the
338
+ * scrollable area, removing fixed headers/footers that would
339
+ * otherwise duplicate in the composite.
340
+ * - `windowHeight`: window height in logical px, for image-to-logical
341
+ * scale computation during backend cropping.
337
342
  */
338
- captureScrollTile(scrollOffsetY, viewportHeight, contentHeight) {
343
+ captureScrollTile(scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
339
344
  if (!this.sessionId || !this.currentScreen)
340
345
  return;
341
346
  if (viewportHeight <= 0)
@@ -350,38 +355,15 @@ class AppAnalytics {
350
355
  return;
351
356
  this.logger.log(`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
352
357
  `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
358
+ `top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
353
359
  `${Math.round(frame.image.length / 1024)}KB`);
354
- await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight);
360
+ await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight);
355
361
  }
356
362
  catch (err) {
357
363
  this.logger.warn('Scroll tile failed:', err);
358
364
  }
359
365
  })();
360
366
  }
361
- /**
362
- * Programmatically scroll-scan the current ScrollView and upload all
363
- * tiles to the backend. Invisible to the user (~300-500ms).
364
- */
365
- autoScanScreen(screenName) {
366
- (async () => {
367
- try {
368
- const tiles = await (0, NativeScreenCapture_1.scanFullContent)();
369
- if (!tiles || tiles.length === 0) {
370
- this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
371
- return;
372
- }
373
- this.logger.log(`Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
374
- `(content=${tiles[0].contentHeight}px)`);
375
- // Upload all tiles in parallel
376
- const sessionId = this.sessionId;
377
- await Promise.all(tiles.map((tile) => this.apiClient.uploadScreenTile(sessionId, screenName, tile.image, tile.width, tile.height, tile.scrollOffsetY, tile.viewportHeight, tile.contentHeight).catch(() => { })));
378
- this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
379
- }
380
- catch (err) {
381
- this.logger.warn('Auto-scan failed:', err);
382
- }
383
- })();
384
- }
385
367
  onScreenEnter(screenName) {
386
368
  this.currentScreen = screenName;
387
369
  this.currentScreenEnteredAt = new Date().toISOString();
@@ -390,14 +372,12 @@ class AppAnalytics {
390
372
  this.scrollViewRegistry.clear();
391
373
  this.errorTracker?.setCurrentScreen(screenName);
392
374
  this.snapshotCapture?.setCurrentScreen(screenName);
393
- // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
394
- // React Native does NOT render off-screen content to the GPU or view
395
- // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
396
- // tiles. Worse, those blank tiles poison the dedup cache and block
397
- // real tiles captured during natural user scroll.
398
- //
399
- // Full-content capture relies entirely on RepliqoScrollView capturing
400
- // viewport tiles as the user naturally scrolls through the content.
375
+ // NOTE: full-content capture is NOT done via programmatic scroll +
376
+ // native capture. React Native does NOT render off-screen content to
377
+ // the GPU or view tree, so any programmatic-scroll approach produces
378
+ // blank tiles. Full-content heatmap backgrounds are built entirely
379
+ // from viewport tiles captured by RepliqoScrollView as the user
380
+ // naturally scrolls through the content.
401
381
  this.logger.log('Screen entered:', screenName);
402
382
  }
403
383
  onScreenExit(screenName) {
package/dist/index.d.ts CHANGED
@@ -5,8 +5,7 @@ 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, captureFullContent, } from './snapshot/NativeScreenCapture';
9
- export type { FullContentResult } from './snapshot/NativeScreenCapture';
8
+ export { isNativeScreenCaptureAvailable, captureNativeFrame, } from './snapshot/NativeScreenCapture';
10
9
  export type { EventType, AnalyticsEvent, TouchEventData, NavigationEventData, ScreenVisit, DeviceInfo, SDKConfig, } from './types/events';
11
10
  export type { ScreenshotData, SnapshotPayload, } from './types/snapshot';
12
11
  export type { CrashReport } from './types/crash';
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.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;
3
+ exports.DEFAULT_CONFIG = exports.RepliqoScrollView = 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,7 +23,6 @@ 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
26
  // Components
28
27
  var RepliqoScrollView_1 = require("./components/RepliqoScrollView");
29
28
  Object.defineProperty(exports, "RepliqoScrollView", { enumerable: true, get: function () { return RepliqoScrollView_1.RepliqoScrollView; } });
@@ -12,50 +12,12 @@ export declare function isNativeScreenCaptureAvailable(): boolean;
12
12
  *
13
13
  * No permissions required. No dialog. No foreground service.
14
14
  *
15
- * @returns NativeFrameResult or null if capture failed / not available
16
- */
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.
15
+ * This is the ONLY native capture path. Full-content capture of
16
+ * off-screen ScrollView content is NOT possible natively (React Native
17
+ * doesn't render off-screen content to the GPU/view tree) full-content
18
+ * heatmap backgrounds are instead built collaboratively from viewport
19
+ * tiles captured as the user naturally scrolls (see RepliqoScrollView).
24
20
  *
25
21
  * @returns NativeFrameResult or null if capture failed / not available
26
22
  */
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>;
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>;
23
+ export declare function captureNativeFrame(): Promise<NativeFrameResult | null>;
@@ -2,8 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isNativeScreenCaptureAvailable = isNativeScreenCaptureAvailable;
4
4
  exports.captureNativeFrame = captureNativeFrame;
5
- exports.captureFullContent = captureFullContent;
6
- exports.scanFullContent = scanFullContent;
7
5
  const react_native_1 = require("react-native");
8
6
  const NativeModule = react_native_1.Platform.OS === 'android'
9
7
  ? react_native_1.NativeModules.ScreenCaptureModule ?? null
@@ -18,6 +16,12 @@ function isNativeScreenCaptureAvailable() {
18
16
  *
19
17
  * No permissions required. No dialog. No foreground service.
20
18
  *
19
+ * This is the ONLY native capture path. Full-content capture of
20
+ * off-screen ScrollView content is NOT possible natively (React Native
21
+ * doesn't render off-screen content to the GPU/view tree) — full-content
22
+ * heatmap backgrounds are instead built collaboratively from viewport
23
+ * tiles captured as the user naturally scrolls (see RepliqoScrollView).
24
+ *
21
25
  * @returns NativeFrameResult or null if capture failed / not available
22
26
  */
23
27
  async function captureNativeFrame() {
@@ -38,65 +42,3 @@ async function captureNativeFrame() {
38
42
  return null;
39
43
  }
40
44
  }
41
- /**
42
- * Capture the FULL content of the primary ScrollView on screen,
43
- * including content scrolled off-screen. Used for heatmap backgrounds.
44
- *
45
- * Falls back to a viewport capture if no ScrollView is found.
46
- * Max height: 5000px. JPEG quality: 60%. Width: ~500px.
47
- *
48
- * Known limitation: FlatList/SectionList use RecyclerView which only
49
- * renders visible items — full-content capture falls back to viewport.
50
- *
51
- * @returns FullContentResult with isFullContent flag, or null
52
- */
53
- async function captureFullContent() {
54
- if (!NativeModule)
55
- return null;
56
- try {
57
- const result = await NativeModule.captureFullContent();
58
- if (!result || !result.image)
59
- return null;
60
- return {
61
- image: result.image,
62
- width: result.width,
63
- height: result.height,
64
- format: 'jpeg',
65
- isFullContent: !!result.isFullContent,
66
- };
67
- }
68
- catch {
69
- return null;
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
- }
@@ -32,7 +32,14 @@ export declare class ApiClient {
32
32
  */
33
33
  /**
34
34
  * Upload a scroll tile for collaborative screen building.
35
+ *
36
+ * @param viewportTop Y position (window logical px) of the ScrollView's
37
+ * top edge. Used by the backend to crop each tile to just the
38
+ * scrollable area, removing fixed headers/footers that would
39
+ * otherwise duplicate in the composite.
40
+ * @param windowHeight Window height (logical px) at capture time.
41
+ * Used by the backend to compute image-to-logical scale for cropping.
35
42
  */
36
- uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number): Promise<void>;
43
+ uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): Promise<void>;
37
44
  uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
38
45
  }
@@ -158,22 +158,36 @@ class ApiClient {
158
158
  */
159
159
  /**
160
160
  * Upload a scroll tile for collaborative screen building.
161
+ *
162
+ * @param viewportTop Y position (window logical px) of the ScrollView's
163
+ * top edge. Used by the backend to crop each tile to just the
164
+ * scrollable area, removing fixed headers/footers that would
165
+ * otherwise duplicate in the composite.
166
+ * @param windowHeight Window height (logical px) at capture time.
167
+ * Used by the backend to compute image-to-logical scale for cropping.
161
168
  */
162
- async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight) {
169
+ async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
163
170
  try {
171
+ const body = {
172
+ sessionId,
173
+ screenName,
174
+ image,
175
+ width,
176
+ height,
177
+ scrollOffsetY: Math.round(scrollOffsetY),
178
+ viewportHeight: Math.round(viewportHeight),
179
+ contentHeight: Math.round(contentHeight),
180
+ };
181
+ if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
182
+ body.viewportTop = Math.round(viewportTop);
183
+ }
184
+ if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
185
+ body.windowHeight = Math.round(windowHeight);
186
+ }
164
187
  const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
165
188
  method: 'POST',
166
189
  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
- }),
190
+ body: JSON.stringify(body),
177
191
  });
178
192
  if (!response.ok) {
179
193
  const text = await response.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",
@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useEffect } from 'react';
2
2
  import {
3
3
  ScrollView,
4
4
  Keyboard,
5
+ Dimensions,
5
6
  type ScrollViewProps,
6
7
  type NativeSyntheticEvent,
7
8
  type NativeScrollEvent,
@@ -54,6 +55,10 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
54
55
  const scrollViewId = useRef<string>(nextScrollViewId());
55
56
  const innerRef = useRef<ScrollView | null>(null);
56
57
  const isMounted = useRef(true);
58
+ // Last measured window-relative top Y of the ScrollView — passed to
59
+ // captureScrollTile so the backend can crop each tile to just the
60
+ // scroll viewport area (no fixed header/footer duplication).
61
+ const lastViewportTop = useRef<number | null>(null);
57
62
  const scrollState = useRef<{
58
63
  offsetY: number;
59
64
  viewportHeight: number;
@@ -88,6 +93,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
88
93
  // RN sometimes returns 0/0 dims before the view is positioned —
89
94
  // ignore those, the next onLayout/onScroll will re-measure.
90
95
  if (width <= 0 || height <= 0) return;
96
+ lastViewportTop.current = y;
91
97
  try {
92
98
  AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
93
99
  x, y, width, height,
@@ -141,10 +147,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
141
147
  initialCaptured.current = true;
142
148
 
143
149
  try {
150
+ const windowHeight = Dimensions.get('window').height;
144
151
  AppAnalytics.getInstance().captureScrollTile(
145
152
  state.offsetY,
146
153
  state.viewportHeight,
147
154
  state.contentHeight,
155
+ lastViewportTop.current ?? undefined,
156
+ windowHeight,
148
157
  );
149
158
  } catch {}
150
159
  };