@repliqo/sdk-react-native 0.3.8 → 0.4.0
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/INTEGRATION_GUIDE.md +1384 -1312
- package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -242
- package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -94
- package/dist/components/RepliqoFlatList.d.ts +5 -0
- package/dist/components/RepliqoFlatList.js +39 -0
- package/dist/components/RepliqoScrollView.d.ts +3 -0
- package/dist/components/RepliqoScrollView.js +16 -265
- package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
- package/dist/components/useRepliqoScrollTracking.js +304 -0
- package/dist/core/client.d.ts +52 -0
- package/dist/core/client.js +304 -16
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/storage.d.ts +29 -0
- package/dist/core/storage.js +33 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/snapshot/capture.d.ts +7 -0
- package/dist/snapshot/capture.js +17 -5
- package/dist/trackers/error.tracker.d.ts +25 -0
- package/dist/trackers/error.tracker.js +114 -37
- package/dist/trackers/navigation.tracker.js +11 -0
- package/dist/transport/api.client.d.ts +34 -1
- package/dist/transport/api.client.js +102 -19
- package/dist/transport/batch-queue.d.ts +53 -1
- package/dist/transport/batch-queue.js +126 -19
- package/dist/types/events.d.ts +12 -0
- package/package.json +68 -64
- package/src/components/RepliqoFlatList.tsx +64 -0
- package/src/components/RepliqoScrollView.tsx +53 -302
- package/src/components/useRepliqoScrollTracking.ts +366 -0
- package/src/core/client.ts +953 -628
- package/src/core/config.ts +38 -30
- package/src/core/storage.ts +51 -0
- package/src/index.ts +54 -50
- package/src/snapshot/NativeScreenCapture.ts +62 -62
- package/src/snapshot/capture.ts +154 -142
- package/src/trackers/error.tracker.ts +211 -136
- package/src/trackers/navigation.tracker.ts +107 -98
- package/src/transport/api.client.ts +430 -327
- package/src/transport/batch-queue.ts +212 -95
- package/src/types/events.ts +72 -60
package/src/core/config.ts
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
|
-
import { SDKConfig } from '../types/events';
|
|
2
|
-
|
|
3
|
-
export const REPLIQO_API_URL = 'https://api-repliqo.odmservice.site';
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_CONFIG: Required<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
1
|
+
import { SDKConfig } from '../types/events';
|
|
2
|
+
|
|
3
|
+
export const REPLIQO_API_URL = 'https://api-repliqo.odmservice.site';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG: Required<
|
|
6
|
+
Omit<SDKConfig, 'apiKey' | 'appId' | 'storage'>
|
|
7
|
+
> = {
|
|
8
|
+
batchSize: 20,
|
|
9
|
+
flushInterval: 30000,
|
|
10
|
+
enableTouchTracking: true,
|
|
11
|
+
enableNavigationTracking: true,
|
|
12
|
+
enableSnapshots: true,
|
|
13
|
+
// 0.5 fps. JPEG screenshots at 390px/40% quality are ~15-30 KB each,
|
|
14
|
+
// so 2 s gives a good quality/bandwidth tradeoff for MVP session replay.
|
|
15
|
+
snapshotInterval: 2000,
|
|
16
|
+
// Cap sessions at ~30 minutes of continuous capture (900 frames).
|
|
17
|
+
maxSnapshotsPerSession: 900,
|
|
18
|
+
// Smaller batches than events - JPEGs are heavier.
|
|
19
|
+
snapshotBatchSize: 8,
|
|
20
|
+
snapshotFlushInterval: 15000,
|
|
21
|
+
// ~32 * 30KB ≈ 1 MB cap on in-memory buffer if the backend is down.
|
|
22
|
+
snapshotMaxBufferSize: 32,
|
|
23
|
+
enableHeatmapCapture: true,
|
|
24
|
+
enableCrashTracking: true,
|
|
25
|
+
debug: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ResolvedConfig = SDKConfig & typeof DEFAULT_CONFIG;
|
|
29
|
+
|
|
30
|
+
export function resolveConfig(config: SDKConfig): ResolvedConfig {
|
|
31
|
+
// Strip keys whose value is explicitly `undefined` — otherwise
|
|
32
|
+
// `{ enableSnapshots: maybeUndefinedVar }` would silently override the
|
|
33
|
+
// default `true` with `undefined` and disable the feature.
|
|
34
|
+
const cleaned = Object.fromEntries(
|
|
35
|
+
Object.entries(config).filter(([, v]) => v !== undefined),
|
|
36
|
+
) as SDKConfig;
|
|
37
|
+
return { ...DEFAULT_CONFIG, ...cleaned };
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal async key-value storage contract, compatible with
|
|
3
|
+
* @react-native-async-storage/async-storage (and trivially mockable).
|
|
4
|
+
*
|
|
5
|
+
* Used to persist analytics data across app kills:
|
|
6
|
+
* - buffered events that couldn't be flushed before backgrounding
|
|
7
|
+
* - crash reports (a fatal crash kills the app before the report's
|
|
8
|
+
* network request can complete — persistence is the only way those
|
|
9
|
+
* reports survive to the next launch)
|
|
10
|
+
*/
|
|
11
|
+
export interface PersistentStorage {
|
|
12
|
+
getItem(key: string): Promise<string | null>;
|
|
13
|
+
setItem(key: string, value: string): Promise<void>;
|
|
14
|
+
removeItem(key: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Dynamic optional require — AsyncStorage is an optional peer. */
|
|
18
|
+
declare const require: any;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Try to auto-load @react-native-async-storage/async-storage. Returns null
|
|
22
|
+
* when the host app doesn't have it installed — persistence is then
|
|
23
|
+
* disabled gracefully (in-memory only, pre-existing behavior).
|
|
24
|
+
*/
|
|
25
|
+
export function tryLoadAsyncStorage(): PersistentStorage | null {
|
|
26
|
+
try {
|
|
27
|
+
const mod = require('@react-native-async-storage/async-storage');
|
|
28
|
+
const storage = mod?.default ?? mod;
|
|
29
|
+
if (
|
|
30
|
+
storage &&
|
|
31
|
+
typeof storage.getItem === 'function' &&
|
|
32
|
+
typeof storage.setItem === 'function' &&
|
|
33
|
+
typeof storage.removeItem === 'function'
|
|
34
|
+
) {
|
|
35
|
+
return storage as PersistentStorage;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Not installed — fine.
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Storage keys namespaced to avoid colliding with the host app's data. */
|
|
44
|
+
export const STORAGE_KEYS = {
|
|
45
|
+
pendingEvents: '@repliqo/pending_events',
|
|
46
|
+
pendingCrashes: '@repliqo/pending_crashes',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/** Caps so a long-offline device can't grow storage unboundedly. */
|
|
50
|
+
export const MAX_PERSISTED_EVENTS = 500;
|
|
51
|
+
export const MAX_PERSISTED_CRASHES = 20;
|
package/src/index.ts
CHANGED
|
@@ -1,50 +1,54 @@
|
|
|
1
|
-
// Main class
|
|
2
|
-
export { AppAnalytics } from './core/client';
|
|
3
|
-
|
|
4
|
-
// Trackers
|
|
5
|
-
export { TouchTracker } from './trackers/touch.tracker';
|
|
6
|
-
export {
|
|
7
|
-
useNavigationTracker,
|
|
8
|
-
trackScreenChange,
|
|
9
|
-
} from './trackers/navigation.tracker';
|
|
10
|
-
export {
|
|
11
|
-
trackScreenEnter,
|
|
12
|
-
trackScreenExit,
|
|
13
|
-
} from './trackers/screen.tracker';
|
|
14
|
-
export { ErrorTracker } from './trackers/error.tracker';
|
|
15
|
-
|
|
16
|
-
// Snapshot
|
|
17
|
-
export { SnapshotCapture } from './snapshot/capture';
|
|
18
|
-
export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
19
|
-
export {
|
|
20
|
-
isNativeScreenCaptureAvailable,
|
|
21
|
-
captureNativeFrame,
|
|
22
|
-
} from './snapshot/NativeScreenCapture';
|
|
23
|
-
|
|
24
|
-
// Types
|
|
25
|
-
export type {
|
|
26
|
-
EventType,
|
|
27
|
-
AnalyticsEvent,
|
|
28
|
-
TouchEventData,
|
|
29
|
-
NavigationEventData,
|
|
30
|
-
ScreenVisit,
|
|
31
|
-
DeviceInfo,
|
|
32
|
-
SDKConfig,
|
|
33
|
-
} from './types/events';
|
|
34
|
-
|
|
35
|
-
export type {
|
|
36
|
-
ScreenshotData,
|
|
37
|
-
SnapshotPayload,
|
|
38
|
-
} from './types/snapshot';
|
|
39
|
-
|
|
40
|
-
export type { CrashReport } from './types/crash';
|
|
41
|
-
|
|
42
|
-
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
43
|
-
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
44
|
-
|
|
45
|
-
// Components
|
|
46
|
-
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export type {
|
|
1
|
+
// Main class
|
|
2
|
+
export { AppAnalytics } from './core/client';
|
|
3
|
+
|
|
4
|
+
// Trackers
|
|
5
|
+
export { TouchTracker } from './trackers/touch.tracker';
|
|
6
|
+
export {
|
|
7
|
+
useNavigationTracker,
|
|
8
|
+
trackScreenChange,
|
|
9
|
+
} from './trackers/navigation.tracker';
|
|
10
|
+
export {
|
|
11
|
+
trackScreenEnter,
|
|
12
|
+
trackScreenExit,
|
|
13
|
+
} from './trackers/screen.tracker';
|
|
14
|
+
export { ErrorTracker } from './trackers/error.tracker';
|
|
15
|
+
|
|
16
|
+
// Snapshot
|
|
17
|
+
export { SnapshotCapture } from './snapshot/capture';
|
|
18
|
+
export { captureScreenshot } from './snapshot/captureScreenshot';
|
|
19
|
+
export {
|
|
20
|
+
isNativeScreenCaptureAvailable,
|
|
21
|
+
captureNativeFrame,
|
|
22
|
+
} from './snapshot/NativeScreenCapture';
|
|
23
|
+
|
|
24
|
+
// Types
|
|
25
|
+
export type {
|
|
26
|
+
EventType,
|
|
27
|
+
AnalyticsEvent,
|
|
28
|
+
TouchEventData,
|
|
29
|
+
NavigationEventData,
|
|
30
|
+
ScreenVisit,
|
|
31
|
+
DeviceInfo,
|
|
32
|
+
SDKConfig,
|
|
33
|
+
} from './types/events';
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
ScreenshotData,
|
|
37
|
+
SnapshotPayload,
|
|
38
|
+
} from './types/snapshot';
|
|
39
|
+
|
|
40
|
+
export type { CrashReport } from './types/crash';
|
|
41
|
+
|
|
42
|
+
export type { SnapshotCaptureConfig } from './snapshot/capture';
|
|
43
|
+
export type { ScreenshotResult } from './snapshot/captureScreenshot';
|
|
44
|
+
|
|
45
|
+
// Components
|
|
46
|
+
export { RepliqoScrollView } from './components/RepliqoScrollView';
|
|
47
|
+
export { RepliqoFlatList } from './components/RepliqoFlatList';
|
|
48
|
+
|
|
49
|
+
// Storage (offline persistence contract)
|
|
50
|
+
export type { PersistentStorage } from './core/storage';
|
|
51
|
+
|
|
52
|
+
// Config
|
|
53
|
+
export { DEFAULT_CONFIG } from './core/config';
|
|
54
|
+
export type { ResolvedConfig } from './core/config';
|
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import { NativeModules, Platform } from 'react-native';
|
|
2
|
-
|
|
3
|
-
export interface NativeFrameResult {
|
|
4
|
-
image: string;
|
|
5
|
-
width: number;
|
|
6
|
-
height: number;
|
|
7
|
-
format: 'jpeg';
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface NativeCaptureResult {
|
|
11
|
-
image: string;
|
|
12
|
-
width: number;
|
|
13
|
-
height: number;
|
|
14
|
-
format: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface NativeScreenCaptureModule {
|
|
18
|
-
captureFrame(): Promise<NativeCaptureResult | null>;
|
|
19
|
-
isAvailable(): Promise<boolean>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const NativeModule: NativeScreenCaptureModule | null =
|
|
23
|
-
Platform.OS === 'android'
|
|
24
|
-
? (NativeModules.ScreenCaptureModule as NativeScreenCaptureModule) ?? null
|
|
25
|
-
: null;
|
|
26
|
-
|
|
27
|
-
/** Whether the native multi-window capture module is available. */
|
|
28
|
-
export function isNativeScreenCaptureAvailable(): boolean {
|
|
29
|
-
return NativeModule !== null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Capture a single frame using native multi-window capture.
|
|
34
|
-
* Captures ALL visible windows: main view + modals + alerts + dialogs.
|
|
35
|
-
*
|
|
36
|
-
* No permissions required. No dialog. No foreground service.
|
|
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
|
-
*
|
|
44
|
-
* @returns NativeFrameResult or null if capture failed / not available
|
|
45
|
-
*/
|
|
46
|
-
export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
|
|
47
|
-
if (!NativeModule) return null;
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const result = await NativeModule.captureFrame();
|
|
51
|
-
if (!result || !result.image) return null;
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
image: result.image,
|
|
55
|
-
width: result.width,
|
|
56
|
-
height: result.height,
|
|
57
|
-
format: 'jpeg',
|
|
58
|
-
};
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
import { NativeModules, Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export interface NativeFrameResult {
|
|
4
|
+
image: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
format: 'jpeg';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NativeCaptureResult {
|
|
11
|
+
image: string;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
format: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface NativeScreenCaptureModule {
|
|
18
|
+
captureFrame(): Promise<NativeCaptureResult | null>;
|
|
19
|
+
isAvailable(): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NativeModule: NativeScreenCaptureModule | null =
|
|
23
|
+
Platform.OS === 'android'
|
|
24
|
+
? (NativeModules.ScreenCaptureModule as NativeScreenCaptureModule) ?? null
|
|
25
|
+
: null;
|
|
26
|
+
|
|
27
|
+
/** Whether the native multi-window capture module is available. */
|
|
28
|
+
export function isNativeScreenCaptureAvailable(): boolean {
|
|
29
|
+
return NativeModule !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Capture a single frame using native multi-window capture.
|
|
34
|
+
* Captures ALL visible windows: main view + modals + alerts + dialogs.
|
|
35
|
+
*
|
|
36
|
+
* No permissions required. No dialog. No foreground service.
|
|
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
|
+
*
|
|
44
|
+
* @returns NativeFrameResult or null if capture failed / not available
|
|
45
|
+
*/
|
|
46
|
+
export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
|
|
47
|
+
if (!NativeModule) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await NativeModule.captureFrame();
|
|
51
|
+
if (!result || !result.image) return null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
image: result.image,
|
|
55
|
+
width: result.width,
|
|
56
|
+
height: result.height,
|
|
57
|
+
format: 'jpeg',
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/snapshot/capture.ts
CHANGED
|
@@ -1,142 +1,154 @@
|
|
|
1
|
-
import { SnapshotPayload, ScreenshotData } from '../types/snapshot';
|
|
2
|
-
import { captureScreenshot } from './captureScreenshot';
|
|
3
|
-
|
|
4
|
-
export interface SnapshotCaptureConfig {
|
|
5
|
-
captureInterval: number;
|
|
6
|
-
maxSnapshotsPerSession: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type ScreenshotProvider = () => Promise<ScreenshotData | null>;
|
|
10
|
-
|
|
11
|
-
type LogFn = (...args: any[]) => void;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Captures JPEG screenshots of the RN view hierarchy at a fixed interval
|
|
15
|
-
* and forwards them to the host (usually the snapshot BatchQueue).
|
|
16
|
-
*/
|
|
17
|
-
export class SnapshotCapture {
|
|
18
|
-
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
-
private sequence: number = 0;
|
|
20
|
-
private config: SnapshotCaptureConfig;
|
|
21
|
-
private onSnapshot: (snapshot: SnapshotPayload) => void;
|
|
22
|
-
private sessionId: string | null = null;
|
|
23
|
-
private currentScreen: string = 'unknown';
|
|
24
|
-
private capturing: boolean = false;
|
|
25
|
-
private running: boolean = false;
|
|
26
|
-
private provider: ScreenshotProvider;
|
|
27
|
-
private log: LogFn;
|
|
28
|
-
private warn: LogFn;
|
|
29
|
-
|
|
30
|
-
constructor(
|
|
31
|
-
config: SnapshotCaptureConfig,
|
|
32
|
-
onSnapshot: (snapshot: SnapshotPayload) => void,
|
|
33
|
-
provider: ScreenshotProvider = captureScreenshot,
|
|
34
|
-
log: LogFn = () => {},
|
|
35
|
-
warn: LogFn = () => {},
|
|
36
|
-
) {
|
|
37
|
-
this.config = config;
|
|
38
|
-
this.onSnapshot = onSnapshot;
|
|
39
|
-
this.provider = provider;
|
|
40
|
-
this.log = log;
|
|
41
|
-
this.warn = warn;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (this.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
1
|
+
import { SnapshotPayload, ScreenshotData } from '../types/snapshot';
|
|
2
|
+
import { captureScreenshot } from './captureScreenshot';
|
|
3
|
+
|
|
4
|
+
export interface SnapshotCaptureConfig {
|
|
5
|
+
captureInterval: number;
|
|
6
|
+
maxSnapshotsPerSession: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ScreenshotProvider = () => Promise<ScreenshotData | null>;
|
|
10
|
+
|
|
11
|
+
type LogFn = (...args: any[]) => void;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Captures JPEG screenshots of the RN view hierarchy at a fixed interval
|
|
15
|
+
* and forwards them to the host (usually the snapshot BatchQueue).
|
|
16
|
+
*/
|
|
17
|
+
export class SnapshotCapture {
|
|
18
|
+
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
+
private sequence: number = 0;
|
|
20
|
+
private config: SnapshotCaptureConfig;
|
|
21
|
+
private onSnapshot: (snapshot: SnapshotPayload) => void;
|
|
22
|
+
private sessionId: string | null = null;
|
|
23
|
+
private currentScreen: string = 'unknown';
|
|
24
|
+
private capturing: boolean = false;
|
|
25
|
+
private running: boolean = false;
|
|
26
|
+
private provider: ScreenshotProvider;
|
|
27
|
+
private log: LogFn;
|
|
28
|
+
private warn: LogFn;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
config: SnapshotCaptureConfig,
|
|
32
|
+
onSnapshot: (snapshot: SnapshotPayload) => void,
|
|
33
|
+
provider: ScreenshotProvider = captureScreenshot,
|
|
34
|
+
log: LogFn = () => {},
|
|
35
|
+
warn: LogFn = () => {},
|
|
36
|
+
) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.onSnapshot = onSnapshot;
|
|
39
|
+
this.provider = provider;
|
|
40
|
+
this.log = log;
|
|
41
|
+
this.warn = warn;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generation counter. Incremented on every start()/stop() so that timer
|
|
46
|
+
* chains from a PREVIOUS generation cannot reschedule themselves. Without
|
|
47
|
+
* it, a fast stop()→start() while a tick's capture is still in flight
|
|
48
|
+
* leaves two parallel timer chains alive (double capture rate).
|
|
49
|
+
*/
|
|
50
|
+
private epoch = 0;
|
|
51
|
+
|
|
52
|
+
start(): void {
|
|
53
|
+
if (this.running) return;
|
|
54
|
+
this.running = true;
|
|
55
|
+
this.epoch++;
|
|
56
|
+
this.log('Snapshot capture started (interval:', this.config.captureInterval, 'ms)');
|
|
57
|
+
this.scheduleNext(this.epoch);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stop(): void {
|
|
61
|
+
this.running = false;
|
|
62
|
+
this.epoch++;
|
|
63
|
+
if (this.timeoutId !== null) {
|
|
64
|
+
clearTimeout(this.timeoutId);
|
|
65
|
+
this.timeoutId = null;
|
|
66
|
+
}
|
|
67
|
+
this.log('Snapshot capture stopped');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async captureNow(): Promise<void> {
|
|
71
|
+
await this.tick();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private scheduleNext(epoch: number): void {
|
|
75
|
+
if (!this.running || epoch !== this.epoch) return;
|
|
76
|
+
this.timeoutId = setTimeout(() => {
|
|
77
|
+
// Orphan timer from a previous generation — do nothing.
|
|
78
|
+
if (epoch !== this.epoch) return;
|
|
79
|
+
Promise.resolve(this.tick()).finally(() => {
|
|
80
|
+
if (this.running && epoch === this.epoch) {
|
|
81
|
+
this.scheduleNext(epoch);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}, this.config.captureInterval);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async tick(): Promise<void> {
|
|
88
|
+
if (!this.sessionId) return;
|
|
89
|
+
if (this.capturing) return;
|
|
90
|
+
|
|
91
|
+
if (this.sequence >= this.config.maxSnapshotsPerSession) {
|
|
92
|
+
this.log('Max snapshots reached:', this.sequence);
|
|
93
|
+
this.stop();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.capturing = true;
|
|
98
|
+
let screenshot: ScreenshotData | null = null;
|
|
99
|
+
try {
|
|
100
|
+
screenshot = await this.provider();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
this.warn('Snapshot capture failed:', err);
|
|
103
|
+
screenshot = null;
|
|
104
|
+
} finally {
|
|
105
|
+
this.capturing = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!screenshot) {
|
|
109
|
+
this.warn('Snapshot returned null (provider unavailable or failed)');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Strip `source` field before sending — the backend DTO only
|
|
114
|
+
// accepts { image, width, height, format } and rejects unknown props.
|
|
115
|
+
const { image, width, height, format } = screenshot;
|
|
116
|
+
|
|
117
|
+
const payload: SnapshotPayload = {
|
|
118
|
+
sessionId: this.sessionId,
|
|
119
|
+
screenName: this.currentScreen,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
sequence: this.sequence,
|
|
122
|
+
isDiff: false,
|
|
123
|
+
data: { image, width, height, format },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
this.sequence++;
|
|
127
|
+
this.log(
|
|
128
|
+
'Snapshot captured: seq=', this.sequence - 1,
|
|
129
|
+
'screen=', this.currentScreen,
|
|
130
|
+
'size=', Math.round(screenshot.image.length / 1024), 'KB',
|
|
131
|
+
);
|
|
132
|
+
this.onSnapshot(payload);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
reset(): void {
|
|
136
|
+
this.sequence = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setSessionId(sessionId: string | null): void {
|
|
140
|
+
this.sessionId = sessionId;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setCurrentScreen(screenName: string): void {
|
|
144
|
+
this.currentScreen = screenName;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getSequence(): number {
|
|
148
|
+
return this.sequence;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
isRunning(): boolean {
|
|
152
|
+
return this.running;
|
|
153
|
+
}
|
|
154
|
+
}
|