@repliqo/sdk-react-native 0.3.2 → 0.3.3

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.
@@ -0,0 +1,279 @@
1
+ package com.repliqo.screencapture;
2
+
3
+ import android.app.Activity;
4
+ import android.graphics.Bitmap;
5
+ import android.graphics.Rect;
6
+ import android.os.Build;
7
+ import android.os.Handler;
8
+ import android.os.HandlerThread;
9
+ import android.util.Base64;
10
+ import android.util.Log;
11
+ import android.view.PixelCopy;
12
+ import android.view.View;
13
+ import android.view.ViewGroup;
14
+ import android.view.Window;
15
+ import android.widget.ScrollView;
16
+
17
+ import com.facebook.react.bridge.Arguments;
18
+ import com.facebook.react.bridge.WritableArray;
19
+ import com.facebook.react.bridge.WritableMap;
20
+
21
+ import java.io.ByteArrayOutputStream;
22
+ import java.lang.reflect.Field;
23
+ import java.util.ArrayList;
24
+ import java.util.concurrent.CountDownLatch;
25
+ import java.util.concurrent.TimeUnit;
26
+
27
+ /**
28
+ * Captures the FULL content of a ScrollView using PixelCopy API +
29
+ * programmatic scrolling.
30
+ *
31
+ * Unlike View.draw() which only renders what the view tree has laid out,
32
+ * PixelCopy captures directly from the GPU's Surface — it sees exactly
33
+ * what's on screen, including content that React Native has rendered
34
+ * but that View.draw() misses.
35
+ *
36
+ * Flow:
37
+ * 1. Find the main ScrollView
38
+ * 2. Save scroll position
39
+ * 3. For each viewport-sized step:
40
+ * a. scrollTo(y) — instant
41
+ * b. Wait 2 frames for RN to render
42
+ * c. PixelCopy the Window surface (captures what's actually visible)
43
+ * d. Crop to the ScrollView's bounds on screen
44
+ * 4. Restore scroll position
45
+ * 5. Return all tiles
46
+ *
47
+ * Requires API 26+ (Android 8.0). Falls back to MultiWindowCapture on older.
48
+ */
49
+ public class PixelCopyScan {
50
+ private static final String TAG = "RepliqoCapture";
51
+ private static final int TARGET_WIDTH = 390;
52
+ private static final int JPEG_QUALITY = 45;
53
+ private static final int FRAME_WAIT_MS = 50; // Wait for RN to render
54
+
55
+ /**
56
+ * Scan the ScrollView content using PixelCopy + programmatic scroll.
57
+ * Must be called on the UI thread. Blocks for ~100ms per tile.
58
+ *
59
+ * @return WritableArray of tiles, or null on failure
60
+ */
61
+ public static WritableArray scan(Activity activity) {
62
+ if (activity == null || activity.isFinishing()) return null;
63
+
64
+ // PixelCopy requires API 26+
65
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
66
+ Log.d(TAG, "PixelCopy not available (API < 26), using fallback");
67
+ return null;
68
+ }
69
+
70
+ try {
71
+ View scrollView = findVerticalScrollView(activity);
72
+ if (scrollView == null) {
73
+ Log.d(TAG, "No ScrollView found for PixelCopy scan");
74
+ return null;
75
+ }
76
+
77
+ View contentView = getContentChild(scrollView);
78
+ if (contentView == null) return null;
79
+
80
+ int viewportHeight = scrollView.getHeight();
81
+ int contentHeight = contentView.getHeight();
82
+ int viewportWidth = scrollView.getWidth();
83
+
84
+ if (viewportWidth <= 0 || viewportHeight <= 0 || contentHeight <= 0) {
85
+ return null;
86
+ }
87
+
88
+ // Get ScrollView position on screen (for cropping)
89
+ int[] scrollViewLoc = new int[2];
90
+ scrollView.getLocationOnScreen(scrollViewLoc);
91
+ Rect scrollViewBounds = new Rect(
92
+ scrollViewLoc[0], scrollViewLoc[1],
93
+ scrollViewLoc[0] + viewportWidth,
94
+ scrollViewLoc[1] + viewportHeight
95
+ );
96
+
97
+ Window window = activity.getWindow();
98
+ int originalScrollY = scrollView.getScrollY();
99
+
100
+ WritableArray tiles = Arguments.createArray();
101
+ int step = (int) (viewportHeight * 0.75); // 25% overlap
102
+
103
+ // Create a handler thread for PixelCopy callbacks
104
+ HandlerThread handlerThread = new HandlerThread("PixelCopyThread");
105
+ handlerThread.start();
106
+ Handler handler = new Handler(handlerThread.getLooper());
107
+
108
+ try {
109
+ int y = 0;
110
+ while (y < contentHeight) {
111
+ int scrollTarget = Math.min(y, contentHeight - viewportHeight);
112
+ if (scrollTarget < 0) scrollTarget = 0;
113
+
114
+ // Scroll to position
115
+ scrollView.scrollTo(0, scrollTarget);
116
+
117
+ // Wait for React Native to render the new content
118
+ Thread.sleep(FRAME_WAIT_MS);
119
+
120
+ // Capture using PixelCopy
121
+ WritableMap tile = captureWithPixelCopy(
122
+ window, scrollViewBounds, handler,
123
+ scrollTarget, viewportWidth, viewportHeight, contentHeight
124
+ );
125
+
126
+ if (tile != null) {
127
+ tiles.pushMap(tile);
128
+ }
129
+
130
+ if (y >= contentHeight - viewportHeight) break;
131
+ y += step;
132
+ if (y > contentHeight - viewportHeight) {
133
+ y = contentHeight - viewportHeight;
134
+ }
135
+ }
136
+ } finally {
137
+ // Restore scroll position
138
+ scrollView.scrollTo(0, originalScrollY);
139
+ handlerThread.quitSafely();
140
+ }
141
+
142
+ Log.d(TAG, "PixelCopy scan: " + tiles.size() + " tiles, " +
143
+ "content=" + contentHeight + "px");
144
+
145
+ return tiles;
146
+
147
+ } catch (Exception e) {
148
+ Log.w(TAG, "PixelCopyScan failed: " + e.getMessage());
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Capture a tile using PixelCopy, cropping to the ScrollView's bounds.
155
+ */
156
+ private static WritableMap captureWithPixelCopy(
157
+ Window window, Rect cropBounds, Handler handler,
158
+ int scrollY, int viewportWidth, int viewportHeight, int contentHeight) {
159
+
160
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null;
161
+
162
+ try {
163
+ // Create bitmap for the crop area
164
+ Bitmap bitmap = Bitmap.createBitmap(
165
+ cropBounds.width(), cropBounds.height(),
166
+ Bitmap.Config.ARGB_8888
167
+ );
168
+
169
+ CountDownLatch latch = new CountDownLatch(1);
170
+ final int[] resultCode = new int[]{-1};
171
+
172
+ PixelCopy.request(window, cropBounds, bitmap, (result) -> {
173
+ resultCode[0] = result;
174
+ latch.countDown();
175
+ }, handler);
176
+
177
+ // Wait for PixelCopy to complete (max 1 second)
178
+ if (!latch.await(1, TimeUnit.SECONDS)) {
179
+ bitmap.recycle();
180
+ return null;
181
+ }
182
+
183
+ if (resultCode[0] != PixelCopy.SUCCESS) {
184
+ bitmap.recycle();
185
+ return null;
186
+ }
187
+
188
+ // Scale to target width
189
+ float scale = (float) TARGET_WIDTH / viewportWidth;
190
+ int outWidth = TARGET_WIDTH;
191
+ int outHeight = Math.round(viewportHeight * scale);
192
+
193
+ Bitmap scaled = Bitmap.createScaledBitmap(bitmap, outWidth, outHeight, true);
194
+ bitmap.recycle();
195
+
196
+ // Compress to JPEG
197
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
198
+ scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
199
+ String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
200
+ scaled.recycle();
201
+
202
+ WritableMap tile = Arguments.createMap();
203
+ tile.putString("image", base64);
204
+ tile.putInt("width", outWidth);
205
+ tile.putInt("height", outHeight);
206
+ tile.putInt("scrollOffsetY", scrollY);
207
+ tile.putInt("viewportHeight", viewportHeight);
208
+ tile.putInt("contentHeight", contentHeight);
209
+ tile.putString("format", "jpeg");
210
+ return tile;
211
+
212
+ } catch (Exception e) {
213
+ Log.w(TAG, "PixelCopy at Y=" + scrollY + " failed: " + e.getMessage());
214
+ return null;
215
+ }
216
+ }
217
+
218
+ // --- View finders (same as ScrollScanCapture) ---
219
+
220
+ @SuppressWarnings("unchecked")
221
+ private static View findVerticalScrollView(Activity activity) {
222
+ View decorView = activity.getWindow().getDecorView();
223
+ View found = findInTree(decorView);
224
+ if (found != null) return found;
225
+ try {
226
+ Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
227
+ Object wmg = wmgClass.getMethod("getInstance").invoke(null);
228
+ Field viewsField = wmgClass.getDeclaredField("mViews");
229
+ viewsField.setAccessible(true);
230
+ ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
231
+ if (views != null) {
232
+ for (View root : views) {
233
+ if (root == decorView) continue;
234
+ found = findInTree(root);
235
+ if (found != null) return found;
236
+ }
237
+ }
238
+ } catch (Exception e) { }
239
+ return null;
240
+ }
241
+
242
+ private static View findInTree(View view) {
243
+ if (view == null) return null;
244
+ if (isVerticalScroll(view) && view instanceof ViewGroup
245
+ && ((ViewGroup) view).getChildCount() > 0) {
246
+ return view;
247
+ }
248
+ if (view instanceof ViewGroup) {
249
+ ViewGroup group = (ViewGroup) view;
250
+ for (int i = 0; i < group.getChildCount(); i++) {
251
+ View found = findInTree(group.getChildAt(i));
252
+ if (found != null) return found;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+
258
+ private static boolean isVerticalScroll(View view) {
259
+ if (view instanceof ScrollView) return true;
260
+ Class<?> clazz = view.getClass();
261
+ while (clazz != null && clazz != View.class) {
262
+ String name = clazz.getName();
263
+ if (name.contains("Horizontal")) return false;
264
+ if (name.contains("NestedScrollView") ||
265
+ name.contains("ReactScrollView") ||
266
+ name.endsWith("ScrollView")) return true;
267
+ clazz = clazz.getSuperclass();
268
+ }
269
+ return false;
270
+ }
271
+
272
+ private static View getContentChild(View scrollView) {
273
+ if (scrollView instanceof ViewGroup) {
274
+ ViewGroup group = (ViewGroup) scrollView;
275
+ if (group.getChildCount() > 0) return group.getChildAt(0);
276
+ }
277
+ return null;
278
+ }
279
+ }
@@ -152,7 +152,14 @@ public class ScreenCaptureModule extends ReactContextBaseJavaModule {
152
152
 
153
153
  mainHandler.post(() -> {
154
154
  try {
155
- WritableArray tiles = ScrollScanCapture.scan(activity);
155
+ // Try PixelCopy (API 26+, captures from GPU framebuffer)
156
+ WritableArray tiles = PixelCopyScan.scan(activity);
157
+
158
+ // Fallback to View.draw scan
159
+ if (tiles == null || tiles.size() == 0) {
160
+ tiles = ScrollScanCapture.scan(activity);
161
+ }
162
+
156
163
  promise.resolve(tiles);
157
164
  } catch (Exception e) {
158
165
  Log.w(TAG, "scanFullContent failed: " + e.getMessage());
@@ -284,8 +284,16 @@ class AppAnalytics {
284
284
  this.scrollOffsetY = 0;
285
285
  this.errorTracker?.setCurrentScreen(screenName);
286
286
  this.snapshotCapture?.setCurrentScreen(screenName);
287
- // Tile capture is handled by RepliqoScrollView (on scroll stops,
288
- // finger lift, momentum end, and initial layout).
287
+ // Auto-scan with PixelCopy: captures entire ScrollView content
288
+ // by scrolling programmatically + PixelCopy from GPU framebuffer.
289
+ // Fires 2s after screen enter to let content fully render.
290
+ if (this.config.enableHeatmapCapture) {
291
+ setTimeout(() => {
292
+ if (this.currentScreen !== screenName || !this.sessionId)
293
+ return;
294
+ this.autoScanScreen(screenName);
295
+ }, 2000);
296
+ }
289
297
  this.logger.log('Screen entered:', screenName);
290
298
  }
291
299
  onScreenExit(screenName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",
@@ -416,8 +416,15 @@ export class AppAnalytics {
416
416
  this.scrollOffsetY = 0;
417
417
  this.errorTracker?.setCurrentScreen(screenName);
418
418
  this.snapshotCapture?.setCurrentScreen(screenName);
419
- // Tile capture is handled by RepliqoScrollView (on scroll stops,
420
- // finger lift, momentum end, and initial layout).
419
+ // Auto-scan with PixelCopy: captures entire ScrollView content
420
+ // by scrolling programmatically + PixelCopy from GPU framebuffer.
421
+ // Fires 2s after screen enter to let content fully render.
422
+ if (this.config.enableHeatmapCapture) {
423
+ setTimeout(() => {
424
+ if (this.currentScreen !== screenName || !this.sessionId) return;
425
+ this.autoScanScreen(screenName);
426
+ }, 2000);
427
+ }
421
428
 
422
429
  this.logger.log('Screen entered:', screenName);
423
430
  }