@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.
- package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +39 -27
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +0 -76
- package/dist/components/RepliqoScrollView.js +7 -1
- package/dist/core/client.d.ts +9 -9
- package/dist/core/client.js +17 -37
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/snapshot/NativeScreenCapture.d.ts +6 -44
- package/dist/snapshot/NativeScreenCapture.js +6 -64
- package/dist/transport/api.client.d.ts +8 -1
- package/dist/transport/api.client.js +25 -11
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +9 -0
- package/src/core/client.ts +20 -54
- package/src/index.ts +0 -2
- package/src/snapshot/NativeScreenCapture.ts +6 -101
- package/src/transport/api.client.ts +27 -10
- package/android/src/main/java/com/repliqo/screencapture/FullContentCapture.java +0 -273
- package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +0 -279
- package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +0 -248
- package/src/snapshot/ScreenCaptureManager.ts +0 -156
|
@@ -1,279 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
package com.repliqo.screencapture;
|
|
2
|
-
|
|
3
|
-
import android.app.Activity;
|
|
4
|
-
import android.graphics.Bitmap;
|
|
5
|
-
import android.graphics.Canvas;
|
|
6
|
-
import android.util.Base64;
|
|
7
|
-
import android.util.Log;
|
|
8
|
-
import android.view.View;
|
|
9
|
-
import android.view.ViewGroup;
|
|
10
|
-
import android.widget.ScrollView;
|
|
11
|
-
|
|
12
|
-
import com.facebook.react.bridge.Arguments;
|
|
13
|
-
import com.facebook.react.bridge.WritableArray;
|
|
14
|
-
import com.facebook.react.bridge.WritableMap;
|
|
15
|
-
|
|
16
|
-
import java.io.ByteArrayOutputStream;
|
|
17
|
-
import java.lang.reflect.Field;
|
|
18
|
-
import java.util.ArrayList;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Performs a fast programmatic scroll-scan of the main ScrollView to
|
|
22
|
-
* capture the ENTIRE content as a series of viewport tiles.
|
|
23
|
-
*
|
|
24
|
-
* The scan is invisible to the user:
|
|
25
|
-
* 1. Save current scroll position
|
|
26
|
-
* 2. scrollTo(0, 0) — instant, no animation
|
|
27
|
-
* 3. Capture viewport at Y=0
|
|
28
|
-
* 4. scrollTo(0, viewportHeight) — instant
|
|
29
|
-
* 5. Capture viewport at Y=viewportHeight
|
|
30
|
-
* 6. ... repeat until end of content
|
|
31
|
-
* 7. scrollTo(0, originalY) — restore position
|
|
32
|
-
*
|
|
33
|
-
* Total time: ~300-500ms for a typical screen. The user sees a brief
|
|
34
|
-
* flicker at most (usually nothing because it happens within one frame).
|
|
35
|
-
*
|
|
36
|
-
* Must run on the UI thread.
|
|
37
|
-
*/
|
|
38
|
-
public class ScrollScanCapture {
|
|
39
|
-
private static final String TAG = "RepliqoCapture";
|
|
40
|
-
private static final int TARGET_WIDTH = 390;
|
|
41
|
-
private static final int JPEG_QUALITY = 40;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Scan the ScrollView and return all viewport tiles.
|
|
45
|
-
*
|
|
46
|
-
* @return WritableArray of tile objects, or null on failure
|
|
47
|
-
*/
|
|
48
|
-
public static WritableArray scan(Activity activity) {
|
|
49
|
-
if (activity == null || activity.isFinishing()) return null;
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
View scrollView = findVerticalScrollView(activity);
|
|
53
|
-
if (scrollView == null) {
|
|
54
|
-
Log.d(TAG, "No ScrollView found for scan");
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
View contentView = getContentChild(scrollView);
|
|
59
|
-
if (contentView == null) return null;
|
|
60
|
-
|
|
61
|
-
int viewportWidth = scrollView.getWidth();
|
|
62
|
-
int viewportHeight = scrollView.getHeight();
|
|
63
|
-
int contentHeight = contentView.getHeight();
|
|
64
|
-
|
|
65
|
-
if (viewportWidth <= 0 || viewportHeight <= 0 || contentHeight <= 0) {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// If content fits in viewport, just capture once
|
|
70
|
-
if (contentHeight <= viewportHeight) {
|
|
71
|
-
WritableArray tiles = Arguments.createArray();
|
|
72
|
-
WritableMap tile = captureSingleTile(
|
|
73
|
-
scrollView, 0, viewportWidth, viewportHeight, contentHeight
|
|
74
|
-
);
|
|
75
|
-
if (tile != null) tiles.pushMap(tile);
|
|
76
|
-
return tiles;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Save original scroll position
|
|
80
|
-
int originalScrollY = scrollView.getScrollY();
|
|
81
|
-
|
|
82
|
-
WritableArray tiles = Arguments.createArray();
|
|
83
|
-
|
|
84
|
-
// Scan from top to bottom in viewport-sized steps
|
|
85
|
-
// Use 80% overlap to avoid gaps from scroll imprecision
|
|
86
|
-
int step = (int) (viewportHeight * 0.8);
|
|
87
|
-
int y = 0;
|
|
88
|
-
|
|
89
|
-
while (y < contentHeight) {
|
|
90
|
-
// Scroll to position — instant, no animation
|
|
91
|
-
scrollView.scrollTo(0, y);
|
|
92
|
-
|
|
93
|
-
// Force a layout pass so the views at this position render
|
|
94
|
-
scrollView.invalidate();
|
|
95
|
-
contentView.invalidate();
|
|
96
|
-
|
|
97
|
-
// Capture the viewport at this position
|
|
98
|
-
WritableMap tile = captureSingleTile(
|
|
99
|
-
scrollView, y, viewportWidth, viewportHeight, contentHeight
|
|
100
|
-
);
|
|
101
|
-
if (tile != null) {
|
|
102
|
-
tiles.pushMap(tile);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
y += step;
|
|
106
|
-
|
|
107
|
-
// Last step: capture the very bottom
|
|
108
|
-
if (y >= contentHeight && y - step < contentHeight - viewportHeight) {
|
|
109
|
-
y = contentHeight - viewportHeight;
|
|
110
|
-
scrollView.scrollTo(0, y);
|
|
111
|
-
scrollView.invalidate();
|
|
112
|
-
contentView.invalidate();
|
|
113
|
-
|
|
114
|
-
WritableMap lastTile = captureSingleTile(
|
|
115
|
-
scrollView, y, viewportWidth, viewportHeight, contentHeight
|
|
116
|
-
);
|
|
117
|
-
if (lastTile != null) {
|
|
118
|
-
tiles.pushMap(lastTile);
|
|
119
|
-
}
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Restore original scroll position — instant
|
|
125
|
-
scrollView.scrollTo(0, originalScrollY);
|
|
126
|
-
|
|
127
|
-
Log.d(TAG, "Scroll scan complete: " + tiles.size() +
|
|
128
|
-
" tiles, content=" + contentHeight + "px");
|
|
129
|
-
|
|
130
|
-
return tiles;
|
|
131
|
-
|
|
132
|
-
} catch (Exception e) {
|
|
133
|
-
Log.w(TAG, "ScrollScanCapture failed: " + e.getMessage());
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private static WritableMap captureSingleTile(
|
|
139
|
-
View scrollView, int scrollY,
|
|
140
|
-
int viewportWidth, int viewportHeight, int contentHeight) {
|
|
141
|
-
try {
|
|
142
|
-
// Scale to target width
|
|
143
|
-
float scale = (float) TARGET_WIDTH / viewportWidth;
|
|
144
|
-
int outWidth = TARGET_WIDTH;
|
|
145
|
-
int outHeight = Math.round(viewportHeight * scale);
|
|
146
|
-
|
|
147
|
-
if (outWidth <= 0 || outHeight <= 0) return null;
|
|
148
|
-
|
|
149
|
-
Bitmap bitmap = Bitmap.createBitmap(outWidth, outHeight,
|
|
150
|
-
Bitmap.Config.ARGB_8888);
|
|
151
|
-
Canvas canvas = new Canvas(bitmap);
|
|
152
|
-
canvas.scale(scale, scale);
|
|
153
|
-
|
|
154
|
-
// Translate canvas to account for scroll position within the view
|
|
155
|
-
canvas.translate(0, -scrollView.getScrollY());
|
|
156
|
-
|
|
157
|
-
// Draw the scrollview's content
|
|
158
|
-
View content = getContentChild(scrollView);
|
|
159
|
-
if (content != null) {
|
|
160
|
-
content.draw(canvas);
|
|
161
|
-
} else {
|
|
162
|
-
scrollView.draw(canvas);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Compress to JPEG
|
|
166
|
-
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
167
|
-
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
|
|
168
|
-
String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
|
|
169
|
-
bitmap.recycle();
|
|
170
|
-
|
|
171
|
-
WritableMap tile = Arguments.createMap();
|
|
172
|
-
tile.putString("image", base64);
|
|
173
|
-
tile.putInt("width", outWidth);
|
|
174
|
-
tile.putInt("height", outHeight);
|
|
175
|
-
tile.putInt("scrollOffsetY", scrollY);
|
|
176
|
-
tile.putInt("viewportHeight", viewportHeight);
|
|
177
|
-
tile.putInt("contentHeight", contentHeight);
|
|
178
|
-
tile.putString("format", "jpeg");
|
|
179
|
-
return tile;
|
|
180
|
-
|
|
181
|
-
} catch (Exception e) {
|
|
182
|
-
Log.w(TAG, "Tile capture at Y=" + scrollY + " failed: " + e.getMessage());
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
@SuppressWarnings("unchecked")
|
|
188
|
-
private static View findVerticalScrollView(Activity activity) {
|
|
189
|
-
View decorView = activity.getWindow().getDecorView();
|
|
190
|
-
View found = findInTree(decorView);
|
|
191
|
-
if (found != null) return found;
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
|
|
195
|
-
Object wmg = wmgClass.getMethod("getInstance").invoke(null);
|
|
196
|
-
Field viewsField = wmgClass.getDeclaredField("mViews");
|
|
197
|
-
viewsField.setAccessible(true);
|
|
198
|
-
ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
|
|
199
|
-
if (views != null) {
|
|
200
|
-
for (View root : views) {
|
|
201
|
-
if (root == decorView) continue;
|
|
202
|
-
found = findInTree(root);
|
|
203
|
-
if (found != null) return found;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
} catch (Exception e) { }
|
|
207
|
-
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private static View findInTree(View view) {
|
|
212
|
-
if (view == null) return null;
|
|
213
|
-
if (isVerticalScroll(view) && view instanceof ViewGroup
|
|
214
|
-
&& ((ViewGroup) view).getChildCount() > 0) {
|
|
215
|
-
return view;
|
|
216
|
-
}
|
|
217
|
-
if (view instanceof ViewGroup) {
|
|
218
|
-
ViewGroup group = (ViewGroup) view;
|
|
219
|
-
for (int i = 0; i < group.getChildCount(); i++) {
|
|
220
|
-
View found = findInTree(group.getChildAt(i));
|
|
221
|
-
if (found != null) return found;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private static boolean isVerticalScroll(View view) {
|
|
228
|
-
if (view instanceof ScrollView) return true;
|
|
229
|
-
Class<?> clazz = view.getClass();
|
|
230
|
-
while (clazz != null && clazz != View.class) {
|
|
231
|
-
String name = clazz.getName();
|
|
232
|
-
if (name.contains("Horizontal")) return false;
|
|
233
|
-
if (name.contains("NestedScrollView") ||
|
|
234
|
-
name.contains("ReactScrollView") ||
|
|
235
|
-
name.endsWith("ScrollView")) return true;
|
|
236
|
-
clazz = clazz.getSuperclass();
|
|
237
|
-
}
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private static View getContentChild(View scrollView) {
|
|
242
|
-
if (scrollView instanceof ViewGroup) {
|
|
243
|
-
ViewGroup group = (ViewGroup) scrollView;
|
|
244
|
-
if (group.getChildCount() > 0) return group.getChildAt(0);
|
|
245
|
-
}
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { captureFullContent } 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
|
-
/** Max base64 size to upload (3MB base64 ≈ ~2.2MB binary). */
|
|
10
|
-
const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
export type UploadFn = (
|
|
13
|
-
screenName: string,
|
|
14
|
-
image: string,
|
|
15
|
-
width: number,
|
|
16
|
-
height: number,
|
|
17
|
-
isFullContent: boolean,
|
|
18
|
-
) => Promise<void>;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Manages full-content screen captures for heatmap backgrounds.
|
|
22
|
-
*
|
|
23
|
-
* - Captures the full ScrollView content on screen enter
|
|
24
|
-
* - Local cache with 24h TTL to avoid redundant captures
|
|
25
|
-
* - Cancels pending captures when navigating away (prevents cross-screen bugs)
|
|
26
|
-
* - Async and non-blocking: never delays navigation or crashes the app
|
|
27
|
-
*/
|
|
28
|
-
export class ScreenCaptureManager {
|
|
29
|
-
private cache = new Map<string, number>();
|
|
30
|
-
private pending = new Set<string>();
|
|
31
|
-
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
-
private currentScreen: string | null = null;
|
|
33
|
-
private uploadFn: UploadFn;
|
|
34
|
-
private debug: boolean;
|
|
35
|
-
|
|
36
|
-
constructor(uploadFn: UploadFn, debug = false) {
|
|
37
|
-
this.uploadFn = uploadFn;
|
|
38
|
-
this.debug = debug;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Called when the user enters a screen. Schedules a full-content
|
|
43
|
-
* capture after a delay if no recent capture exists.
|
|
44
|
-
*
|
|
45
|
-
* Cancels any pending capture from a previous screen to prevent
|
|
46
|
-
* cross-screen data corruption (capturing screen B but labeling as A).
|
|
47
|
-
*/
|
|
48
|
-
onScreenEnter(screenName: string): void {
|
|
49
|
-
// Cancel any pending capture from the previous screen
|
|
50
|
-
this.cancelPending();
|
|
51
|
-
this.currentScreen = screenName;
|
|
52
|
-
|
|
53
|
-
if (!this.shouldCapture(screenName)) return;
|
|
54
|
-
|
|
55
|
-
this.pending.add(screenName);
|
|
56
|
-
|
|
57
|
-
this.pendingTimer = setTimeout(() => {
|
|
58
|
-
this.pendingTimer = null;
|
|
59
|
-
// Verify the user is STILL on this screen before capturing
|
|
60
|
-
if (this.currentScreen !== screenName) {
|
|
61
|
-
this.pending.delete(screenName);
|
|
62
|
-
if (this.debug) {
|
|
63
|
-
console.log(
|
|
64
|
-
`[Repliqo] Screen capture skipped: navigated away from "${screenName}"`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
this.captureAndUpload(screenName).catch(() => {});
|
|
70
|
-
}, RENDER_DELAY_MS);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
private shouldCapture(screenName: string): boolean {
|
|
74
|
-
if (this.pending.has(screenName)) return false;
|
|
75
|
-
|
|
76
|
-
const lastCapture = this.cache.get(screenName);
|
|
77
|
-
if (lastCapture && Date.now() - lastCapture < CACHE_TTL_MS) {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private async captureAndUpload(screenName: string): Promise<void> {
|
|
85
|
-
try {
|
|
86
|
-
const result = await captureFullContent();
|
|
87
|
-
if (!result) {
|
|
88
|
-
if (this.debug) {
|
|
89
|
-
console.log(`[Repliqo] Screen capture returned null for "${screenName}"`);
|
|
90
|
-
}
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Guard: check we're still on the same screen after the async capture
|
|
95
|
-
if (this.currentScreen !== screenName) {
|
|
96
|
-
if (this.debug) {
|
|
97
|
-
console.log(
|
|
98
|
-
`[Repliqo] Screen capture discarded: user left "${screenName}" during capture`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Guard: reject oversized images to prevent OOM during upload
|
|
105
|
-
if (result.image.length > MAX_IMAGE_SIZE) {
|
|
106
|
-
if (this.debug) {
|
|
107
|
-
console.warn(
|
|
108
|
-
`[Repliqo] Screen capture too large (${Math.round(result.image.length / 1024)}KB), skipping "${screenName}"`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (this.debug) {
|
|
115
|
-
console.log(
|
|
116
|
-
`[Repliqo] Screen captured: "${screenName}" ${result.width}x${result.height} ` +
|
|
117
|
-
`(full=${result.isFullContent}, ${Math.round(result.image.length / 1024)}KB)`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
await this.uploadFn(
|
|
122
|
-
screenName,
|
|
123
|
-
result.image,
|
|
124
|
-
result.width,
|
|
125
|
-
result.height,
|
|
126
|
-
result.isFullContent,
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
this.cache.set(screenName, Date.now());
|
|
130
|
-
|
|
131
|
-
if (this.debug) {
|
|
132
|
-
console.log(`[Repliqo] Screen capture uploaded: "${screenName}"`);
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
if (this.debug) {
|
|
136
|
-
console.warn(`[Repliqo] Screen capture failed for "${screenName}":`, err);
|
|
137
|
-
}
|
|
138
|
-
} finally {
|
|
139
|
-
this.pending.delete(screenName);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private cancelPending(): void {
|
|
144
|
-
if (this.pendingTimer !== null) {
|
|
145
|
-
clearTimeout(this.pendingTimer);
|
|
146
|
-
this.pendingTimer = null;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
reset(): void {
|
|
151
|
-
this.cancelPending();
|
|
152
|
-
this.cache.clear();
|
|
153
|
-
this.pending.clear();
|
|
154
|
-
this.currentScreen = null;
|
|
155
|
-
}
|
|
156
|
-
}
|