@repliqo/sdk-react-native 0.1.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.
Files changed (58) hide show
  1. package/INTEGRATION_GUIDE.md +1312 -0
  2. package/android/build.gradle +24 -0
  3. package/android/src/main/AndroidManifest.xml +4 -0
  4. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +230 -0
  5. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -0
  6. package/android/src/main/java/com/repliqo/screencapture/ScreenCapturePackage.java +38 -0
  7. package/dist/components/ScreenCapturePermission.d.ts +55 -0
  8. package/dist/components/ScreenCapturePermission.js +112 -0
  9. package/dist/core/client.d.ts +41 -0
  10. package/dist/core/client.js +309 -0
  11. package/dist/core/config.d.ts +4 -0
  12. package/dist/core/config.js +26 -0
  13. package/dist/core/logger.d.ts +8 -0
  14. package/dist/core/logger.js +25 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.js +28 -0
  17. package/dist/snapshot/NativeScreenCapture.d.ts +17 -0
  18. package/dist/snapshot/NativeScreenCapture.js +38 -0
  19. package/dist/snapshot/capture.d.ts +36 -0
  20. package/dist/snapshot/capture.js +109 -0
  21. package/dist/snapshot/captureScreenshot.d.ts +26 -0
  22. package/dist/snapshot/captureScreenshot.js +89 -0
  23. package/dist/trackers/error.tracker.d.ts +22 -0
  24. package/dist/trackers/error.tracker.js +123 -0
  25. package/dist/trackers/navigation.tracker.d.ts +6 -0
  26. package/dist/trackers/navigation.tracker.js +78 -0
  27. package/dist/trackers/screen.tracker.d.ts +2 -0
  28. package/dist/trackers/screen.tracker.js +23 -0
  29. package/dist/trackers/touch.tracker.d.ts +8 -0
  30. package/dist/trackers/touch.tracker.js +30 -0
  31. package/dist/transport/api.client.d.ts +29 -0
  32. package/dist/transport/api.client.js +156 -0
  33. package/dist/transport/batch-queue.d.ts +18 -0
  34. package/dist/transport/batch-queue.js +80 -0
  35. package/dist/types/crash.d.ts +10 -0
  36. package/dist/types/crash.js +2 -0
  37. package/dist/types/events.d.ts +53 -0
  38. package/dist/types/events.js +2 -0
  39. package/dist/types/snapshot.d.ts +21 -0
  40. package/dist/types/snapshot.js +9 -0
  41. package/package.json +64 -0
  42. package/src/components/ScreenCapturePermission.tsx +160 -0
  43. package/src/core/client.ts +425 -0
  44. package/src/core/config.ts +27 -0
  45. package/src/core/logger.ts +27 -0
  46. package/src/index.ts +47 -0
  47. package/src/snapshot/NativeScreenCapture.ts +54 -0
  48. package/src/snapshot/capture.ts +142 -0
  49. package/src/snapshot/captureScreenshot.ts +109 -0
  50. package/src/trackers/error.tracker.ts +136 -0
  51. package/src/trackers/navigation.tracker.ts +98 -0
  52. package/src/trackers/screen.tracker.ts +19 -0
  53. package/src/trackers/touch.tracker.tsx +44 -0
  54. package/src/transport/api.client.ts +225 -0
  55. package/src/transport/batch-queue.ts +95 -0
  56. package/src/types/crash.ts +10 -0
  57. package/src/types/events.ts +59 -0
  58. package/src/types/snapshot.ts +23 -0
