@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
package/src/core/client.ts
CHANGED
|
@@ -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
|
|
15
|
+
import { captureNativeFrame } from '../snapshot/NativeScreenCapture';
|
|
16
16
|
|
|
17
17
|
export class AppAnalytics {
|
|
18
18
|
private static instance: AppAnalytics | null = null;
|
|
@@ -446,14 +446,21 @@ export class AppAnalytics {
|
|
|
446
446
|
* Capture the current viewport as a tile for collaborative screen
|
|
447
447
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
448
448
|
*
|
|
449
|
-
* The tile includes
|
|
450
|
-
* in the full content this viewport belongs
|
|
451
|
-
*
|
|
449
|
+
* The tile includes:
|
|
450
|
+
* - `scrollOffsetY`: where in the full content this viewport belongs
|
|
451
|
+
* - `viewportTop`: Y position (window logical px) of the ScrollView's
|
|
452
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
453
|
+
* scrollable area, removing fixed headers/footers that would
|
|
454
|
+
* otherwise duplicate in the composite.
|
|
455
|
+
* - `windowHeight`: window height in logical px, for image-to-logical
|
|
456
|
+
* scale computation during backend cropping.
|
|
452
457
|
*/
|
|
453
458
|
captureScrollTile(
|
|
454
459
|
scrollOffsetY: number,
|
|
455
460
|
viewportHeight: number,
|
|
456
461
|
contentHeight: number,
|
|
462
|
+
viewportTop?: number,
|
|
463
|
+
windowHeight?: number,
|
|
457
464
|
): void {
|
|
458
465
|
if (!this.sessionId || !this.currentScreen) return;
|
|
459
466
|
if (viewportHeight <= 0) return; // No real dimensions yet
|
|
@@ -470,6 +477,7 @@ export class AppAnalytics {
|
|
|
470
477
|
this.logger.log(
|
|
471
478
|
`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
|
|
472
479
|
`vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
|
|
480
|
+
`top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
|
|
473
481
|
`${Math.round(frame.image.length / 1024)}KB`,
|
|
474
482
|
);
|
|
475
483
|
|
|
@@ -482,6 +490,8 @@ export class AppAnalytics {
|
|
|
482
490
|
scrollOffsetY,
|
|
483
491
|
viewportHeight,
|
|
484
492
|
contentHeight,
|
|
493
|
+
viewportTop,
|
|
494
|
+
windowHeight,
|
|
485
495
|
);
|
|
486
496
|
} catch (err) {
|
|
487
497
|
this.logger.warn('Scroll tile failed:', err);
|
|
@@ -489,48 +499,6 @@ export class AppAnalytics {
|
|
|
489
499
|
})();
|
|
490
500
|
}
|
|
491
501
|
|
|
492
|
-
/**
|
|
493
|
-
* Programmatically scroll-scan the current ScrollView and upload all
|
|
494
|
-
* tiles to the backend. Invisible to the user (~300-500ms).
|
|
495
|
-
*/
|
|
496
|
-
private autoScanScreen(screenName: string): void {
|
|
497
|
-
(async () => {
|
|
498
|
-
try {
|
|
499
|
-
const tiles = await scanFullContent();
|
|
500
|
-
if (!tiles || tiles.length === 0) {
|
|
501
|
-
this.logger.log(`Auto-scan: no ScrollView found for "${screenName}"`);
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
this.logger.log(
|
|
506
|
-
`Auto-scan: "${screenName}" captured ${tiles.length} tiles ` +
|
|
507
|
-
`(content=${tiles[0].contentHeight}px)`,
|
|
508
|
-
);
|
|
509
|
-
|
|
510
|
-
// Upload all tiles in parallel
|
|
511
|
-
const sessionId = this.sessionId!;
|
|
512
|
-
await Promise.all(
|
|
513
|
-
tiles.map((tile) =>
|
|
514
|
-
this.apiClient.uploadScreenTile(
|
|
515
|
-
sessionId,
|
|
516
|
-
screenName,
|
|
517
|
-
tile.image,
|
|
518
|
-
tile.width,
|
|
519
|
-
tile.height,
|
|
520
|
-
tile.scrollOffsetY,
|
|
521
|
-
tile.viewportHeight,
|
|
522
|
-
tile.contentHeight,
|
|
523
|
-
).catch(() => {}),
|
|
524
|
-
),
|
|
525
|
-
);
|
|
526
|
-
|
|
527
|
-
this.logger.log(`Auto-scan: "${screenName}" tiles uploaded`);
|
|
528
|
-
} catch (err) {
|
|
529
|
-
this.logger.warn('Auto-scan failed:', err);
|
|
530
|
-
}
|
|
531
|
-
})();
|
|
532
|
-
}
|
|
533
|
-
|
|
534
502
|
onScreenEnter(screenName: string): void {
|
|
535
503
|
this.currentScreen = screenName;
|
|
536
504
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
@@ -539,14 +507,12 @@ export class AppAnalytics {
|
|
|
539
507
|
this.scrollViewRegistry.clear();
|
|
540
508
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
541
509
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
542
|
-
// NOTE:
|
|
543
|
-
// React Native does NOT render off-screen content to
|
|
544
|
-
// tree, so programmatic
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
// Full-content capture relies entirely on RepliqoScrollView capturing
|
|
549
|
-
// viewport tiles as the user naturally scrolls through the content.
|
|
510
|
+
// NOTE: full-content capture is NOT done via programmatic scroll +
|
|
511
|
+
// native capture. React Native does NOT render off-screen content to
|
|
512
|
+
// the GPU or view tree, so any programmatic-scroll approach produces
|
|
513
|
+
// blank tiles. Full-content heatmap backgrounds are built entirely
|
|
514
|
+
// from viewport tiles captured by RepliqoScrollView as the user
|
|
515
|
+
// naturally scrolls through the content.
|
|
550
516
|
|
|
551
517
|
this.logger.log('Screen entered:', screenName);
|
|
552
518
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,9 +19,7 @@ export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
|
19
19
|
export {
|
|
20
20
|
isNativeScreenCaptureAvailable,
|
|
21
21
|
captureNativeFrame,
|
|
22
|
-
captureFullContent,
|
|
23
22
|
} from './snapshot/NativeScreenCapture';
|
|
24
|
-
export type { FullContentResult } from './snapshot/NativeScreenCapture';
|
|
25
23
|
|
|
26
24
|
// Types
|
|
27
25
|
export type {
|
|
@@ -14,24 +14,8 @@ interface NativeCaptureResult {
|
|
|
14
14
|
format: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
interface NativeFullContentResult extends NativeCaptureResult {
|
|
18
|
-
isFullContent: boolean;
|
|
19
|
-
}
|
|
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
|
-
|
|
31
17
|
interface NativeScreenCaptureModule {
|
|
32
18
|
captureFrame(): Promise<NativeCaptureResult | null>;
|
|
33
|
-
captureFullContent(): Promise<NativeFullContentResult | null>;
|
|
34
|
-
scanFullContent(): Promise<NativeTile[] | null>;
|
|
35
19
|
isAvailable(): Promise<boolean>;
|
|
36
20
|
}
|
|
37
21
|
|
|
@@ -51,6 +35,12 @@ export function isNativeScreenCaptureAvailable(): boolean {
|
|
|
51
35
|
*
|
|
52
36
|
* No permissions required. No dialog. No foreground service.
|
|
53
37
|
*
|
|
38
|
+
* This is the ONLY native capture path. Full-content capture of
|
|
39
|
+
* off-screen ScrollView content is NOT possible natively (React Native
|
|
40
|
+
* doesn't render off-screen content to the GPU/view tree) — full-content
|
|
41
|
+
* heatmap backgrounds are instead built collaboratively from viewport
|
|
42
|
+
* tiles captured as the user naturally scrolls (see RepliqoScrollView).
|
|
43
|
+
*
|
|
54
44
|
* @returns NativeFrameResult or null if capture failed / not available
|
|
55
45
|
*/
|
|
56
46
|
export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
|
|
@@ -70,88 +60,3 @@ export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
|
|
|
70
60
|
return null;
|
|
71
61
|
}
|
|
72
62
|
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Capture the FULL content of the primary ScrollView on screen,
|
|
76
|
-
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
77
|
-
*
|
|
78
|
-
* Falls back to a viewport capture if no ScrollView is found.
|
|
79
|
-
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
80
|
-
*
|
|
81
|
-
* @returns NativeFrameResult or null if capture failed / not available
|
|
82
|
-
*/
|
|
83
|
-
export interface FullContentResult extends NativeFrameResult {
|
|
84
|
-
/** True if the capture includes full scrollable content. False if viewport-only fallback. */
|
|
85
|
-
isFullContent: boolean;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Capture the FULL content of the primary ScrollView on screen,
|
|
90
|
-
* including content scrolled off-screen. Used for heatmap backgrounds.
|
|
91
|
-
*
|
|
92
|
-
* Falls back to a viewport capture if no ScrollView is found.
|
|
93
|
-
* Max height: 5000px. JPEG quality: 60%. Width: ~500px.
|
|
94
|
-
*
|
|
95
|
-
* Known limitation: FlatList/SectionList use RecyclerView which only
|
|
96
|
-
* renders visible items — full-content capture falls back to viewport.
|
|
97
|
-
*
|
|
98
|
-
* @returns FullContentResult with isFullContent flag, or null
|
|
99
|
-
*/
|
|
100
|
-
export async function captureFullContent(): Promise<FullContentResult | null> {
|
|
101
|
-
if (!NativeModule) return null;
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
const result = await NativeModule.captureFullContent();
|
|
105
|
-
if (!result || !result.image) return null;
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
image: result.image,
|
|
109
|
-
width: result.width,
|
|
110
|
-
height: result.height,
|
|
111
|
-
format: 'jpeg',
|
|
112
|
-
isFullContent: !!result.isFullContent,
|
|
113
|
-
};
|
|
114
|
-
} catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
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
|
-
}
|
|
@@ -229,6 +229,13 @@ export class ApiClient {
|
|
|
229
229
|
*/
|
|
230
230
|
/**
|
|
231
231
|
* Upload a scroll tile for collaborative screen building.
|
|
232
|
+
*
|
|
233
|
+
* @param viewportTop Y position (window logical px) of the ScrollView's
|
|
234
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
235
|
+
* scrollable area, removing fixed headers/footers that would
|
|
236
|
+
* otherwise duplicate in the composite.
|
|
237
|
+
* @param windowHeight Window height (logical px) at capture time.
|
|
238
|
+
* Used by the backend to compute image-to-logical scale for cropping.
|
|
232
239
|
*/
|
|
233
240
|
async uploadScreenTile(
|
|
234
241
|
sessionId: string,
|
|
@@ -239,23 +246,33 @@ export class ApiClient {
|
|
|
239
246
|
scrollOffsetY: number,
|
|
240
247
|
viewportHeight: number,
|
|
241
248
|
contentHeight: number,
|
|
249
|
+
viewportTop?: number,
|
|
250
|
+
windowHeight?: number,
|
|
242
251
|
): Promise<void> {
|
|
243
252
|
try {
|
|
253
|
+
const body: Record<string, unknown> = {
|
|
254
|
+
sessionId,
|
|
255
|
+
screenName,
|
|
256
|
+
image,
|
|
257
|
+
width,
|
|
258
|
+
height,
|
|
259
|
+
scrollOffsetY: Math.round(scrollOffsetY),
|
|
260
|
+
viewportHeight: Math.round(viewportHeight),
|
|
261
|
+
contentHeight: Math.round(contentHeight),
|
|
262
|
+
};
|
|
263
|
+
if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
|
|
264
|
+
body.viewportTop = Math.round(viewportTop);
|
|
265
|
+
}
|
|
266
|
+
if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
|
|
267
|
+
body.windowHeight = Math.round(windowHeight);
|
|
268
|
+
}
|
|
269
|
+
|
|
244
270
|
const response = await fetch(
|
|
245
271
|
`${this.baseUrl}/api/screen-tiles`,
|
|
246
272
|
{
|
|
247
273
|
method: 'POST',
|
|
248
274
|
headers: this.getHeaders(),
|
|
249
|
-
body: JSON.stringify(
|
|
250
|
-
sessionId,
|
|
251
|
-
screenName,
|
|
252
|
-
image,
|
|
253
|
-
width,
|
|
254
|
-
height,
|
|
255
|
-
scrollOffsetY: Math.round(scrollOffsetY),
|
|
256
|
-
viewportHeight: Math.round(viewportHeight),
|
|
257
|
-
contentHeight: Math.round(contentHeight),
|
|
258
|
-
}),
|
|
275
|
+
body: JSON.stringify(body),
|
|
259
276
|
},
|
|
260
277
|
);
|
|
261
278
|
|
|
@@ -1,273 +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 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
|
-
// Force the content view to measure and layout at its full
|
|
107
|
-
// height. React Native's ScrollView optimization may skip
|
|
108
|
-
// laying out off-screen children, causing draw() to only
|
|
109
|
-
// render the visible portion. This forces a full layout pass.
|
|
110
|
-
int widthSpec = View.MeasureSpec.makeMeasureSpec(
|
|
111
|
-
contentWidth, View.MeasureSpec.EXACTLY);
|
|
112
|
-
int heightSpec = View.MeasureSpec.makeMeasureSpec(
|
|
113
|
-
captureHeight, View.MeasureSpec.EXACTLY);
|
|
114
|
-
contentView.measure(widthSpec, heightSpec);
|
|
115
|
-
contentView.layout(0, 0, contentWidth, captureHeight);
|
|
116
|
-
|
|
117
|
-
// Create bitmap — wrapped in OOM catch
|
|
118
|
-
Bitmap bitmap;
|
|
119
|
-
try {
|
|
120
|
-
bitmap = Bitmap.createBitmap(outWidth, outHeight,
|
|
121
|
-
Bitmap.Config.ARGB_8888);
|
|
122
|
-
} catch (OutOfMemoryError oom) {
|
|
123
|
-
Log.w(TAG, "OOM creating bitmap " + outWidth + "x" + outHeight +
|
|
124
|
-
", falling back to viewport");
|
|
125
|
-
return viewportFallback(activity);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
Canvas canvas = new Canvas(bitmap);
|
|
129
|
-
canvas.scale(scale, scale);
|
|
130
|
-
|
|
131
|
-
// Draw the full content
|
|
132
|
-
contentView.draw(canvas);
|
|
133
|
-
|
|
134
|
-
// Restore the original layout so the UI isn't affected.
|
|
135
|
-
// requestLayout triggers an async re-layout on the next frame.
|
|
136
|
-
contentView.requestLayout();
|
|
137
|
-
|
|
138
|
-
// Compress to JPEG
|
|
139
|
-
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
140
|
-
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos);
|
|
141
|
-
String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
|
|
142
|
-
bitmap.recycle();
|
|
143
|
-
|
|
144
|
-
Log.d(TAG, "Full content captured: " + outWidth + "x" + outHeight +
|
|
145
|
-
" (content: " + contentWidth + "x" + contentHeight +
|
|
146
|
-
", viewport: " + contentWidth + "x" + viewportHeight + ")");
|
|
147
|
-
|
|
148
|
-
return new FullCaptureResult(base64, outWidth, outHeight, true);
|
|
149
|
-
|
|
150
|
-
} catch (Exception e) {
|
|
151
|
-
Log.w(TAG, "FullContentCapture failed: " + e.getMessage());
|
|
152
|
-
return viewportFallback(activity);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Fallback: capture just the visible viewport via MultiWindowCapture.
|
|
158
|
-
*/
|
|
159
|
-
private static FullCaptureResult viewportFallback(Activity activity) {
|
|
160
|
-
MultiWindowCapture.CaptureResult vp = MultiWindowCapture.capture(activity);
|
|
161
|
-
if (vp == null) return null;
|
|
162
|
-
return new FullCaptureResult(vp.image, vp.width, vp.height, false);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Find the first VERTICAL ScrollView in the view hierarchy.
|
|
167
|
-
* Searches all root views (including modals).
|
|
168
|
-
*
|
|
169
|
-
* Matches by walking the class hierarchy for known scroll classes:
|
|
170
|
-
* - android.widget.ScrollView
|
|
171
|
-
* - androidx.core.widget.NestedScrollView
|
|
172
|
-
* - com.facebook.react.views.scroll.ReactScrollView (old arch)
|
|
173
|
-
* - Any class with "ScrollView" in the name (catches Fabric variants)
|
|
174
|
-
*
|
|
175
|
-
* Explicitly SKIPS HorizontalScrollView and classes containing
|
|
176
|
-
* "Horizontal" to avoid capturing carousels.
|
|
177
|
-
*/
|
|
178
|
-
private static View findVerticalScrollView(Activity activity) {
|
|
179
|
-
View decorView = activity.getWindow().getDecorView();
|
|
180
|
-
View found = findVerticalScrollInTree(decorView);
|
|
181
|
-
if (found != null) return found;
|
|
182
|
-
|
|
183
|
-
// Check other windows (modals)
|
|
184
|
-
try {
|
|
185
|
-
Class<?> wmgClass = Class.forName("android.view.WindowManagerGlobal");
|
|
186
|
-
Object wmg = wmgClass.getMethod("getInstance").invoke(null);
|
|
187
|
-
Field viewsField = wmgClass.getDeclaredField("mViews");
|
|
188
|
-
viewsField.setAccessible(true);
|
|
189
|
-
@SuppressWarnings("unchecked")
|
|
190
|
-
ArrayList<View> views = (ArrayList<View>) viewsField.get(wmg);
|
|
191
|
-
if (views != null) {
|
|
192
|
-
for (View root : views) {
|
|
193
|
-
if (root == decorView) continue;
|
|
194
|
-
found = findVerticalScrollInTree(root);
|
|
195
|
-
if (found != null) return found;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
} catch (Exception e) {
|
|
199
|
-
// Reflection failed
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* DFS for a vertical scroll view in the tree.
|
|
207
|
-
*/
|
|
208
|
-
private static View findVerticalScrollInTree(View view) {
|
|
209
|
-
if (view == null) return null;
|
|
210
|
-
|
|
211
|
-
if (isVerticalScrollView(view)) {
|
|
212
|
-
if (view instanceof ViewGroup && ((ViewGroup) view).getChildCount() > 0) {
|
|
213
|
-
return view;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (view instanceof ViewGroup) {
|
|
218
|
-
ViewGroup group = (ViewGroup) view;
|
|
219
|
-
for (int i = 0; i < group.getChildCount(); i++) {
|
|
220
|
-
View found = findVerticalScrollInTree(group.getChildAt(i));
|
|
221
|
-
if (found != null) return found;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Check if a view is a vertical scroll view.
|
|
230
|
-
* Walks the class hierarchy to catch subclasses.
|
|
231
|
-
*/
|
|
232
|
-
private static boolean isVerticalScrollView(View view) {
|
|
233
|
-
// Direct instanceof for standard Android ScrollView
|
|
234
|
-
if (view instanceof ScrollView) return true;
|
|
235
|
-
|
|
236
|
-
// Walk class hierarchy for name-based matching
|
|
237
|
-
Class<?> clazz = view.getClass();
|
|
238
|
-
while (clazz != null && clazz != View.class) {
|
|
239
|
-
String name = clazz.getName();
|
|
240
|
-
|
|
241
|
-
// Skip horizontal variants
|
|
242
|
-
if (name.contains("Horizontal")) return false;
|
|
243
|
-
|
|
244
|
-
// Match known vertical scroll classes:
|
|
245
|
-
// - androidx.core.widget.NestedScrollView
|
|
246
|
-
// - com.facebook.react.views.scroll.ReactScrollView (old arch)
|
|
247
|
-
// - com.facebook.react.views.scroll.ReactScrollViewManager$... (Fabric)
|
|
248
|
-
// - Any other class ending in "ScrollView" that isn't horizontal
|
|
249
|
-
if (name.contains("NestedScrollView") ||
|
|
250
|
-
name.contains("ReactScrollView") ||
|
|
251
|
-
(name.endsWith("ScrollView") && !name.contains("Horizontal"))) {
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
clazz = clazz.getSuperclass();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return false;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Get the content child of a ScrollView.
|
|
263
|
-
*/
|
|
264
|
-
private static View getContentChild(View scrollView) {
|
|
265
|
-
if (scrollView instanceof ViewGroup) {
|
|
266
|
-
ViewGroup group = (ViewGroup) scrollView;
|
|
267
|
-
if (group.getChildCount() > 0) {
|
|
268
|
-
return group.getChildAt(0);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
}
|