import { type ScreenEvent, type RecorderConfig, type EventRecorder, } from '../types' import { type eventWithTime } from '@rrweb/types' import { trace, SpanStatusCode } from '@opentelemetry/api' import { Dimensions, Platform } from 'react-native' import { createRecordingMetaEvent, createFullSnapshotEvent, createIncrementalSnapshotWithImageUpdate as createIncrementalSnapshotUtil, generateScreenHash, logger, } from '../utils' import { screenRecordingService, type ScreenRecordingConfig, } from '../services/screenRecordingService' const isWeb = Platform.OS === 'web' export class ScreenRecorder implements EventRecorder { private config?: RecorderConfig private isRecording = false private generation = 0 private isBufferOnlyMode = false private needsFullSnapshot = true private events: ScreenEvent[] = [] private captureInterval?: any private bufferSnapshotInterval?: any private captureCount: number = 0 private maxCaptures: number = 100 // Limit captures to prevent memory issues private captureQuality: number = 0.2 private captureScale: number = 0.33 private captureFormat: 'png' | 'jpg' = 'jpg' private screenDimensions: { width: number; height: number } | null = null private eventRecorder?: EventRecorder private nodeIdCounter: number = 1 private lastScreenCapture: string | null = null private lastScreenHash: string | null = null private enableChangeDetection: boolean = true private hashSampleSize: number = 100 private currentImageNodeId: number | null = null private recordingConfig?: ScreenRecordingConfig private viewShotRef: any = null init(config: RecorderConfig, eventRecorder?: EventRecorder): void { this.config = config this.eventRecorder = eventRecorder this._getScreenDimensions() // Initialize masking configuration this.recordingConfig = { enabled: true, ...this.config.masking, } // Update the masking service configuration screenRecordingService.updateConfig(this.recordingConfig) } start(): void { this.isRecording = true this.generation++ this.needsFullSnapshot = true this.events = [] this.captureCount = 0 this.lastScreenCapture = null this.lastScreenHash = null this.currentImageNodeId = null // Reset image node ID for new session logger.info('ScreenRecorder', 'Screen recording started') this._startPeriodicCapture() this._startBufferSnapshotInterval() // Capture initial screen immediately this._captureScreen() } stop(): void { this.isRecording = false this.generation++ this._stopPeriodicCapture() this._stopBufferSnapshotInterval() // Screen recording stopped } pause(): void { this.isRecording = false this._stopPeriodicCapture() this._stopBufferSnapshotInterval() } resume(): void { this.isRecording = true this._startPeriodicCapture() this._startBufferSnapshotInterval() } /** * Enables behavior specific to crash-buffer-only mode. * In this mode we periodically force a full snapshot anchor for replayability. */ setBufferOnlyMode(enabled: boolean): void { this.isBufferOnlyMode = enabled if (!enabled) { this.needsFullSnapshot = false this._stopBufferSnapshotInterval() return } this.needsFullSnapshot = true if (this.isRecording) { this._startBufferSnapshotInterval() } } private _getScreenDimensions(): void { try { this.screenDimensions = Dimensions.get('window') } catch (error) { // Failed to get screen dimensions - silently continue this.screenDimensions = { width: 375, height: 667 } // Default fallback } } private _startPeriodicCapture(): void { if (this.captureInterval) { clearInterval(this.captureInterval) } // Capture screen every 5 seconds (reduced frequency) this.captureInterval = setInterval(() => { this._captureScreen() }, 5000) } private _stopPeriodicCapture(): void { if (this.captureInterval) { clearInterval(this.captureInterval) this.captureInterval = undefined } } private _startBufferSnapshotInterval(): void { this._stopBufferSnapshotInterval() if (!this.isBufferOnlyMode) { return } const configuredInterval = this.config?.buffering?.snapshotIntervalMs || 20000 const intervalMs = Math.max(5000, configuredInterval) this.bufferSnapshotInterval = setInterval(() => { // Force a fresh full snapshot anchor in buffer-only mode. this.needsFullSnapshot = true void this._captureScreen() }, intervalMs) } private _stopBufferSnapshotInterval(): void { if (this.bufferSnapshotInterval) { clearInterval(this.bufferSnapshotInterval) this.bufferSnapshotInterval = undefined } } private async _captureScreen(timestamp?: number): Promise { if (!this.isRecording || this.captureCount >= this.maxCaptures) return // Capture before any await so we can detect a stop/restart landing // on top of this in-flight capture. A post-await generation mismatch // means the recorder was restarted — publishing would mix node IDs. const gen = this.generation try { const base64Image = await this._captureScreenBase64() if (gen !== this.generation) return if (base64Image) { // Check if screen has changed by comparing with previous capture const hasChanged = this.enableChangeDetection ? this._hasScreenChanged(base64Image) : true if (hasChanged) { // Keep replay segments anchored: buffer-only mode can force a full snapshot. const shouldEmitFullSnapshot = this.needsFullSnapshot || this.currentImageNodeId === null || !this.lastScreenCapture if (!shouldEmitFullSnapshot) { const success = this.updateScreenWithIncrementalSnapshot( base64Image, timestamp, ) if (!success) { // Fallback to full snapshot if incremental update fails this._createAndEmitFullSnapshotEvent(base64Image, timestamp) this.needsFullSnapshot = false } } else { // First capture, forced anchor, or no existing image node. this._createAndEmitFullSnapshotEvent(base64Image, timestamp) this.needsFullSnapshot = false } this.lastScreenCapture = base64Image this.lastScreenHash = this._generateScreenHash(base64Image) this.captureCount++ } } } catch (error) { this._recordScreenCaptureError(error as Error) } } private async _captureScreenBase64(): Promise { try { // Check if we're on web platform if (isWeb) { logger.warn( 'ScreenRecorder', 'Screen capture not available on web platform', ) return null } // Try native masking first if available if (screenRecordingService.isScreenRecordingAvailable()) { logger.info('ScreenRecorder', 'Using native masking for screen capture') const recordingImage = await screenRecordingService.captureMaskedScreen( { quality: this.captureQuality, scale: this.captureScale, }, ) if (recordingImage) { return recordingImage } logger.warn( 'ScreenRecorder', 'Native masking failed, falling back to view-shot', ) } // // Fallback to react-native-view-shot if (!this.viewShotRef) { logger.warn( 'ScreenRecorder', 'ViewShot ref not available for screen capture', ) return null } return null // // Check if captureRef is available // if (!captureRef) { // logger.warn('ScreenRecorder', 'react-native-view-shot not available'); // return null; // } // // Capture the screen using react-native-view-shot // const result = await captureRef(this.viewShotRef, { // format: this.captureFormat, // quality: this.captureQuality, // result: 'base64', // }); // return result; } catch (error) { logger.error( 'ScreenRecorder', 'Failed to capture screen. Make sure react-native-view-shot is properly installed and linked:', error, ) return null } } private _createAndEmitFullSnapshotEvent( base64Image: string, timestamp?: number, ): void { if (!this.screenDimensions) return // Keep replay anchors valid: each full snapshot is explicitly preceded by meta. this.recordEvent(createRecordingMetaEvent()) // Use the new createFullSnapshot method const fullSnapshotEvent = this.createFullSnapshot(base64Image, timestamp) this.recordEvent(fullSnapshotEvent) } /** * Create a full snapshot event with the given base64 image * @param base64Image - Base64 encoded image data * @param timestamp - Optional timestamp to use for the event * @returns Full snapshot event */ createFullSnapshot(base64Image: string, timestamp?: number): eventWithTime { if (!this.screenDimensions) { throw new Error('Screen dimensions not available') } const { width, height } = this.screenDimensions this.nodeIdCounter = 1 // Use utility function to create full snapshot event const fullSnapshotEvent = createFullSnapshotEvent( base64Image, width, height, this.captureFormat, { current: this.nodeIdCounter }, timestamp, ) // Store the image node ID for future incremental updates // The image node ID is the first node created (after the document) this.currentImageNodeId = 0 // First element node is the image return fullSnapshotEvent } /** * Create an incremental snapshot event with mutation data to update image src * @param base64Image - New base64 encoded image data * @param captureFormat - Image format (png, jpg, etc.) * @param timestamp - Optional timestamp to use for the event * @returns Incremental snapshot event with mutation data */ createIncrementalSnapshotWithImageUpdate( base64Image: string, captureFormat?: string, timestamp?: number, ): eventWithTime { return createIncrementalSnapshotUtil( base64Image, captureFormat || this.captureFormat, timestamp, ) } /** * Update the screen with a new image using incremental snapshot * @param base64Image - New base64 encoded image data * @param timestamp - Optional timestamp to use for the event * @returns true if update was successful, false otherwise */ updateScreenWithIncrementalSnapshot( base64Image: string, timestamp?: number, ): boolean { if (this.currentImageNodeId === null) { logger.warn( 'ScreenRecorder', 'No image node ID available for incremental update', ) return false } const incrementalEvent = this.createIncrementalSnapshotWithImageUpdate( base64Image, 'jpg', timestamp, ) this.recordEvent(incrementalEvent) return true } /** * Force a full snapshot (useful when screen dimensions change or for debugging) * @param base64Image - Base64 encoded image data */ forceFullSnapshot(base64Image: string): void { this._createAndEmitFullSnapshotEvent(base64Image) this.lastScreenCapture = base64Image this.lastScreenHash = this._generateScreenHash(base64Image) this.captureCount++ } /** * Check if the screen has changed by comparing with the previous capture * @param currentBase64 - Current screen capture as base64 * @returns true if screen has changed, false otherwise */ private _hasScreenChanged(currentBase64: string): boolean { // If this is the first capture, consider it changed if (!this.lastScreenCapture) { return true } // Generate hash for current capture const currentHash = this._generateScreenHash(currentBase64) // Compare with previous hash return currentHash !== this.lastScreenHash } /** * Generate a simple hash for screen comparison * This is a lightweight hash that focuses on the beginning and end of the base64 string * to detect changes without doing a full comparison * @param base64Image - Base64 encoded image * @returns Hash string for comparison */ private _generateScreenHash(base64Image: string): string { return generateScreenHash(base64Image, this.hashSampleSize) } private _sendEvent(_event: ScreenEvent): void { // Screen event recorded // Send event to backend or store locally } private _recordOpenTelemetrySpan(event: ScreenEvent): void { try { const span = trace.getTracer('screen').startSpan(`Screen.${event.type}`, { attributes: { 'screen.type': event.type, 'screen.timestamp': event.timestamp, 'screen.platform': 'react-native', }, }) if (event.metadata) { Object.entries(event.metadata).forEach(([key, value]) => { span.setAttribute(`screen.metadata.${key}`, String(value)) }) } span.setStatus({ code: SpanStatusCode.OK }) span.end() } catch (error) { // Failed to record OpenTelemetry span for screen - silently continue } } private _recordScreenCaptureError(error: Error): void { try { const span = trace.getTracer('screen').startSpan('Screen.capture.error', { attributes: { 'screen.error': true, 'screen.error.type': error.name, 'screen.error.message': error.message, 'screen.timestamp': Date.now(), }, }) span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }) span.recordException(error) span.end() } catch (spanError) { // Failed to record error span - silently continue } } // Configuration methods setCaptureInterval(intervalMs: number): void { if (this.captureInterval) { clearInterval(this.captureInterval) } if (this.isRecording) { this.captureInterval = setInterval(() => { this._captureScreen() }, intervalMs) } } setCaptureQuality(quality: number): void { this.captureQuality = Math.max(0.1, Math.min(1.0, quality)) } setCaptureFormat(format: 'png' | 'jpg'): void { this.captureFormat = format } setMaxCaptures(max: number): void { this.maxCaptures = Math.max(1, max) } /** * Enable or disable change detection * @param enabled - Whether to enable change detection */ setChangeDetection(enabled: boolean): void { this.enableChangeDetection = enabled } /** * Set the hash sample size for change detection * @param size - Number of characters to sample from each part of the image */ setHashSampleSize(size: number): void { this.hashSampleSize = Math.max(10, Math.min(1000, size)) } // Performance monitoring recordScreenPerformance(screenName: string, loadTime: number): void { const event: ScreenEvent = { screenName, type: 'screenCapture', timestamp: Date.now(), metadata: { screenName, loadTime, performance: 'monitoring', captureCount: this.captureCount, }, } this.events.push(event) this._sendEvent(event) this._recordOpenTelemetrySpan(event) this.events.push(event) this._sendEvent(event) this._recordOpenTelemetrySpan(event) } // Error tracking recordScreenError(error: Error, screenName?: string): void { const event: ScreenEvent = { screenName: screenName || 'unknown', type: 'screenCapture', timestamp: Date.now(), metadata: { error: true, errorType: error.name, errorMessage: error.message, screenName, captureCount: this.captureCount, }, } this.events.push(event) this._sendEvent(event) this._recordOpenTelemetrySpan(event) this.events.push(event) this._sendEvent(event) this._recordScreenCaptureError(error) } // Get recorded events getEvents(): ScreenEvent[] { return [...this.events] } // Clear events clearEvents(): void { this.events = [] this.captureCount = 0 } // Get screen capture statistics getScreenStats(): Record { const stats = { totalCaptures: this.captureCount, totalEvents: this.events.length, averageCaptureTime: 0, successRate: 0, } if (this.events.length > 0) { const captureTimes = this.events .map((event) => event.metadata?.captureTime || 0) .filter((time) => time > 0) if (captureTimes.length > 0) { stats.averageCaptureTime = captureTimes.reduce((a, b) => a + b, 0) / captureTimes.length } const successfulCaptures = this.events.filter( (event) => event.dataUrl, ).length stats.successRate = (successfulCaptures / this.events.length) * 100 } return stats } // Get recording status isRecordingEnabled(): boolean { return this.isRecording } // Get current configuration getConfiguration(): Record { return { captureInterval: this.captureInterval ? 2000 : 0, // Default 5 seconds captureQuality: this.captureQuality, captureFormat: this.captureFormat, maxCaptures: this.maxCaptures, screenDimensions: this.screenDimensions, } } // Shutdown shutdown(): void { this.stop() this.clearEvents() // Screen recorder shutdown } /** * Set the viewshot ref for screen capture * @param ref - React Native View ref for screen capture */ setViewShotRef(ref: any): void { this.viewShotRef = ref } /** * Force capture screen (useful after touch interactions) * This bypasses the change detection and always captures * @param timestamp - Optional timestamp to use for the capture event */ forceCapture(timestamp?: number): void { if (!this.isRecording) { return } this._captureScreen(timestamp) } /** * Record an rrweb event * @param event - The rrweb event to record */ recordEvent(event: any): void { if (this.eventRecorder) { this.eventRecorder.recordEvent(event) } } }