@@ -0,0 +1,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
+ start(): void {
45
+ if (this.running) return;
46
+ this.running = true;
47
+ this.log('Snapshot capture started (interval:', this.config.captureInterval, 'ms)');
48
+ this.scheduleNext();
49
+ }
50
+
51
+ stop(): void {
52
+ this.running = false;
53
+ if (this.timeoutId !== null) {
54
+ clearTimeout(this.timeoutId);
55
+ this.timeoutId = null;
56
+ }
57
+ this.log('Snapshot capture stopped');
58
+ }
59
+
60
+ async captureNow(): Promise<void> {
61
+ await this.tick();
62
+ }
63
+
64
+ private scheduleNext(): void {
65
+ if (!this.running) return;
66
+ this.timeoutId = setTimeout(() => {
67
+ Promise.resolve(this.tick()).finally(() => {
68
+ if (this.running) {
69
+ this.scheduleNext();
70
+ }
71
+ });
72
+ }, this.config.captureInterval);
73
+ }
74
+
75
+ private async tick(): Promise<void> {
76
+ if (!this.sessionId) return;
77
+ if (this.capturing) return;
78
+
79
+ if (this.sequence >= this.config.maxSnapshotsPerSession) {
80
+ this.log('Max snapshots reached:', this.sequence);
81
+ this.stop();
82
+ return;
83
+ }
84
+
85
+ this.capturing = true;
86
+ let screenshot: ScreenshotData | null = null;
87
+ try {
88
+ screenshot = await this.provider();
89
+ } catch (err) {
90
+ this.warn('Snapshot capture failed:', err);
91
+ screenshot = null;
92
+ } finally {
93
+ this.capturing = false;
94
+ }
95
+
96
+ if (!screenshot) {
97
+ this.warn('Snapshot returned null (provider unavailable or failed)');
98
+ return;
99
+ }
100
+
101
+ // Strip `source` field before sending — the backend DTO only
102
+ // accepts { image, width, height, format } and rejects unknown props.
103
+ const { image, width, height, format } = screenshot;
104
+
105
+ const payload: SnapshotPayload = {
106
+ sessionId: this.sessionId,
107
+ screenName: this.currentScreen,
108
+ timestamp: new Date().toISOString(),
109
+ sequence: this.sequence,
110
+ isDiff: false,
111
+ data: { image, width, height, format },
112
+ };
113
+
114
+ this.sequence++;
115
+ this.log(
116
+ 'Snapshot captured: seq=', this.sequence - 1,
117
+ 'screen=', this.currentScreen,
118
+ 'size=', Math.round(screenshot.image.length / 1024), 'KB',
119
+ );
120
+ this.onSnapshot(payload);
121
+ }
122
+
123
+ reset(): void {
124
+ this.sequence = 0;
125
+ }
126
+
127
+ setSessionId(sessionId: string | null): void {
128
+ this.sessionId = sessionId;
129
+ }
130
+
131
+ setCurrentScreen(screenName: string): void {
132
+ this.currentScreen = screenName;
133
+ }
134
+
135
+ getSequence(): number {
136
+ return this.sequence;
137
+ }
138
+
139
+ isRunning(): boolean {
140
+ return this.running;
141
+ }
142
+ }
@@ -0,0 +1,109 @@
1
+ import { Dimensions } from 'react-native';
2
+ import {
3
+ isNativeScreenCaptureAvailable,
4
+ captureNativeFrame,
5
+ } from './NativeScreenCapture';
6
+
7
+ export type CaptureSource = 'native' | 'viewshot';
8
+
9
+ export interface ScreenshotResult {
10
+ image: string;
11
+ width: number;
12
+ height: number;
13
+ format: 'jpeg';
14
+ /** Which capture method produced this frame. */
15
+ source: CaptureSource;
16
+ }
17
+
18
+ const TARGET_WIDTH = 390;
19
+ const JPEG_QUALITY = 0.4;
20
+
21
+ // --- view-shot fallback (lazy-loaded) ---
22
+
23
+ let viewShotCaptureScreen: ((opts: any) => Promise<string>) | null = null;
24
+ let viewShotResolved = false;
25
+
26
+ function resolveViewShot(): ((opts: any) => Promise<string>) | null {
27
+ if (viewShotResolved) return viewShotCaptureScreen;
28
+ viewShotResolved = true;
29
+
30
+ try {
31
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
32
+ const viewShot = require('react-native-view-shot');
33
+ if (typeof viewShot.captureScreen === 'function') {
34
+ viewShotCaptureScreen = viewShot.captureScreen;
35
+ } else if (typeof viewShot.captureRef === 'function') {
36
+ viewShotCaptureScreen = (opts: any) =>
37
+ viewShot.captureRef('window', opts);
38
+ }
39
+ } catch {
40
+ // view-shot not installed - native capture only
41
+ }
42
+
43
+ return viewShotCaptureScreen;
44
+ }
45
+
46
+ /**
47
+ * Capture a JPEG screenshot using the best available method:
48
+ *
49
+ * 1. Native multi-window capture (Android) — uses View.draw() on all
50
+ * app windows via WindowManagerGlobal reflection. Captures modals,
51
+ * alerts, dialogs, overlays. No permissions needed.
52
+ * 2. react-native-view-shot fallback — captures the main RN view only
53
+ * (no modals or native dialogs)
54
+ * 3. null — if both are unavailable
55
+ *
56
+ * Never throws. Returns null on any failure.
57
+ */
58
+ export async function captureScreenshot(): Promise<ScreenshotResult | null> {
59
+ // Strategy 1: Native multi-window capture (preferred on Android)
60
+ if (isNativeScreenCaptureAvailable()) {
61
+ const native = await captureNativeFrame();
62
+ if (native) return { ...native, source: 'native' as CaptureSource };
63
+ // If native returns null (e.g. first frame not ready), fall through
64
+ // to view-shot for this single frame rather than showing nothing.
65
+ }
66
+
67
+ // Strategy 2: react-native-view-shot fallback
68
+ const captureScreen = resolveViewShot();
69
+ if (!captureScreen) return null;
70
+
71
+ try {
72
+ const { width: screenWidth, height: screenHeight } =
73
+ Dimensions.get('window');
74
+
75
+ const aspectRatio = screenHeight / screenWidth;
76
+ const outputWidth = Math.min(TARGET_WIDTH, screenWidth);
77
+ const outputHeight = Math.round(outputWidth * aspectRatio);
78
+
79
+ const base64 = await captureScreen({
80
+ format: 'jpg',
81
+ quality: JPEG_QUALITY,
82
+ width: outputWidth,
83
+ height: outputHeight,
84
+ result: 'base64',
85
+ });
86
+
87
+ if (typeof base64 !== 'string' || base64.length === 0) {
88
+ return null;
89
+ }
90
+
91
+ return {
92
+ image: base64,
93
+ width: outputWidth,
94
+ height: outputHeight,
95
+ format: 'jpeg',
96
+ source: 'viewshot' as CaptureSource,
97
+ };
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Test-only: reset cached view-shot resolution.
105
+ */
106
+ export function __resetCaptureRefForTests(): void {
107
+ viewShotCaptureScreen = null;
108
+ viewShotResolved = false;
109
+ }
@@ -0,0 +1,136 @@
1
+ import { CrashReport } from '../types/crash';
2
+
3
+ type CrashCallback = (crash: CrashReport) => void;
4
+
5
+ export class ErrorTracker {
6
+ private onCrash: CrashCallback;
7
+ private sessionId: string | null = null;
8
+ private currentScreen: string | null = null;
9
+ private originalHandler:
10
+ | ((error: Error, isFatal?: boolean) => void)
11
+ | null = null;
12
+ private isActive = false;
13
+
14
+ constructor(onCrash: CrashCallback) {
15
+ this.onCrash = onCrash;
16
+ }
17
+
18
+ setSessionId(id: string | null): void {
19
+ this.sessionId = id;
20
+ }
21
+
22
+ setCurrentScreen(screen: string | null): void {
23
+ this.currentScreen = screen;
24
+ }
25
+
26
+ start(): void {
27
+ if (this.isActive) return;
28
+ this.isActive = true;
29
+ this.setupGlobalErrorHandler();
30
+ this.setupUnhandledRejectionHandler();
31
+ }
32
+
33
+ stop(): void {
34
+ if (!this.isActive) return;
35
+ this.isActive = false;
36
+ this.restoreGlobalErrorHandler();
37
+ }
38
+
39
+ /**
40
+ * Manual error reporting - allows consumers to report caught errors.
41
+ */
42
+ reportError(error: Error, metadata?: Record<string, any>): void {
43
+ try {
44
+ const crash: CrashReport = {
45
+ sessionId: this.sessionId || undefined,
46
+ type: 'js_error',
47
+ message: error.message,
48
+ stackTrace: error.stack || undefined,
49
+ screenName: this.currentScreen || undefined,
50
+ metadata,
51
+ timestamp: new Date().toISOString(),
52
+ };
53
+ this.onCrash(crash);
54
+ } catch (_e) {
55
+ // Never crash the app due to error reporting
56
+ }
57
+ }
58
+
59
+ private setupGlobalErrorHandler(): void {
60
+ try {
61
+ const ErrorUtils = (global as any).ErrorUtils;
62
+ if (ErrorUtils) {
63
+ this.originalHandler = ErrorUtils.getGlobalHandler();
64
+ ErrorUtils.setGlobalHandler(
65
+ (error: Error, isFatal?: boolean) => {
66
+ try {
67
+ const crash: CrashReport = {
68
+ sessionId: this.sessionId || undefined,
69
+ type: isFatal ? 'native_crash' : 'js_error',
70
+ message: error.message,
71
+ stackTrace: error.stack || undefined,
72
+ screenName: this.currentScreen || undefined,
73
+ metadata: { isFatal },
74
+ timestamp: new Date().toISOString(),
75
+ };
76
+ this.onCrash(crash);
77
+ } catch (_e) {
78
+ // Never crash the app due to error reporting
79
+ }
80
+
81
+ // Always call original handler so RN can handle the error
82
+ if (this.originalHandler) {
83
+ this.originalHandler(error, isFatal);
84
+ }
85
+ },
86
+ );
87
+ }
88
+ } catch (_e) {
89
+ // Never crash the app due to error handler setup
90
+ }
91
+ }
92
+
93
+ private setupUnhandledRejectionHandler(): void {
94
+ try {
95
+ if (typeof global !== 'undefined') {
96
+ (global as any).__analyticsUnhandledRejection = (
97
+ _id: number,
98
+ error: any,
99
+ ) => {
100
+ try {
101
+ const message =
102
+ error instanceof Error ? error.message : String(error);
103
+ const stack =
104
+ error instanceof Error ? error.stack : undefined;
105
+
106
+ const crash: CrashReport = {
107
+ sessionId: this.sessionId || undefined,
108
+ type: 'unhandled_rejection',
109
+ message,
110
+ stackTrace: stack || undefined,
111
+ screenName: this.currentScreen || undefined,
112
+ timestamp: new Date().toISOString(),
113
+ };
114
+ this.onCrash(crash);
115
+ } catch (_e) {
116
+ // Never crash the app due to error reporting
117
+ }
118
+ };
119
+ }
120
+ } catch (_e) {
121
+ // Never crash the app due to rejection handler setup
122
+ }
123
+ }
124
+
125
+ private restoreGlobalErrorHandler(): void {
126
+ try {
127
+ const ErrorUtils = (global as any).ErrorUtils;
128
+ if (ErrorUtils && this.originalHandler) {
129
+ ErrorUtils.setGlobalHandler(this.originalHandler);
130
+ this.originalHandler = null;
131
+ }
132
+ } catch (_e) {
133
+ // Never crash the app due to handler restoration
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,98 @@
1
+ import { useRef, useCallback } from 'react';
2
+ import { AppAnalytics } from '../core/client';
3
+
4
+ interface NavigationState {
5
+ routes: Array<{ name: string; key: string; state?: NavigationState }>;
6
+ index: number;
7
+ }
8
+
9
+ function getActiveRouteName(state: NavigationState | undefined): string | null {
10
+ if (!state) {
11
+ return null;
12
+ }
13
+
14
+ const route = state.routes[state.index];
15
+ if (!route) {
16
+ return null;
17
+ }
18
+
19
+ // Dive into nested navigators
20
+ if (route.state) {
21
+ return getActiveRouteName(route.state as NavigationState);
22
+ }
23
+
24
+ return route.name;
25
+ }
26
+
27
+ export function useNavigationTracker() {
28
+ const routeNameRef = useRef<string | null>(null);
29
+ const navigationRef = useRef<any>(null);
30
+
31
+ const onReady = useCallback(() => {
32
+ if (navigationRef.current) {
33
+ const state = navigationRef.current.getRootState() as
34
+ | NavigationState
35
+ | undefined;
36
+ const currentRouteName = getActiveRouteName(state);
37
+ routeNameRef.current = currentRouteName;
38
+
39
+ if (currentRouteName) {
40
+ try {
41
+ const analytics = AppAnalytics.getInstance();
42
+ analytics.onScreenEnter(currentRouteName);
43
+ } catch {
44
+ // AppAnalytics not initialized, silently ignore
45
+ }
46
+ }
47
+ }
48
+ }, []);
49
+
50
+ const onStateChange = useCallback(() => {
51
+ if (!navigationRef.current) {
52
+ return;
53
+ }
54
+
55
+ const state = navigationRef.current.getRootState() as
56
+ | NavigationState
57
+ | undefined;
58
+ const currentRouteName = getActiveRouteName(state);
59
+ const previousRouteName = routeNameRef.current;
60
+
61
+ if (
62
+ currentRouteName &&
63
+ previousRouteName &&
64
+ currentRouteName !== previousRouteName
65
+ ) {
66
+ try {
67
+ const analytics = AppAnalytics.getInstance();
68
+ analytics.trackNavigation(previousRouteName, currentRouteName);
69
+ analytics.onScreenExit(previousRouteName);
70
+ analytics.onScreenEnter(currentRouteName);
71
+ } catch {
72
+ // AppAnalytics not initialized, silently ignore
73
+ }
74
+ }
75
+
76
+ routeNameRef.current = currentRouteName;
77
+ }, []);
78
+
79
+ return {
80
+ navigationRef,
81
+ onStateChange,
82
+ onReady,
83
+ };
84
+ }
85
+
86
+ export function trackScreenChange(
87
+ fromScreen: string,
88
+ toScreen: string,
89
+ ): void {
90
+ try {
91
+ const analytics = AppAnalytics.getInstance();
92
+ analytics.trackNavigation(fromScreen, toScreen);
93
+ analytics.onScreenExit(fromScreen);
94
+ analytics.onScreenEnter(toScreen);
95
+ } catch {
96
+ // AppAnalytics not initialized, silently ignore
97
+ }
98
+ }
@@ -0,0 +1,19 @@
1
+ import { AppAnalytics } from '../core/client';
2
+
3
+ export function trackScreenEnter(screenName: string): void {
4
+ try {
5
+ const analytics = AppAnalytics.getInstance();
6
+ analytics.onScreenEnter(screenName);
7
+ } catch {
8
+ // AppAnalytics not initialized, silently ignore
9
+ }
10
+ }
11
+
12
+ export function trackScreenExit(screenName: string): void {
13
+ try {
14
+ const analytics = AppAnalytics.getInstance();
15
+ analytics.onScreenExit(screenName);
16
+ } catch {
17
+ // AppAnalytics not initialized, silently ignore
18
+ }
19
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { View, GestureResponderEvent } from 'react-native';
3
+ import { AppAnalytics } from '../core/client';
4
+
5
+ interface TouchTrackerProps {
6
+ children: React.ReactNode;
7
+ screenName?: string;
8
+ enabled?: boolean;
9
+ }
10
+
11
+ export const TouchTracker: React.FC<TouchTrackerProps> = ({
12
+ children,
13
+ screenName,
14
+ enabled = true,
15
+ }) => {
16
+ const handleTouchCapture = React.useCallback(
17
+ (event: GestureResponderEvent): boolean => {
18
+ if (!enabled) {
19
+ return false;
20
+ }
21
+
22
+ try {
23
+ const analytics = AppAnalytics.getInstance();
24
+ const { pageX, pageY } = event.nativeEvent;
25
+ analytics.trackTouch(pageX, pageY, screenName);
26
+ } catch {
27
+ // AppAnalytics not initialized, silently ignore
28
+ }
29
+
30
+ // Return false so touches pass through to children
31
+ return false;
32
+ },
33
+ [enabled, screenName],
34
+ );
35
+
36
+ return (
37
+ <View
38
+ style={{ flex: 1 }}
39
+ onStartShouldSetResponderCapture={handleTouchCapture}
40
+ >
41
+ {children}
42
+ </View>
43
+ );
44
+ };