diff --git a/.gitignore b/.gitignore index 83ca870..4e855c0 100644 --- a/.gitignore +++ b/.gitignore @@ -77,12 +77,8 @@ android/keystores/debug.keystore # Turborepo .turbo/ -# generated by bob -lib/ # React Native Codegen ios/generated android/generated -# React Native Nitro Modules -nitrogen/ diff --git a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/NitroScreenRecorder.kt b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/NitroScreenRecorder.kt index 3b7d4de..69bac9b 100644 --- a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/NitroScreenRecorder.kt +++ b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/NitroScreenRecorder.kt @@ -33,6 +33,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { private var globalRecordingService: ScreenRecordingService? = null private var isServiceBound = false private var lastGlobalRecording: File? = null + private var lastGlobalAudioRecording: File? = null private var globalRecordingErrorCallback: ((RecordingError) -> Unit)? = null private val screenRecordingListeners = @@ -64,12 +65,14 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { fun notifyGlobalRecordingFinished( file: File, + audioFile: File?, event: ScreenRecordingEvent, enabledMic: Boolean ) { - Log.d(TAG, "๐Ÿ notifyGlobalRecordingFinished called with file: ${file.absolutePath}") + Log.d(TAG, "๐Ÿ notifyGlobalRecordingFinished called with file: ${file.absolutePath}, audioFile: ${audioFile?.absolutePath}") instance?.let { recorder -> recorder.lastGlobalRecording = file + recorder.lastGlobalAudioRecording = audioFile recorder.notifyListeners(event) } } @@ -89,11 +92,52 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { mediaProjectionManager = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager instance = this + + // Try to rebind to existing service if it's running (handles hot reload case) + if (isServiceRunning(ctx)) { + Log.d(TAG, "๐Ÿ”„ Service is running, attempting to rebind...") + rebindToExistingService(ctx) + } + Log.d(TAG, "โœ… NitroScreenRecorder initialization complete") } ?: run { Log.e(TAG, "โŒ NitroScreenRecorder: applicationContext was null") } } + + /** + * Check if the ScreenRecordingService is currently running. + * This works even if we're not bound to the service. + */ + @Suppress("DEPRECATION") + private fun isServiceRunning(context: Context): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + for (service in manager.getRunningServices(Int.MAX_VALUE)) { + if (ScreenRecordingService::class.java.name == service.service.className) { + return true + } + } + return false + } + + /** + * Attempt to rebind to an existing ScreenRecordingService. + * Called on init to handle hot reload scenarios. + */ + private fun rebindToExistingService(context: Context) { + if (isServiceBound) { + Log.d(TAG, "Already bound to service") + return + } + + val intent = Intent(context, ScreenRecordingService::class.java) + try { + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + Log.d(TAG, "๐Ÿ”— Rebind to existing service initiated") + } catch (e: Exception) { + Log.e(TAG, "โŒ Failed to rebind to service: ${e.message}") + } + } private fun notifyListeners(event: ScreenRecordingEvent) { Log.d( @@ -275,6 +319,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { enableCamera: Boolean, cameraPreviewStyle: RecorderCameraStyle, cameraDevice: CameraDevice, + separateAudioFile: Boolean, onRecordingFinished: (ScreenRecordingFile) -> Unit ) { // no-op @@ -297,7 +342,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { // --- Global Recording Methods --- - override fun startGlobalRecording(enableMic: Boolean, onRecordingError: (RecordingError) -> Unit) { + override fun startGlobalRecording(enableMic: Boolean, separateAudioFile: Boolean, onRecordingError: (RecordingError) -> Unit) { if (globalRecordingService?.isCurrentlyRecording() == true) { Log.w(TAG, "โš ๏ธ Global recording already in progress") return @@ -317,7 +362,8 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { action = ScreenRecordingService.ACTION_START_RECORDING putExtra(ScreenRecordingService.EXTRA_RESULT_CODE, resultCode) putExtra(ScreenRecordingService.EXTRA_RESULT_DATA, resultData) - putExtra(ScreenRecordingService.EXTRA_ENABLE_MIC, enableMic) // Use the parameter instead of hardcoded true + putExtra(ScreenRecordingService.EXTRA_ENABLE_MIC, enableMic) + putExtra(ScreenRecordingService.EXTRA_SEPARATE_AUDIO, separateAudioFile) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -336,38 +382,81 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { override fun stopGlobalRecording(settledTimeMs: Double): Promise { return Promise.async { - val ctx = NitroModules.applicationContext ?: return@async null - - if (globalRecordingService?.isCurrentlyRecording() != true) { - Log.w(TAG, "No active recording to stop") - return@async null - } + try { + val ctx = NitroModules.applicationContext + if (ctx == null) { + Log.w(TAG, "No application context") + return@async null + } - val stopIntent = Intent(ctx, ScreenRecordingService::class.java).apply { - action = ScreenRecordingService.ACTION_STOP_RECORDING - } - ctx.startService(stopIntent) + // Check if we have an active session (MediaProjection exists) + val service = globalRecordingService + val hasActiveSession = service?.hasActiveSession() == true + val serviceRunning = isServiceRunning(ctx) + + if (!hasActiveSession && !serviceRunning) { + Log.w(TAG, "No active recording session to stop") + return@async null + } + + // If service is running but we're not bound, we still send the stop intent + // This handles hot reload scenarios where the service is orphaned + if (serviceRunning) { + Log.d(TAG, "๐Ÿ›‘ Stopping recording service (bound: $isServiceBound, hasSession: $hasActiveSession)") + + val stopIntent = Intent(ctx, ScreenRecordingService::class.java).apply { + action = ScreenRecordingService.ACTION_STOP_RECORDING + } + ctx.startService(stopIntent) + } - if (isServiceBound) { - ctx.unbindService(serviceConnection) - isServiceBound = false - } + if (isServiceBound) { + try { + ctx.unbindService(serviceConnection) + } catch (e: Exception) { + Log.w(TAG, "Service already unbound: ${e.message}") + } + isServiceBound = false + } + + globalRecordingService = null - delay(settledTimeMs.toLong()) + delay(settledTimeMs.toLong()) - return@async retrieveLastGlobalRecording() + return@async retrieveLastGlobalRecording() + } catch (e: Exception) { + Log.e(TAG, "Error stopping global recording: ${e.message}") + e.printStackTrace() + return@async null + } } } override fun retrieveLastGlobalRecording(): ScreenRecordingFile? { return lastGlobalRecording?.let { file -> if (file.exists()) { + // Build audio file info if available + val audioFile = lastGlobalAudioRecording?.let { audioFile -> + if (audioFile.exists()) { + AudioRecordingFile( + path = "file://${audioFile.absolutePath}", + name = audioFile.name, + size = audioFile.length().toDouble(), + duration = RecorderUtils.getAudioDuration(audioFile) + ) + } else { + null + } + } + ScreenRecordingFile( path = "file://${file.absolutePath}", name = file.name, size = file.length().toDouble(), duration = RecorderUtils.getVideoDuration(file), - enabledMicrophone = true // Assume true for global recordings + enabledMicrophone = true, // Assume true for global recordings + audioFile = audioFile, + appAudioFile = null // App audio capture not supported on Android ) } else { null @@ -381,5 +470,117 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() { val globalDir = File(ctx.filesDir, "recordings") RecorderUtils.clearDirectory(globalDir) lastGlobalRecording = null + lastGlobalAudioRecording = null + } + + // --- Chunking --- + + override fun markChunkStart(chunkId: String?): Promise { + return Promise.async { + Log.d(TAG, "๐Ÿ“ markChunkStart called with chunkId=$chunkId") + val startTime = System.currentTimeMillis() + globalRecordingService?.markChunkStart() ?: run { + Log.w(TAG, "โš ๏ธ markChunkStart: Service not bound") + } + val elapsedMs = (System.currentTimeMillis() - startTime).toDouble() + return@async elapsedMs + } + } + + override fun finalizeChunk(chunkId: String?, settledTimeMs: Double): Promise { + return Promise.async { + Log.d(TAG, "๐Ÿ“ฆ finalizeChunk called with chunkId=$chunkId, settledTimeMs=$settledTimeMs") + + val service = globalRecordingService + if (service == null) { + Log.w(TAG, "โš ๏ธ finalizeChunk: Service not bound") + return@async null + } + + val chunkFile = service.finalizeChunk() + + if (chunkFile == null) { + Log.w(TAG, "โš ๏ธ finalizeChunk: No chunk file returned") + return@async null + } + + // Wait for file to settle + delay(settledTimeMs.toLong()) + + // Store as last recording for retrieval + lastGlobalRecording = chunkFile + + // Get audio file if extracted + val audioFile = service.getLastAudioFile() + lastGlobalAudioRecording = audioFile + + // Build audio file info if available + val audioFileInfo = audioFile?.let { af -> + if (af.exists()) { + AudioRecordingFile( + path = "file://${af.absolutePath}", + name = af.name, + size = af.length().toDouble(), + duration = RecorderUtils.getAudioDuration(af) + ) + } else { + null + } + } + + // Return the chunk file with audio if available + return@async if (chunkFile.exists()) { + ScreenRecordingFile( + path = "file://${chunkFile.absolutePath}", + name = chunkFile.name, + size = chunkFile.length().toDouble(), + duration = RecorderUtils.getVideoDuration(chunkFile), + enabledMicrophone = service.isMicrophoneEnabled(), + audioFile = audioFileInfo, + appAudioFile = null + ) + } else { + null + } + } + } + + // --- Extension Status --- + + override fun getExtensionStatus(): RawExtensionStatus { + val service = globalRecordingService + return RawExtensionStatus( + isMicrophoneEnabled = service?.isMicrophoneEnabled() ?: false, + isCapturingChunk = service?.isCapturingChunk() ?: false, + chunkStartedAt = service?.getChunkStartedAt() ?: 0.0, + captureMode = service?.getCaptureMode() ?: CaptureMode.UNKNOWN + ) + } + + override fun isScreenBeingRecorded(): Boolean { + val service = globalRecordingService + val hasSession = service?.hasActiveSession() == true + val isRecording = service?.isCurrentlyRecording() == true + val ctx = NitroModules.applicationContext + val serviceRunning = if (ctx != null) isServiceRunning(ctx) else false + + // Log for debugging + Log.d(TAG, "๐Ÿ“Š isScreenBeingRecorded: hasSession=$hasSession, isRecording=$isRecording, serviceRunning=$serviceRunning, isBound=$isServiceBound") + + // Return true if we have an active MediaProjection session (even if paused between chunks) + if (hasSession) { + return true + } + + // Fallback: check if the service is running even if we're not bound + if (ctx == null) return false + + // If service is running but we're not bound, try to rebind + if (serviceRunning && !isServiceBound) { + Log.d(TAG, "๐Ÿ“ก Service running but not bound, attempting rebind...") + rebindToExistingService(ctx) + } + + return serviceRunning } } diff --git a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/RecorderUtils.kt b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/RecorderUtils.kt index e0ce4ea..2a5843f 100644 --- a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/RecorderUtils.kt +++ b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/RecorderUtils.kt @@ -102,6 +102,25 @@ object RecorderUtils { return file } + /** + * Creates a new audio file in the specified directory. + */ + fun createAudioOutputFile(directory: File, prefix: String): File { + Log.d(TAG, "๐Ÿ“ Creating audio output file with prefix '$prefix'...") + if (!directory.exists()) { + Log.d(TAG, "๐Ÿ“ Creating directory: ${directory.absolutePath}") + directory.mkdirs() + } + + val timestamp = + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + // Use .m4a extension for extracted audio (AAC in MPEG-4 container) + val fileName = "${prefix}_$timestamp.m4a" + val file = File(directory, fileName) + Log.d(TAG, "๐Ÿ“ Created audio output file: ${file.absolutePath}") + return file + } + /** * Retrieves the duration of a video file in **seconds**. */ @@ -178,13 +197,131 @@ object RecorderUtils { } /** - * Deletes all .mp4 files in a given directory. + * Retrieves the duration of an audio file in **seconds**. + */ + fun getAudioDuration(file: File): Double { + if (!file.exists()) return 0.0 + return try { + val retriever = MediaMetadataRetriever().apply { + setDataSource(file.absolutePath) + } + val seconds = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toDouble() + ?.div(1_000.0) ?: 0.0 + retriever.release() + seconds + } catch (e: Exception) { + Log.w(TAG, "Could not get audio duration: ${e.message}") + 0.0 + } + } + + /** + * Extracts the audio track from a video file and saves it as a separate M4A file. + * This allows having audio in both the video AND a separate audio file. + * + * @param videoFile The source video file containing audio + * @param audioOutputFile The destination file for the extracted audio + * @return true if extraction was successful, false otherwise + */ + fun extractAudioFromVideo(videoFile: File, audioOutputFile: File): Boolean { + if (!videoFile.exists()) { + Log.e(TAG, "โŒ Video file does not exist: ${videoFile.absolutePath}") + return false + } + + var extractor: android.media.MediaExtractor? = null + var muxer: android.media.MediaMuxer? = null + + try { + Log.d(TAG, "๐ŸŽต Extracting audio from video: ${videoFile.name}") + + extractor = android.media.MediaExtractor() + extractor.setDataSource(videoFile.absolutePath) + + // Find the audio track + var audioTrackIndex = -1 + var audioFormat: android.media.MediaFormat? = null + + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(android.media.MediaFormat.KEY_MIME) + if (mime?.startsWith("audio/") == true) { + audioTrackIndex = i + audioFormat = format + break + } + } + + if (audioTrackIndex == -1 || audioFormat == null) { + Log.w(TAG, "โš ๏ธ No audio track found in video file") + return false + } + + Log.d(TAG, "๐Ÿ“ Found audio track at index $audioTrackIndex") + + // Select the audio track + extractor.selectTrack(audioTrackIndex) + + // Create muxer for output + muxer = android.media.MediaMuxer( + audioOutputFile.absolutePath, + android.media.MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 + ) + + val outputTrackIndex = muxer.addTrack(audioFormat) + muxer.start() + + // Allocate buffer for reading samples + val maxBufferSize = audioFormat.getInteger(android.media.MediaFormat.KEY_MAX_INPUT_SIZE, 1024 * 1024) + val buffer = java.nio.ByteBuffer.allocate(maxBufferSize) + val bufferInfo = android.media.MediaCodec.BufferInfo() + + // Copy all audio samples + while (true) { + val sampleSize = extractor.readSampleData(buffer, 0) + if (sampleSize < 0) { + break + } + + bufferInfo.offset = 0 + bufferInfo.size = sampleSize + bufferInfo.presentationTimeUs = extractor.sampleTime + bufferInfo.flags = extractor.sampleFlags + + muxer.writeSampleData(outputTrackIndex, buffer, bufferInfo) + extractor.advance() + } + + Log.d(TAG, "โœ… Audio extraction complete: ${audioOutputFile.name}") + return true + + } catch (e: Exception) { + Log.e(TAG, "โŒ Error extracting audio: ${e.message}") + e.printStackTrace() + // Clean up failed output file + audioOutputFile.delete() + return false + } finally { + try { + muxer?.stop() + muxer?.release() + } catch (e: Exception) { + Log.w(TAG, "Warning during muxer cleanup: ${e.message}") + } + extractor?.release() + } + } + + /** + * Deletes all .mp4 and .m4a files in a given directory. */ fun clearDirectory(directory: File) { Log.d(TAG, "๐Ÿงน Clearing directory: ${directory.absolutePath}") if (directory.exists() && directory.isDirectory) { directory.listFiles()?.forEach { file -> - if (file.isFile && file.name.endsWith(".mp4")) { + if (file.isFile && (file.name.endsWith(".mp4") || file.name.endsWith(".m4a"))) { if (file.delete()) { Log.d(TAG, "๐Ÿ—‘๏ธ Deleted file: ${file.name}") } else { diff --git a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/ScreenRecordingService.kt b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/ScreenRecordingService.kt index 8ecc74f..6e3417a 100644 --- a/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/ScreenRecordingService.kt +++ b/android/src/main/java/com/margelo/nitro/nitroscreenrecorder/ScreenRecordingService.kt @@ -8,10 +8,14 @@ import android.hardware.display.VirtualDisplay import android.media.MediaRecorder import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager +import android.content.pm.ServiceInfo import android.os.Binder import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.util.Log +import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import com.margelo.nitro.nitroscreenrecorder.utils.RecorderUtils import java.io.File @@ -24,11 +28,28 @@ class ScreenRecordingService : Service() { private var isRecording = false private var currentRecordingFile: File? = null private var enableMic = false + + // Separate audio file extraction (done post-recording) + private var separateAudioFile = false + private var currentAudioFile: File? = null + + // Chunking state + // NOTE: On Android 14+ (API 34+), MediaProjection only allows ONE VirtualDisplay per token. + // We use timestamp-based chunking: markChunkStart records timestamp, finalizeChunk stops & returns file. + private var isCapturing = false + private var chunkStartedAt: Double = 0.0 + private var recordingStartedAt: Double = 0.0 + + // Capture mode (Android 14+) + // Defaults to ENTIRESCREEN on Android 14+, updated to SINGLEAPP when visibility callback fires with false + private var captureMode: CaptureMode = CaptureMode.UNKNOWN + private var isSingleAppMode = false private var screenWidth = 0 private var screenHeight = 0 private var screenDensity = 0 private var startId: Int = -1 + private val mainHandler = Handler(Looper.getMainLooper()) private val binder = LocalBinder() @@ -39,6 +60,37 @@ class ScreenRecordingService : Service() { stopRecording() } } + + // Android 14+ (API 34): Called when captured content visibility changes + // On Android 14/15, this callback fires for BOTH modes: + // - isVisible=true fires initially for both modes + // - isVisible=false ONLY fires in single-app mode when user navigates away + // So we can only reliably detect single-app mode when isVisible=false + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onCapturedContentVisibilityChanged(isVisible: Boolean) { + super.onCapturedContentVisibilityChanged(isVisible) + Log.d(TAG, "๐Ÿ“ฑ Captured content visibility changed: isVisible=$isVisible") + + if (!isVisible) { + // isVisible=false ONLY happens in single-app mode when user navigates away + Log.d(TAG, "๐Ÿ“ฑ isVisible=false โ†’ Confirmed SINGLE APP mode") + isSingleAppMode = true + captureMode = CaptureMode.SINGLEAPP + } else { + // isVisible=true fires for both modes initially, not conclusive + Log.d(TAG, "๐Ÿ“ฑ isVisible=true โ†’ Could be either mode, keeping current: $captureMode") + } + } + + // Android 14+ (API 34): Called when captured content is resized + // NOTE: This callback fires for both entire screen and single-app mode with varying dimensions + // due to status bar, navigation bar, etc. It's NOT a reliable indicator of capture mode. + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun onCapturedContentResize(width: Int, height: Int) { + super.onCapturedContentResize(width, height) + Log.d(TAG, "๐Ÿ“ฑ Captured content resized: ${width}x${height} (screen: ${screenWidth}x${screenHeight})") + // Don't use this to determine capture mode - onCapturedContentVisibilityChanged is the reliable indicator + } } companion object { @@ -47,9 +99,12 @@ class ScreenRecordingService : Service() { private const val CHANNEL_ID = "screen_recording_channel" const val ACTION_START_RECORDING = "START_RECORDING" const val ACTION_STOP_RECORDING = "STOP_RECORDING" + const val ACTION_MARK_CHUNK_START = "MARK_CHUNK_START" + const val ACTION_FINALIZE_CHUNK = "FINALIZE_CHUNK" const val EXTRA_RESULT_CODE = "RESULT_CODE" const val EXTRA_RESULT_DATA = "RESULT_DATA" const val EXTRA_ENABLE_MIC = "ENABLE_MIC" + const val EXTRA_SEPARATE_AUDIO = "SEPARATE_AUDIO" } inner class LocalBinder : Binder() { @@ -88,14 +143,29 @@ class ScreenRecordingService : Service() { intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED) val resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA) val enableMicrophone = intent.getBooleanExtra(EXTRA_ENABLE_MIC, false) + val separateAudio = intent.getBooleanExtra(EXTRA_SEPARATE_AUDIO, false) Log.d( TAG, - "๐ŸŽฌ Start recording: resultCode=$resultCode, enableMic=$enableMicrophone" + "๐ŸŽฌ Start recording: resultCode=$resultCode, enableMic=$enableMicrophone, separateAudio=$separateAudio" ) + // CRITICAL: Call startForeground IMMEDIATELY to satisfy Android's timing requirements + // This must happen within 5 seconds of startForegroundService() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val serviceType = if (enableMicrophone) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } + startForeground(NOTIFICATION_ID, createForegroundNotification(false), serviceType) + } else { + startForeground(NOTIFICATION_ID, createForegroundNotification(false)) + } + Log.d(TAG, "โœ… startForeground called immediately in onStartCommand") + if (resultData != null) { - startRecording(resultCode, resultData, enableMicrophone) + startRecording(resultCode, resultData, enableMicrophone, separateAudio) } else { Log.e(TAG, "โŒ ResultData is null, cannot start recording") } @@ -104,6 +174,14 @@ class ScreenRecordingService : Service() { Log.d(TAG, "๐Ÿ›‘ Stop recording action received") stopRecording() } + ACTION_MARK_CHUNK_START -> { + Log.d(TAG, "๐Ÿ“ Mark chunk start action received") + markChunkStart() + } + ACTION_FINALIZE_CHUNK -> { + Log.d(TAG, "๐Ÿ“ฆ Finalize chunk action received") + // Note: finalizeChunk returns file synchronously, but we handle it via callback + } } return START_NOT_STICKY @@ -141,22 +219,46 @@ class ScreenRecordingService : Service() { fun startRecording( resultCode: Int, resultData: Intent, - enableMicrophone: Boolean + enableMicrophone: Boolean, + separateAudio: Boolean = false ) { Log.d( TAG, - "๐ŸŽฌ startRecording called: resultCode=$resultCode, enableMic=$enableMicrophone" + "๐ŸŽฌ startRecording called: resultCode=$resultCode, enableMic=$enableMicrophone, separateAudio=$separateAudio" ) if (isRecording) { Log.w(TAG, "โš ๏ธ Already recording") return } + + // If there's an existing MediaProjection (e.g., from before hot reload), clean it up first + if (mediaProjection != null) { + Log.w(TAG, "๐Ÿงน Cleaning up stale MediaProjection before starting new recording") + cleanup() + } try { this.enableMic = enableMicrophone + this.separateAudioFile = separateAudio + + // Reset chunking state + isCapturing = false + chunkStartedAt = 0.0 + + // On Android 14+, we default to ENTIRESCREEN. + // If user selected single-app mode and navigates away, onCapturedContentVisibilityChanged(false) + // will fire and we'll update to SINGLEAPP. + // On older Android versions, capture mode is always UNKNOWN (no user choice). + captureMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + CaptureMode.ENTIRESCREEN + } else { + CaptureMode.UNKNOWN + } + isSingleAppMode = false - startForeground(NOTIFICATION_ID, createForegroundNotification(false)) + // Note: startForeground is now called in onStartCommand before this method + // to satisfy Android's timing requirements val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager @@ -164,7 +266,7 @@ class ScreenRecordingService : Service() { mediaProjectionManager.getMediaProjection(resultCode, resultData) // Register the callback BEFORE creating VirtualDisplay - mediaProjection?.registerCallback(mediaProjectionCallback, null) + mediaProjection?.registerCallback(mediaProjectionCallback, mainHandler) // write into the app-specific external cache (no runtime READ_EXTERNAL_STORAGE needed) val base = applicationContext.externalCacheDir @@ -173,6 +275,8 @@ class ScreenRecordingService : Service() { currentRecordingFile = RecorderUtils.createOutputFile(recordingsDir, "global_recording") + // Record video with audio embedded normally + // If separateAudioFile is requested, we'll extract it after recording stops mediaRecorder = RecorderUtils.setupMediaRecorder( this, enableMicrophone, @@ -196,6 +300,7 @@ class ScreenRecordingService : Service() { mediaRecorder?.start() isRecording = true + recordingStartedAt = System.currentTimeMillis() / 1000.0 val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.notify(NOTIFICATION_ID, createForegroundNotification(true)) @@ -207,6 +312,7 @@ class ScreenRecordingService : Service() { NitroScreenRecorder.notifyGlobalRecordingEvent(event) Log.d(TAG, "๐ŸŽ‰ Global screen recording started successfully") + Log.d(TAG, "๐Ÿ“บ Capture mode: $captureMode (will update to SINGLEAPP if user navigates away)") } catch (e: Exception) { Log.e(TAG, "โŒ Error starting global recording: ${e.message}") @@ -224,12 +330,27 @@ class ScreenRecordingService : Service() { fun stopRecording(): File? { Log.d(TAG, "๐Ÿ›‘ stopRecording called") + // Handle case where we're paused between chunks (no active recorder but session exists) + if (!isRecording && mediaProjection != null) { + Log.d(TAG, "๐Ÿ“ Stopping paused session (no active recorder)") + val event = ScreenRecordingEvent( + type = RecordingEventType.GLOBAL, + reason = RecordingEventReason.ENDED + ) + NitroScreenRecorder.notifyGlobalRecordingEvent(event) + cleanup() + stopForeground(true) + stopSelf(this.startId) + return null + } + if (!isRecording) { - Log.w(TAG, "โš ๏ธ Not recording") + Log.w(TAG, "โš ๏ธ Not recording and no session") return null } var recordingFile: File? = null + var audioFile: File? = null try { mediaRecorder?.stop() @@ -241,12 +362,29 @@ class ScreenRecordingService : Service() { recordingFile = RecorderUtils.optimizeForStreaming(it) } + // Extract audio to separate file if requested and mic was enabled + if (separateAudioFile && enableMic && recordingFile != null) { + val base = applicationContext.externalCacheDir ?: applicationContext.filesDir + val recordingsDir = File(base, "recordings") + currentAudioFile = RecorderUtils.createAudioOutputFile(recordingsDir, "global_recording_audio") + + val extracted = RecorderUtils.extractAudioFromVideo(recordingFile!!, currentAudioFile!!) + if (extracted) { + audioFile = currentAudioFile + Log.d(TAG, "๐ŸŽต Audio extracted to separate file: ${audioFile?.absolutePath}") + } else { + Log.w(TAG, "โš ๏ธ Failed to extract audio from video") + currentAudioFile?.delete() + currentAudioFile = null + } + } + val event = ScreenRecordingEvent( type = RecordingEventType.GLOBAL, reason = RecordingEventReason.ENDED ) recordingFile?.let { - NitroScreenRecorder.notifyGlobalRecordingFinished(it, event, enableMic) + NitroScreenRecorder.notifyGlobalRecordingFinished(it, audioFile, event, enableMic) } Log.d(TAG, "๐ŸŽ‰ Global screen recording stopped successfully") @@ -268,6 +406,201 @@ class ScreenRecordingService : Service() { return recordingFile } + /** + * Marks the start of a new chunk using VirtualDisplay.setSurface() for seamless swap. + * + * On Android 14+ (API 34+), you cannot create multiple VirtualDisplays from the same + * MediaProjection token. However, you CAN swap the surface on an existing VirtualDisplay + * using setSurface(). This allows seamless chunking: + * + * 1. Create a new MediaRecorder with a new output file + * 2. Swap the VirtualDisplay surface to the new recorder's surface + * 3. Start the new recorder, stop the old one (if any) + * 4. Discard the pre-chunk content (old file) + * + * Can be called: + * - After startGlobalRecording() to begin first chunk (discards pre-chunk content) + * - After finalizeChunk() to begin next chunk (VirtualDisplay still exists, no active recorder) + */ + fun markChunkStart() { + Log.d(TAG, "๐Ÿ“ markChunkStart called") + + if (virtualDisplay == null) { + Log.w(TAG, "โš ๏ธ markChunkStart: No VirtualDisplay - call startGlobalRecording first") + val error = RecordingError( + name = "ChunkStartError", + message = "No active recording session. Call startGlobalRecording first." + ) + NitroScreenRecorder.notifyGlobalRecordingError(error) + return + } + + try { + // Save references to old recorder/file (may be null if coming from finalizeChunk) + val oldRecorder = mediaRecorder + val oldFile = currentRecordingFile + + // Create new recording file for the chunk + val base = applicationContext.externalCacheDir ?: applicationContext.filesDir + val recordingsDir = File(base, "recordings") + val newRecordingFile = RecorderUtils.createOutputFile(recordingsDir, "chunk") + + // Create and prepare new MediaRecorder + val newRecorder = RecorderUtils.setupMediaRecorder( + this, + enableMic, + newRecordingFile, + screenWidth, + screenHeight, + 8 * 1024 * 1024 + ) + newRecorder.prepare() + + // IMPORTANT: Start the new recorder BEFORE swapping the surface + // This ensures frames are written to a fully initialized recorder + newRecorder.start() + Log.d(TAG, "๐Ÿ“ New chunk recorder started") + + // SEAMLESS SWAP: Update the VirtualDisplay surface to point to new recorder + // This redirects the screen capture to the running recorder + virtualDisplay?.setSurface(newRecorder.surface) + Log.d(TAG, "๐Ÿ“ VirtualDisplay surface swapped to new recorder") + + // Update references + mediaRecorder = newRecorder + currentRecordingFile = newRecordingFile + isRecording = true + + // Clean up old recorder if it exists (content is pre-chunk, discard it) + if (oldRecorder != null) { + try { + oldRecorder.stop() + oldRecorder.release() + // Delete the old file - this was pre-chunk content + oldFile?.delete() + Log.d(TAG, "๐Ÿ“ Old recorder stopped and pre-chunk content discarded") + } catch (e: Exception) { + Log.w(TAG, "โš ๏ธ Error stopping old recorder: ${e.message}") + // Still try to delete the file + oldFile?.delete() + } + } else { + Log.d(TAG, "๐Ÿ“ No old recorder (starting fresh chunk after finalize)") + } + + // Update chunking state + isCapturing = true + chunkStartedAt = System.currentTimeMillis() / 1000.0 + + Log.d(TAG, "๐Ÿ“ Chunk started at $chunkStartedAt (seamless surface swap)") + + } catch (e: Exception) { + Log.e(TAG, "โŒ Error in markChunkStart: ${e.message}") + e.printStackTrace() + val error = RecordingError( + name = "ChunkStartError", + message = e.message ?: "Failed to start chunk" + ) + NitroScreenRecorder.notifyGlobalRecordingError(error) + } + } + + /** + * Finalizes the current chunk by stopping the recorder and returning the file. + * + * The MediaProjection and VirtualDisplay remain active, allowing you to call + * markChunkStart() again to begin recording a new chunk without re-prompting + * the user for permission. + * + * Flow: startGlobalRecording -> markChunkStart -> finalizeChunk -> markChunkStart -> finalizeChunk -> ... -> stopGlobalRecording + */ + fun finalizeChunk(): File? { + Log.d(TAG, "๐Ÿ“ฆ finalizeChunk called") + + if (!isCapturing) { + Log.w(TAG, "โš ๏ธ finalizeChunk: Not currently capturing a chunk (markChunkStart not called)") + return null + } + + if (mediaRecorder == null) { + Log.w(TAG, "โš ๏ธ finalizeChunk: No active recorder") + return null + } + + var chunkFile: File? = null + + try { + val chunkEndedAt = System.currentTimeMillis() / 1000.0 + val chunkDuration = chunkEndedAt - chunkStartedAt + Log.d(TAG, "๐Ÿ“ฆ Chunk duration: ${chunkDuration}s (from $chunkStartedAt to $chunkEndedAt)") + + // IMPORTANT: Set surface to null FIRST to stop receiving new frames + // This prevents frames from being sent to a recorder that's being stopped + virtualDisplay?.setSurface(null) + Log.d(TAG, "๐Ÿ“ฆ VirtualDisplay surface cleared (paused)") + + // Now stop and release the recorder + mediaRecorder?.stop() + mediaRecorder?.release() + mediaRecorder = null + + chunkFile = currentRecordingFile + currentRecordingFile = null + + // Optimize for streaming + chunkFile?.let { + chunkFile = RecorderUtils.optimizeForStreaming(it) + } + + // Extract audio to separate file if requested and mic was enabled + if (separateAudioFile && enableMic && chunkFile != null) { + val base = applicationContext.externalCacheDir ?: applicationContext.filesDir + val recordingsDir = File(base, "recordings") + val audioFile = RecorderUtils.createAudioOutputFile(recordingsDir, "chunk_audio") + + val extracted = RecorderUtils.extractAudioFromVideo(chunkFile!!, audioFile) + if (extracted) { + currentAudioFile = audioFile + Log.d(TAG, "๐ŸŽต Chunk audio extracted to: ${audioFile.absolutePath}") + } else { + Log.w(TAG, "โš ๏ธ Failed to extract audio from chunk") + audioFile.delete() + } + } + + // Update state - chunk done, but recording session still active + isCapturing = false + chunkStartedAt = 0.0 + // Keep isRecording = false to indicate paused state + // MediaProjection stays alive for next markChunkStart() + isRecording = false + + Log.d(TAG, "๐Ÿ“ฆ Chunk finalized: ${chunkFile?.absolutePath}") + Log.d(TAG, "โ„น๏ธ Call markChunkStart() to begin next chunk, or stopGlobalRecording() to end session") + + } catch (e: Exception) { + Log.e(TAG, "โŒ Error in finalizeChunk: ${e.message}") + e.printStackTrace() + val error = RecordingError( + name = "ChunkFinalizeError", + message = e.message ?: "Failed to finalize chunk" + ) + NitroScreenRecorder.notifyGlobalRecordingError(error) + } + + return chunkFile + } + + // Status getters for NitroScreenRecorder + fun isCapturingChunk(): Boolean = isCapturing + fun getChunkStartedAt(): Double = chunkStartedAt + fun getCaptureMode(): CaptureMode = captureMode + fun isMicrophoneEnabled(): Boolean = enableMic + fun getLastAudioFile(): File? = currentAudioFile + + /** Returns true if we have an active MediaProjection session (even if paused between chunks) */ + fun hasActiveSession(): Boolean = mediaProjection != null + private fun cleanup() { Log.d(TAG, "๐Ÿงน cleanup() called") @@ -276,6 +609,19 @@ class ScreenRecordingService : Service() { virtualDisplay = null mediaRecorder?.release() mediaRecorder = null + + // Reset audio file state + currentAudioFile = null + separateAudioFile = false + + // Reset chunking state + isCapturing = false + chunkStartedAt = 0.0 + recordingStartedAt = 0.0 + + // Reset capture mode + captureMode = CaptureMode.UNKNOWN + isSingleAppMode = false // Unregister callback before stopping MediaProjection mediaProjection?.unregisterCallback(mediaProjectionCallback) @@ -295,4 +641,15 @@ class ScreenRecordingService : Service() { cleanup() super.onDestroy() } + + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(TAG, "๐Ÿšจ onTaskRemoved called - app swiped away from recents") + // Don't cleanup here - keep recording even if app is swiped away + super.onTaskRemoved(rootIntent) + } + + override fun onTrimMemory(level: Int) { + Log.d(TAG, "๐Ÿ’พ onTrimMemory called with level: $level") + super.onTrimMemory(level) + } } diff --git a/example/app.json b/example/app.json index 6fa8457..c60d710 100644 --- a/example/app.json +++ b/example/app.json @@ -21,11 +21,11 @@ "**/*" ], "ios": { - "bundleIdentifier": "nitroscreenrecorderexample.example", + "bundleIdentifier": "com.listenlabs.screenrecorderexample", "infoPlist": { "NSMicrophoneUsageDescription": "ScreenRecorder needs access to your Microphone." }, - "appleTeamId": "32U9AXHQSK" + "appleTeamId": "96YBPK674P" }, "android": { "package": "nitroscreenrecorderexample.example", diff --git a/example/package.json b/example/package.json index b4b6898..5471672 100644 --- a/example/package.json +++ b/example/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "expo": "54.0.21", + "expo-camera": "^17.0.10", "expo-dev-client": "~6.0.10", "expo-status-bar": "~3.0.7", "expo-video": "~3.0.12", diff --git a/example/src/App.tsx b/example/src/App.tsx index 1672c25..5761f2d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,339 +1,3069 @@ import { View, StyleSheet, - Button, Text, ScrollView, Platform, + TouchableOpacity, + Alert, + Animated, + Dimensions, + Easing, } from 'react-native'; import * as ScreenRecorder from '../../'; import { useVideoPlayer, VideoView } from 'expo-video'; -import { useState } from 'react'; -import { getVideoInfoAsync } from 'expo-video-metadata'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { CameraView, useCameraPermissions } from 'expo-camera'; + +const MIC_FAILURE_DELAY_MS = 1500; // Wait 1.5s before marking mic failure +const THERMAL_STRESS_CHUNK_MS = 150; +const THERMAL_STRESS_PAUSE_MS = 1; +const GPU_LAYER_COUNT = 28; +const GPU_LAYER_MIN_SIZE = 80; +const GPU_LAYER_MAX_SIZE = 200; +const GPU_LAYER_OPACITY_MIN = 0.08; +const GPU_LAYER_OPACITY_MAX = 0.35; +const GPU_OVERLAY_OPACITY = 0.35; +const GPU_ANIMATION_DURATION_MS = 5000; +const NETWORK_STRESS_URL = 'https://speed.cloudflare.com/__down?bytes=8000000'; +const NETWORK_STRESS_CONCURRENCY = 2; +const NETWORK_STRESS_PAUSE_MS = 200; +const MEMORY_STRESS_CHUNK_MB = 12; +const MEMORY_STRESS_MAX_BUFFERS = 6; +const MEMORY_STRESS_INTERVAL_MS = 500; + +/** + * Dev-only hook to cleanup stale Android recording sessions after hot reload. + * In production, this is a no-op since hot reload doesn't exist. + */ +const useDevCleanup = () => { + useEffect(() => { + if (__DEV__ && Platform.OS === 'android') { + console.log('๐Ÿงน [Dev] Cleaning up any stale recording sessions...'); + ScreenRecorder.stopGlobalRecording({ settledTimeMs: 100 }) + .then(() => { + console.log('๐Ÿงน [Dev] Cleanup complete (session was active)'); + }) + .catch(() => { + console.log('๐Ÿงน [Dev] Cleanup complete (no active session)'); + }); + } + }, []); +}; + +type Chunk = { + id: number; + file: ScreenRecorder.ScreenRecordingFile; + timestamp: Date; +}; export default function App() { + // Dev-only: cleanup stale sessions after hot reload (Android) + useDevCleanup(); + + // In-app recording state const [inAppRecording, setInAppRecording] = useState< ScreenRecorder.ScreenRecordingFile | undefined >(); + // Global recording state const [globalRecording, setGlobalRecording] = useState< ScreenRecorder.ScreenRecordingFile | undefined >(); - const { isRecording } = ScreenRecorder.useGlobalRecording({ + // Chunking state + const [chunks, setChunks] = useState([]); + const [chunkCounter, setChunkCounter] = useState(0); + const [isChunkingActive, setIsChunkingActive] = useState(false); + const [selectedChunk, setSelectedChunk] = useState(); + + // Mic detection gating state + const [hadMicFailure, setHadMicFailure] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const micFailureTimeoutRef = useRef | null>( + null + ); + const hasStoppedForMicFailureRef = useRef(false); + + // Use the hook - it handles extension status polling while recording + const { isRecording, extensionStatus } = ScreenRecorder.useGlobalRecording({ onRecordingStarted: () => { - console.log('Recording started'); + console.log('๐ŸŽฌ Recording started'); + hasStoppedForMicFailureRef.current = false; + setHadMicFailure(false); }, onRecordingFinished: () => { - console.log('Recording ended'); + console.log('๐Ÿ›‘ Recording ended'); + setIsChunkingActive(false); + hasStoppedForMicFailureRef.current = false; }, onBroadcastModalShown: () => { - console.log('Modal showing'); + console.log('๐Ÿ“ฑ Modal showing'); }, onBroadcastModalDismissed: () => { - console.log('Modal dismissed'); + console.log('๐Ÿ“ฑ Modal dismissed'); }, }); - // @ts-ignore - const inAppPlayer = useVideoPlayer(inAppRecording?.path); - // @ts-ignore - const globalPlayer = useVideoPlayer(globalRecording?.path); + + // Mic detection gating logic + const isMicEnabled = + Platform.OS === 'android' + ? isRecording + : extensionStatus.isMicrophoneEnabled; + + const isReady = isRecording && isMicEnabled; + + // Extension is actually running (not just starting) + const extensionRunning = + extensionStatus.state === 'running' || + extensionStatus.state === 'capturingChunk'; + const currentMicFailure = isRecording && extensionRunning && !isMicEnabled; + + // Detect mic failure with delay to avoid false positives + useEffect(() => { + if (currentMicFailure && !hadMicFailure) { + if (micFailureTimeoutRef.current) { + clearTimeout(micFailureTimeoutRef.current); + } + + micFailureTimeoutRef.current = setTimeout(() => { + console.log( + 'โš ๏ธ Mic failure detected - recording started without microphone enabled' + ); + setHadMicFailure(true); + micFailureTimeoutRef.current = null; + }, MIC_FAILURE_DELAY_MS); + } else if (!currentMicFailure && micFailureTimeoutRef.current) { + clearTimeout(micFailureTimeoutRef.current); + micFailureTimeoutRef.current = null; + } + + return () => { + if (micFailureTimeoutRef.current) { + clearTimeout(micFailureTimeoutRef.current); + micFailureTimeoutRef.current = null; + } + }; + }, [currentMicFailure, hadMicFailure]); + + // Log when recording becomes ready (mic enabled) + const wasReadyRef = useRef(false); + useEffect(() => { + if (isReady && !wasReadyRef.current) { + console.log('โœ… Recording ready with mic enabled'); + wasReadyRef.current = true; + } else if (!isRecording && wasReadyRef.current) { + wasReadyRef.current = false; // Reset when recording stops + } + }, [isReady, isRecording]); + + // Clear mic failure when recording becomes ready + useEffect(() => { + if (isReady && hadMicFailure) { + console.log('โœ… Mic now enabled, clearing failure state'); + setHadMicFailure(false); + } + }, [isReady, hadMicFailure]); + + // Auto-stop recording on mic failure + useEffect(() => { + if ( + hadMicFailure && + isRecording && + !isStopping && + !hasStoppedForMicFailureRef.current + ) { + console.log('๐Ÿ›‘ Auto-stopping recording due to mic not enabled'); + hasStoppedForMicFailureRef.current = true; + setIsStopping(true); + ScreenRecorder.stopGlobalRecording({ settledTimeMs: 500 }) + .then(() => { + console.log('โœ… Recording stopped after mic failure'); + setIsStopping(false); + }) + .catch((error) => { + console.error( + 'โŒ Failed to stop recording after mic failure:', + error + ); + setIsStopping(false); + }); + } + }, [hadMicFailure, isRecording, isStopping]); + + // Clear stopping state when recording ends + useEffect(() => { + if (!isRecording && isStopping) { + setIsStopping(false); + } + }, [isRecording, isStopping]); + + // Video players + const inAppPlayer = useVideoPlayer(inAppRecording?.path ?? null); + const globalPlayer = useVideoPlayer(globalRecording?.path ?? null); + const chunkPlayer = useVideoPlayer(selectedChunk?.file.path ?? null); // Permission Functions - const getCameraPermissionStatus = () => { - console.log('CAMERA STATUS:', ScreenRecorder.getCameraPermissionStatus()); + const requestPermissions = async () => { + const mic = await ScreenRecorder.requestMicrophonePermission(); + console.log('Mic permission:', mic.status); + if (Platform.OS === 'ios') { + const cam = await ScreenRecorder.requestCameraPermission(); + console.log('Camera permission:', cam.status); + } + Alert.alert('Permissions Requested', 'Check console for status'); }; - const getMicrophonePermissionStatus = () => { - console.log('MIC STATUS:', ScreenRecorder.getMicrophonePermissionStatus()); + // In-App Recording Functions + const handleStartInAppRecording = async () => { + try { + await ScreenRecorder.startInAppRecording({ + options: { + enableMic: true, + enableCamera: false, + }, + onRecordingFinished(file) { + console.log('โœ… In-app recording finished:', file.name); + setInAppRecording(file); + }, + }); + } catch (error) { + console.error('โŒ Error starting in-app recording:', error); + Alert.alert('Error', String(error)); + } }; - const requestCameraPermission = () => { - ScreenRecorder.requestCameraPermission().then((status) => { - console.log('Received Camera Status:', JSON.stringify(status, null, 2)); - }); + const handleStopInAppRecording = async () => { + const file = await ScreenRecorder.stopInAppRecording(); + if (file) { + setInAppRecording(file); + } }; - const requestMicrophonePermission = () => { - ScreenRecorder.requestMicrophonePermission().then((status) => { - console.log('Received Mic Status:', JSON.stringify(status, null, 2)); + // Global Recording Functions + const handleStartGlobalRecording = () => { + // Reset chunking state when starting new recording + setChunks([]); + setChunkCounter(0); + setIsChunkingActive(false); + setSelectedChunk(undefined); + + ScreenRecorder.startGlobalRecording({ + options: { + enableMic: true, + separateAudioFile: true, + }, + onRecordingError: (error) => { + console.error('โŒ Global recording error:', error); + Alert.alert('Recording Error', error.message); + }, }); }; - // Recording Options - const options: ScreenRecorder.InAppRecordingOptions = { - enableMic: true, - enableCamera: true, - cameraPreviewStyle: { - width: 150, - height: 200, - top: 30, - left: 20, - borderRadius: 10, - }, - cameraDevice: 'back', + const handleStopGlobalRecording = async () => { + const file = await ScreenRecorder.stopGlobalRecording(); + if (file) { + setGlobalRecording(file); + console.log('โœ… Global recording stopped:'); + console.log(` ๐Ÿ“น Video: ${file.path}`); + console.log(` ๐Ÿ“น Name: ${file.name}`); + console.log(` ๐Ÿ“น Size: ${(file.size / 1024).toFixed(1)} KB`); + console.log(` ๐Ÿ“น Duration: ${file.duration.toFixed(1)}s`); + if (file.audioFile) { + console.log(` ๐ŸŽต Audio: ${file.audioFile.path}`); + console.log(` ๐ŸŽต Audio Name: ${file.audioFile.name}`); + console.log( + ` ๐ŸŽต Audio Size: ${(file.audioFile.size / 1024).toFixed(1)} KB` + ); + console.log( + ` ๐ŸŽต Audio Duration: ${file.audioFile.duration.toFixed(1)}s` + ); + } else { + console.log(` ๐ŸŽต Audio: (none)`); + } + } + setIsChunkingActive(false); }; - // In-App Recording Functions - const handleStartInAppRecording = async () => { + const formatAudioMetrics = useCallback((metricsJson: string) => { try { - await ScreenRecorder.startInAppRecording({ - options, - onRecordingFinished(file) { + const parsed = JSON.parse(metricsJson); + return { + formatted: JSON.stringify(parsed, null, 2), + parsed, + }; + } catch (error) { + console.warn('Failed to parse audio metrics JSON', error); + return { + formatted: metricsJson, + parsed: undefined, + }; + } + }, []); + + const logAudioMetricsToConsole = useCallback( + (context?: string) => { + if (Platform.OS !== 'ios') { + return; + } + const metricsJson = ScreenRecorder.getExtensionAudioMetrics(); + const { parsed } = formatAudioMetrics(metricsJson); + const metricsArray = parsed?.metrics; + const latestMetrics = + Array.isArray(metricsArray) && metricsArray.length > 0 + ? metricsArray[metricsArray.length - 1] + : (parsed ?? metricsJson); + const label = context + ? `๐Ÿ“Š Extension audio metrics (${context})` + : '๐Ÿ“Š Extension audio metrics'; + console.log(label, latestMetrics); + }, + [formatAudioMetrics] + ); + + // Chunking Functions + const handleMarkChunkStart = useCallback(() => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + ScreenRecorder.markChunkStart(); + setIsChunkingActive(true); + console.log('๐Ÿ“ Chunk start marked'); + Alert.alert('Chunk Started', 'Recording content from this point...'); + }, [isRecording]); + + const handleFinalizeChunk = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + if (!isChunkingActive) { + Alert.alert('No Active Chunk', 'Call markChunkStart() first'); + return; + } + + console.log('๐Ÿ“ฆ Finalizing chunk...'); + const t0 = performance.now(); + // Note: This uses undefined chunkId since handleMarkChunkStart doesn't set one + const file = await ScreenRecorder.finalizeChunk(undefined, { + settledTimeMs: 1000, + }); + console.log( + `๐Ÿ“ฆ finalizeChunk took ${(performance.now() - t0).toFixed(0)}ms` + ); + logAudioMetricsToConsole('finalize chunk'); + + if (file) { + const newChunk: Chunk = { + id: chunkCounter + 1, + file, + timestamp: new Date(), + }; + setChunks((prev) => [...prev, newChunk]); + setChunkCounter((prev) => prev + 1); + setSelectedChunk(newChunk); + + // Log all file paths + console.log(`โœ… Chunk ${newChunk.id} finalized:`); + console.log(` ๐Ÿ“น Video: ${file.path}`); + console.log(` ๐Ÿ“น Name: ${file.name}`); + console.log(` ๐Ÿ“น Size: ${(file.size / 1024).toFixed(1)} KB`); + console.log(` ๐Ÿ“น Duration: ${file.duration.toFixed(1)}s`); + if (file.audioFile) { + console.log(` ๐ŸŽต Audio: ${file.audioFile.path}`); + console.log(` ๐ŸŽต Audio Name: ${file.audioFile.name}`); + console.log( + ` ๐ŸŽต Audio Size: ${(file.audioFile.size / 1024).toFixed(1)} KB` + ); + console.log( + ` ๐ŸŽต Audio Duration: ${file.audioFile.duration.toFixed(1)}s` + ); + } else { + console.log(` ๐ŸŽต Audio: (none)`); + } + + Alert.alert( + 'Chunk Finalized', + `Chunk ${newChunk.id} saved (${(file.size / 1024).toFixed(1)} KB, ${file.duration.toFixed(1)}s)${file.audioFile ? '\n๐ŸŽต Audio extracted' : ''}` + ); + } else { + console.log('โš ๏ธ No chunk file returned'); + // Dump extension logs on failure for debugging + if (Platform.OS === 'ios') { + console.log('๐Ÿ“œ Extension logs (last 15 entries):'); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + Alert.alert( + 'Error', + 'Failed to get chunk file. Check console for extension logs.' + ); + } + }, [isRecording, isChunkingActive, chunkCounter, logAudioMetricsToConsole]); + + const handleClearChunks = () => { + setChunks([]); + setChunkCounter(0); + setSelectedChunk(undefined); + ScreenRecorder.clearCache(); + console.log('๐Ÿ—‘๏ธ Chunks cleared'); + }; + + // ============================================================================ + // STRESS TESTS + // ============================================================================ + + const [isStressTesting, setIsStressTesting] = useState(false); + + // Extension logs state (iOS only) + const [extensionLogs, setExtensionLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); + const [audioMetricsJson, setAudioMetricsJson] = useState(''); + const [showAudioMetrics, setShowAudioMetrics] = useState(false); + + // Camera overlay state for lipsync testing + const [showCameraOverlay, setShowCameraOverlay] = useState(false); + const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + const [isThermalStressActive, setIsThermalStressActive] = useState(false); + const [isGpuStressActive, setIsGpuStressActive] = useState(false); + const [isNetworkStressActive, setIsNetworkStressActive] = useState(false); + const [isMemoryStressActive, setIsMemoryStressActive] = useState(false); + const gpuAnimationValue = useRef(new Animated.Value(0)).current; + const gpuAnimationRef = useRef(null); + const thermalStressStateRef = useRef<{ + active: boolean; + timer: ReturnType | null; + iteration: number; + lastValue: number; + }>({ + active: false, + timer: null, + iteration: 0, + lastValue: 2, + }); + const networkStressStateRef = useRef<{ + active: boolean; + controllers: Set; + iteration: number; + }>({ + active: false, + controllers: new Set(), + iteration: 0, + }); + const memoryStressStateRef = useRef<{ + active: boolean; + timer: ReturnType | null; + buffers: Uint8Array[]; + }>({ + active: false, + timer: null, + buffers: [], + }); + + const gpuLayers = useMemo(() => { + const { width, height } = Dimensions.get('window'); + return Array.from({ length: GPU_LAYER_COUNT }, (_, index) => { + const seed = index * 9973; + const rand = (offset: number) => + Math.abs(Math.sin(seed + offset * 13.37)) % 1; + const size = + GPU_LAYER_MIN_SIZE + + rand(1) * (GPU_LAYER_MAX_SIZE - GPU_LAYER_MIN_SIZE); + const dx = (rand(2) - 0.5) * width * 1.2; + const dy = (rand(3) - 0.5) * height * 1.2; + const rotate = rand(4) * 360; + const scale = 0.6 + rand(5) * 1.4; + const opacity = + GPU_LAYER_OPACITY_MIN + + rand(6) * (GPU_LAYER_OPACITY_MAX - GPU_LAYER_OPACITY_MIN); + const color = `rgba(${Math.floor(rand(7) * 255)}, ${Math.floor( + rand(8) * 255 + )}, ${Math.floor(rand(9) * 255)}, ${opacity.toFixed(2)})`; + const top = rand(10) * (height - size); + const left = rand(11) * (width - size); + return { size, dx, dy, rotate, scale, color, top, left }; + }); + }, []); + + const handleRefreshLogs = useCallback(() => { + const logs = ScreenRecorder.getExtensionLogs(); + setExtensionLogs(logs); + console.log(`๐Ÿ“œ Loaded ${logs.length} extension logs`); + }, []); + + const handleDumpAudioMetrics = useCallback(() => { + const metricsJson = ScreenRecorder.getExtensionAudioMetrics(); + const { parsed } = formatAudioMetrics(metricsJson); + const metricsArray = parsed?.metrics; + const latestMetrics = + Array.isArray(metricsArray) && metricsArray.length > 0 + ? metricsArray[metricsArray.length - 1] + : (parsed ?? metricsJson); + setAudioMetricsJson(JSON.stringify(latestMetrics, null, 2)); + setShowAudioMetrics(true); + console.log('๐Ÿ“Š Extension audio metrics (latest):', latestMetrics); + }, [formatAudioMetrics]); + + const runThermalStress = useCallback(() => { + if (!thermalStressStateRef.current.active) { + return; + } + + const start = performance.now(); + let n = thermalStressStateRef.current.lastValue; + let accumulator = 0; + + while (performance.now() - start < THERMAL_STRESS_CHUNK_MS) { + let isPrime = true; + for (let i = 2; i * i <= n; i++) { + if (n % i === 0) { + isPrime = false; + break; + } + } + if (isPrime) { + accumulator += n; + } + n += 1; + } + + thermalStressStateRef.current.lastValue = n + (accumulator % 2); + thermalStressStateRef.current.iteration += 1; + + if (thermalStressStateRef.current.iteration % 30 === 0) { + console.log( + `๐Ÿ”ฅ Thermal stress tick (${thermalStressStateRef.current.iteration})` + ); + } + + thermalStressStateRef.current.timer = setTimeout( + runThermalStress, + THERMAL_STRESS_PAUSE_MS + ); + }, []); + + const startGpuStress = useCallback(() => { + if (gpuAnimationRef.current) { + return; + } + setIsGpuStressActive(true); + gpuAnimationValue.setValue(0); + gpuAnimationRef.current = Animated.loop( + Animated.sequence([ + Animated.timing(gpuAnimationValue, { + toValue: 1, + duration: GPU_ANIMATION_DURATION_MS, + easing: Easing.linear, + useNativeDriver: true, + }), + Animated.timing(gpuAnimationValue, { + toValue: 0, + duration: GPU_ANIMATION_DURATION_MS, + easing: Easing.linear, + useNativeDriver: true, + }), + ]) + ); + gpuAnimationRef.current.start(); + }, [gpuAnimationValue]); + + const stopGpuStress = useCallback(() => { + gpuAnimationRef.current?.stop(); + gpuAnimationRef.current = null; + setIsGpuStressActive(false); + }, []); + + const runNetworkStressLoop = useCallback(async () => { + const state = networkStressStateRef.current; + while (state.active) { + const controller = new AbortController(); + state.controllers.add(controller); + try { + const response = await fetch(NETWORK_STRESS_URL, { + cache: 'no-store', + signal: controller.signal, + }); + await response.arrayBuffer(); + state.iteration += 1; + if (state.iteration % 5 === 0) { + console.log(`๐ŸŒ Network stress tick (${state.iteration})`); + } + } catch (error) { + if (!controller.signal.aborted) { + console.warn('๐ŸŒ Network stress error', error); + } + } finally { + state.controllers.delete(controller); + } + if (NETWORK_STRESS_PAUSE_MS > 0) { + await new Promise((resolve) => + setTimeout(resolve, NETWORK_STRESS_PAUSE_MS) + ); + } + } + }, []); + + const startNetworkStress = useCallback(() => { + const state = networkStressStateRef.current; + if (state.active) { + return; + } + state.active = true; + state.iteration = 0; + setIsNetworkStressActive(true); + for (let i = 0; i < NETWORK_STRESS_CONCURRENCY; i += 1) { + runNetworkStressLoop().catch((error) => { + console.warn('๐ŸŒ Network stress loop error', error); + }); + } + }, [runNetworkStressLoop]); + + const stopNetworkStress = useCallback(() => { + const state = networkStressStateRef.current; + state.active = false; + state.controllers.forEach((controller) => controller.abort()); + state.controllers.clear(); + setIsNetworkStressActive(false); + }, []); + + const startMemoryStress = useCallback(() => { + const state = memoryStressStateRef.current; + if (state.active) { + return; + } + state.active = true; + setIsMemoryStressActive(true); + state.timer = setInterval(() => { + if (!state.active) { + return; + } + try { + const chunkSizeBytes = MEMORY_STRESS_CHUNK_MB * 1024 * 1024; + const buffer = new Uint8Array(chunkSizeBytes); + buffer[0] = state.buffers.length % 255; + state.buffers.push(buffer); + if (state.buffers.length > MEMORY_STRESS_MAX_BUFFERS) { + state.buffers.shift(); + } + } catch (error) { + console.warn('๐Ÿง  Memory stress error', error); + } + }, MEMORY_STRESS_INTERVAL_MS); + }, []); + + const stopMemoryStress = useCallback(() => { + const state = memoryStressStateRef.current; + state.active = false; + if (state.timer) { + clearInterval(state.timer); + state.timer = null; + } + state.buffers = []; + setIsMemoryStressActive(false); + }, []); + + const startThermalStress = useCallback(() => { + if (thermalStressStateRef.current.active) { + return; + } + thermalStressStateRef.current.active = true; + thermalStressStateRef.current.iteration = 0; + setIsThermalStressActive(true); + runThermalStress(); + }, [runThermalStress]); + + const stopThermalStress = useCallback(() => { + thermalStressStateRef.current.active = false; + if (thermalStressStateRef.current.timer) { + clearTimeout(thermalStressStateRef.current.timer); + thermalStressStateRef.current.timer = null; + } + setIsThermalStressActive(false); + }, []); + + const startKitchenSinkStress = useCallback(() => { + startThermalStress(); + startGpuStress(); + startNetworkStress(); + startMemoryStress(); + }, [ + startGpuStress, + startMemoryStress, + startNetworkStress, + startThermalStress, + ]); + + const stopKitchenSinkStress = useCallback(() => { + stopThermalStress(); + stopGpuStress(); + stopNetworkStress(); + stopMemoryStress(); + }, [stopGpuStress, stopMemoryStress, stopNetworkStress, stopThermalStress]); + + useEffect(() => { + return () => { + stopThermalStress(); + stopGpuStress(); + stopNetworkStress(); + stopMemoryStress(); + }; + }, [stopGpuStress, stopMemoryStress, stopNetworkStress, stopThermalStress]); + + const stressTestRapidChunks = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Rapid Chunk Cycling'); + const results: { id: string; success: boolean; duration?: number }[] = []; + + for (let i = 1; i <= 5; i++) { + const chunkId = `rapid-${i}`; + console.log(` Starting chunk ${chunkId}...`); + ScreenRecorder.markChunkStart(chunkId); + + // Short recording (1 second) + await new Promise((r) => setTimeout(r, 1500)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const elapsed = (performance.now() - t0).toFixed(0); + results.push({ id: chunkId, success: !!file, duration: file?.duration }); + console.log( + ` Chunk ${chunkId}: ${file ? 'โœ…' : 'โŒ'} (${file?.duration?.toFixed(1) ?? 0}s) [${elapsed}ms]` + ); + + // On failure, dump extension logs immediately + if (!file && Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after ${chunkId} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-10).forEach((log) => console.log(` ${log}`)); + } + } + + const passed = results.filter((r) => r.success).length; + setIsStressTesting(false); + + // Always dump logs at end if any failures + if (passed < 5 && Platform.OS === 'ios') { + console.log('๐Ÿ“œ Full extension logs:'); + const logs = ScreenRecorder.getExtensionLogs(); + logs.forEach((log) => console.log(` ${log}`)); + } + + Alert.alert('Rapid Chunks', `${passed}/5 chunks retrieved successfully`); + }, [isRecording]); + + const stressTestDuplicateId = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Duplicate ID'); + + // First recording with ID "duplicate-test" + ScreenRecorder.markChunkStart('duplicate-test'); + await new Promise((r) => setTimeout(r, 1000)); + await ScreenRecorder.finalizeChunk('duplicate-test', { + settledTimeMs: 500, + }); + + // Second recording with SAME ID + ScreenRecorder.markChunkStart('duplicate-test'); + await new Promise((r) => setTimeout(r, 2000)); // Longer, different duration + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk('duplicate-test', { + settledTimeMs: 500, + }); + console.log( + ` finalizeChunk took ${(performance.now() - t0).toFixed(0)}ms` + ); + + // Should get the SECOND recording (newer), not the first + const isLonger = file && file.duration > 1.5; + console.log(` Duration: ${file?.duration?.toFixed(1)}s (expected >1.5s)`); + + setIsStressTesting(false); + Alert.alert( + 'Duplicate ID Test', + isLonger ? 'โœ… Got newer recording' : 'โŒ Got older recording or none' + ); + }, [isRecording]); + + const stressTestMissingId = useCallback(async () => { + console.log('๐Ÿงช Stress Test: Missing ID'); + + const file = ScreenRecorder.retrieveGlobalRecording( + 'this-id-does-not-exist' + ); + + if (file === null || file === undefined) { + console.log(' โœ… Correctly returned nil for missing ID'); + Alert.alert('Missing ID Test', 'โœ… Correctly returned nil'); + } else { + console.log(' โŒ Unexpectedly returned a file!'); + Alert.alert('Missing ID Test', 'โŒ Should have returned nil'); + } + }, []); + + const stressTestAudioPairing = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Audio Pairing'); + const results: { + id: string; + videoDuration: number; + audioDuration: number; + match: boolean; + }[] = []; + + for (let i = 1; i <= 3; i++) { + const chunkId = `audio-${i}`; + ScreenRecorder.markChunkStart(chunkId); + + // Different durations for each chunk + await new Promise((r) => setTimeout(r, 1000 * i)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const elapsed = (performance.now() - t0).toFixed(0); + + if (file && file.audioFile) { + const videoDur = file.duration; + const audioDur = file.audioFile.duration; + // Audio should roughly match video duration (within 0.5s) + const match = Math.abs(videoDur - audioDur) < 0.5; + results.push({ + id: chunkId, + videoDuration: videoDur, + audioDuration: audioDur, + match, + }); + console.log( + ` ${chunkId}: video=${videoDur.toFixed(1)}s, audio=${audioDur.toFixed(1)}s ${match ? 'โœ…' : 'โŒ'} [${elapsed}ms]` + ); + } else { + console.log(` ${chunkId}: โŒ No file returned [${elapsed}ms]`); + // Dump extension logs on failure + if (Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after ${chunkId} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + } + } + + const passed = results.filter((r) => r.match).length; + setIsStressTesting(false); + Alert.alert( + 'Audio Pairing', + `${passed}/${results.length} audio files match video duration` + ); + }, [isRecording]); + + const stressTestLongRecording = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Long Recording (10s)'); + + ScreenRecorder.markChunkStart('long-recording'); + console.log(' Recording for 10 seconds...'); + + await new Promise((r) => setTimeout(r, 10000)); + + console.log(' Finalizing...'); + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk('long-recording', { + settledTimeMs: 500, + }); + const elapsed = (performance.now() - t0).toFixed(0); + + setIsStressTesting(false); + + if (file) { + console.log(` โœ… Got file in ${elapsed}ms`); + console.log(` Duration: ${file.duration.toFixed(1)}s`); + console.log(` Size: ${(file.size / 1024 / 1024).toFixed(2)} MB`); + Alert.alert( + 'Long Recording', + `โœ… ${file.duration.toFixed(1)}s, ${(file.size / 1024 / 1024).toFixed(2)} MB\nFinalized in ${elapsed}ms` + ); + } else { + console.log(` โŒ No file returned after ${elapsed}ms`); + // Dump extension logs on failure + if (Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after Long Recording failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + Alert.alert('Long Recording', `โŒ No file returned after ${elapsed}ms`); + } + }, [isRecording]); + + const stressTestRaceCondition = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Race Conditions'); + const results: { test: string; passed: boolean; detail: string }[] = []; + + // Test 1: Start Q2 before Q1's finalizeChunk completes + console.log(' Test 1: Overlapping mark/finalize'); + ScreenRecorder.markChunkStart('race-q1'); + await new Promise((r) => setTimeout(r, 1500)); + + // Start finalizing Q1, but DON'T await yet + const q1Promise = ScreenRecorder.finalizeChunk('race-q1', { + settledTimeMs: 500, + }); + + // Immediately start Q2 (race condition scenario) + ScreenRecorder.markChunkStart('race-q2'); + await new Promise((r) => setTimeout(r, 1500)); + + // Now await Q1 + const q1File = await q1Promise; + const q1Passed = q1File !== null && q1File.duration > 1; + results.push({ + test: 'Overlapping mark/finalize', + passed: q1Passed, + detail: q1File ? `Q1: ${q1File.duration.toFixed(1)}s` : 'Q1: null', + }); + console.log( + ` Q1 result: ${q1Passed ? 'โœ…' : 'โŒ'} ${q1File?.duration?.toFixed(1) ?? 'null'}s` + ); + + // Finalize Q2 + const t0 = performance.now(); + const q2File = await ScreenRecorder.finalizeChunk('race-q2', { + settledTimeMs: 500, + }); + const q2Elapsed = (performance.now() - t0).toFixed(0); + const q2Passed = q2File !== null && q2File.duration > 1; + results.push({ + test: 'Q2 after overlap', + passed: q2Passed, + detail: q2File + ? `Q2: ${q2File.duration.toFixed(1)}s [${q2Elapsed}ms]` + : 'Q2: null', + }); + console.log( + ` Q2 result: ${q2Passed ? 'โœ…' : 'โŒ'} ${q2File?.duration?.toFixed(1) ?? 'null'}s [${q2Elapsed}ms]` + ); + + // Test 2: Concurrent finalizeChunk calls (should be rejected) + console.log(' Test 2: Concurrent finalizeChunk (should reject second)'); + ScreenRecorder.markChunkStart('race-concurrent'); + await new Promise((r) => setTimeout(r, 1000)); + + // Fire two finalizeChunks at once + const [f1, f2] = await Promise.all([ + ScreenRecorder.finalizeChunk('race-concurrent', { settledTimeMs: 500 }), + ScreenRecorder.finalizeChunk('race-concurrent', { settledTimeMs: 500 }), + ]); + + const bothSucceeded = f1 !== null && f2 !== null; + results.push({ + test: 'Concurrent finalizeChunk', + passed: !bothSucceeded, // Pass if second was rejected OR only one succeeded + detail: `f1: ${f1 ? 'file' : 'null'}, f2: ${f2 ? 'file' : 'null'}`, + }); + console.log( + ` Concurrent result: f1=${f1 ? 'โœ…' : 'โŒ'}, f2=${f2 ? 'โœ…' : 'โŒ'} (expect one null)` + ); + + // Test 3: Rapid fire (no await between cycles) + console.log(' Test 3: Rapid fire mark/finalize'); + const rapidResults: boolean[] = []; + for (let i = 0; i < 3; i++) { + const chunkId = `rapid-race-${i}`; + ScreenRecorder.markChunkStart(chunkId); + await new Promise((r) => setTimeout(r, 800)); + const t1 = performance.now(); + const f = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const e = (performance.now() - t1).toFixed(0); + rapidResults.push(f !== null); + console.log(` rapid-${i}: ${f ? 'โœ…' : 'โŒ'} [${e}ms]`); + } + results.push({ + test: 'Rapid fire', + passed: rapidResults.every((r) => r), + detail: `${rapidResults.filter((r) => r).length}/3 succeeded`, + }); + + setIsStressTesting(false); + Alert.alert( + 'Race Conditions', + results + .map((r) => `${r.passed ? 'โœ…' : 'โŒ'} ${r.test}: ${r.detail}`) + .join('\n') + ); + }, [isRecording]); + + const stressTestAudioMismatch = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Aggressive Audio Mismatch Detection'); + console.log(' Recording 5 chunks with DISTINCT durations...'); + + // Each chunk has a unique duration so we can detect mismatches + const expectedDurations = [2, 4, 3, 5, 1]; // seconds - intentionally not sequential + const results: { + chunk: number; + expectedDur: number; + videoDur: number; + audioDur: number; + videoMatch: boolean; + audioMatch: boolean; + }[] = []; + + for (let i = 0; i < expectedDurations.length; i++) { + const expectedDur = expectedDurations[i]; + const chunkId = `mismatch-${i}`; + console.log(` Chunk ${i + 1}: Recording for ${expectedDur}s...`); + + ScreenRecorder.markChunkStart(chunkId); + await new Promise((r) => setTimeout(r, expectedDur * 1000)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const elapsed = (performance.now() - t0).toFixed(0); + + if (file) { + const videoDur = file.duration; + const audioDur = file.audioFile?.duration ?? 0; + + // Video should be within 0.5s of expected + const videoMatch = Math.abs(videoDur - expectedDur) < 0.5; + // Audio should match video (within 0.3s) + const audioMatch = file.audioFile + ? Math.abs(videoDur - audioDur) < 0.3 + : true; // No audio file is OK if mic not enabled + + results.push({ + chunk: i + 1, + expectedDur, + videoDur, + audioDur, + videoMatch, + audioMatch, + }); + + const status = videoMatch && audioMatch ? 'โœ…' : 'โŒ'; + console.log( + ` Chunk ${i + 1}: ${status} expected=${expectedDur}s, video=${videoDur.toFixed(1)}s, audio=${audioDur.toFixed(1)}s [${elapsed}ms]` + ); + + if (!videoMatch) { + console.log(` โš ๏ธ VIDEO MISMATCH: Got wrong chunk!`); + } + if (!audioMatch && file.audioFile) { + console.log(` โš ๏ธ AUDIO MISMATCH: Audio doesn't match video!`); + } + } else { + console.log(` Chunk ${i + 1}: โŒ No file returned [${elapsed}ms]`); + // Dump extension logs on failure + if (Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after Chunk ${i + 1} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + results.push({ + chunk: i + 1, + expectedDur, + videoDur: 0, + audioDur: 0, + videoMatch: false, + audioMatch: false, + }); + } + } + + setIsStressTesting(false); + + const videoMatches = results.filter((r) => r.videoMatch).length; + const audioMatches = results.filter((r) => r.audioMatch).length; + const allPassed = videoMatches === 5 && audioMatches === 5; + + console.log(` Summary: Video ${videoMatches}/5, Audio ${audioMatches}/5`); + + Alert.alert( + allPassed + ? 'โœ… Audio Mismatch Test Passed' + : 'โŒ Audio Mismatch Test Failed', + results + .map( + (r) => + `Chunk ${r.chunk}: ${r.videoMatch && r.audioMatch ? 'โœ…' : 'โŒ'} ` + + `exp=${r.expectedDur}s vid=${r.videoDur.toFixed(1)}s aud=${r.audioDur.toFixed(1)}s` + ) + .join('\n') + ); + }, [isRecording]); + + const stressTestRealisticInterview = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿงช Stress Test: Realistic Interview (5 questions, 3-6s each)'); + + // Simulate realistic interview: 5 questions with 3-6 second answers + const questionDurations = [4, 5, 3, 6, 4]; // seconds per answer + const results: { + question: number; + expectedDur: number; + actualDur: number; + audioDur: number; + success: boolean; + finalizeTime: number; + }[] = []; + + for (let i = 0; i < questionDurations.length; i++) { + const expectedDur = questionDurations[i]; + const chunkId = `interview-q${i + 1}`; + console.log(` Q${i + 1}: Answering for ${expectedDur}s...`); + + // Start tracking this answer + ScreenRecorder.markChunkStart(chunkId); + + // Simulate user answering + await new Promise((r) => setTimeout(r, expectedDur * 1000)); + + // Finalize and submit + console.log(` Q${i + 1}: Submitting answer...`); + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const finalizeTime = performance.now() - t0; + + if (file) { + const durationMatch = Math.abs(file.duration - expectedDur) < 0.5; + const audioMatch = file.audioFile + ? Math.abs(file.duration - file.audioFile.duration) < 0.3 + : true; + const success = durationMatch && audioMatch; + + results.push({ + question: i + 1, + expectedDur, + actualDur: file.duration, + audioDur: file.audioFile?.duration ?? 0, + success, + finalizeTime, + }); + + console.log( + ` Q${i + 1}: ${success ? 'โœ…' : 'โŒ'} ` + + `${file.duration.toFixed(1)}s video, ${file.audioFile?.duration.toFixed(1) ?? 'n/a'}s audio ` + + `[${finalizeTime.toFixed(0)}ms]` + ); + } else { + results.push({ + question: i + 1, + expectedDur, + actualDur: 0, + audioDur: 0, + success: false, + finalizeTime, + }); + console.log( + ` Q${i + 1}: โŒ No file returned [${finalizeTime.toFixed(0)}ms]` + ); + // Dump extension logs on failure + if (Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after Q${i + 1} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + } + + // Brief pause between questions (simulating UI transition) + if (i < questionDurations.length - 1) { + console.log(` (transitioning to next question...)`); + await new Promise((r) => setTimeout(r, 500)); + } + } + + setIsStressTesting(false); + + const passed = results.filter((r) => r.success).length; + const avgFinalizeTime = + results.reduce((sum, r) => sum + r.finalizeTime, 0) / results.length; + + console.log( + ` Summary: ${passed}/5 passed, avg finalize: ${avgFinalizeTime.toFixed(0)}ms` + ); + + Alert.alert( + passed === 5 ? 'โœ… Interview Test Passed' : 'โŒ Interview Test Failed', + `${passed}/5 questions succeeded\n` + + `Avg finalize time: ${avgFinalizeTime.toFixed(0)}ms\n\n` + + results + .map( + (r) => + `Q${r.question}: ${r.success ? 'โœ…' : 'โŒ'} ${r.actualDur.toFixed(1)}s [${r.finalizeTime.toFixed(0)}ms]` + ) + .join('\n') + ); + }, [isRecording]); + + const stressTestHardMode = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + + const numQuestions = 40; + // Generate random durations between 0.5s and 15s + const questionDurations = Array.from( + { length: numQuestions }, + () => Math.random() * 14.5 + 0.5 + ); + + const totalExpectedTime = questionDurations.reduce((a, b) => a + b, 0); + console.log( + `๐Ÿ”ฅ HARD MODE: ${numQuestions} questions, ~${Math.round(totalExpectedTime)}s total expected` + ); + console.log( + ` Durations: ${questionDurations.map((d) => d.toFixed(1)).join(', ')}` + ); + + const results: { + question: number; + expectedDur: number; + actualDur: number; + audioDur: number; + success: boolean; + noFile: boolean; + finalizeTime: number; + }[] = []; + + const testStartTime = performance.now(); + + for (let i = 0; i < questionDurations.length; i++) { + const expectedDur = questionDurations[i]; + console.log( + ` Q${i + 1}/${numQuestions}: Recording for ${expectedDur.toFixed(1)}s...` + ); + + // Start tracking this answer + const chunkId = `hardmode-q${i + 1}`; + ScreenRecorder.markChunkStart(chunkId); + + // Simulate recording + await new Promise((r) => setTimeout(r, expectedDur * 1000)); + + // Finalize and submit + console.log(` Q${i + 1}/${numQuestions}: Finalizing...`); + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 500, + }); + const finalizeTime = performance.now() - t0; + + if (file) { + // Wider tolerance for short recordings (< 2s get ยฑ1s, others get ยฑ0.5s) + const tolerance = expectedDur < 2 ? 1.0 : 0.5; + const durationMatch = Math.abs(file.duration - expectedDur) < tolerance; + const audioMatch = file.audioFile + ? Math.abs(file.duration - file.audioFile.duration) < 0.5 + : true; + const success = durationMatch && audioMatch; + + results.push({ + question: i + 1, + expectedDur, + actualDur: file.duration, + audioDur: file.audioFile?.duration ?? 0, + success, + noFile: false, + finalizeTime, + }); + + const emoji = success ? 'โœ…' : 'โŒ'; + const diff = file.duration - expectedDur; + const diffStr = diff >= 0 ? `+${diff.toFixed(1)}` : diff.toFixed(1); + console.log( + ` Q${i + 1}/${numQuestions}: ${emoji} ${file.duration.toFixed(1)}s (${diffStr}s) [${finalizeTime.toFixed(0)}ms]` + ); + } else { + results.push({ + question: i + 1, + expectedDur, + actualDur: 0, + audioDur: 0, + success: false, + noFile: true, + finalizeTime, + }); + console.log( + ` Q${i + 1}/${numQuestions}: โŒ No file returned [${finalizeTime.toFixed(0)}ms]` + ); + // Dump extension logs only when no file returned + if (Platform.OS === 'ios') { console.log( - 'In-app recording finished:', - JSON.stringify(file, null, 2) + ` ๐Ÿ“œ Extension logs after Q${i + 1} failure (no file):` ); - setInAppRecording(file); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-15).forEach((log) => console.log(` ${log}`)); + } + } + + // Brief pause between questions + if (i < questionDurations.length - 1) { + await new Promise((r) => setTimeout(r, 300)); + } + } + + const testDuration = (performance.now() - testStartTime) / 1000; + setIsStressTesting(false); + + const passed = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success); + const noFileCount = results.filter((r) => r.noFile).length; + const avgFinalizeTime = + results.reduce((sum, r) => sum + r.finalizeTime, 0) / results.length; + const actualDurations = results + .map((r) => r.actualDur) + .filter((d) => d > 0); + const minDur = + actualDurations.length > 0 ? Math.min(...actualDurations) : 0; + const maxDur = + actualDurations.length > 0 ? Math.max(...actualDurations) : 0; + const avgDur = + actualDurations.length > 0 + ? actualDurations.reduce((a, b) => a + b, 0) / actualDurations.length + : 0; + + console.log(`\n๐Ÿ”ฅ HARD MODE RESULTS:`); + console.log( + ` Passed: ${passed}/${numQuestions} (${((passed / numQuestions) * 100).toFixed(0)}%)` + ); + console.log(` No file returned: ${noFileCount}`); + console.log(` Test duration: ${testDuration.toFixed(0)}s`); + console.log(` Avg finalize: ${avgFinalizeTime.toFixed(0)}ms`); + if (actualDurations.length > 0) { + console.log( + ` Duration range: ${minDur.toFixed(1)}s - ${maxDur.toFixed(1)}s (avg: ${avgDur.toFixed(1)}s)` + ); + } + + if (failed.length > 0) { + console.log( + ` Failed questions: ${failed.map((f) => f.question).join(', ')}` + ); + } + + const passThreshold = Math.floor(numQuestions * 0.8); // 80% pass rate + Alert.alert( + passed >= passThreshold ? 'โœ… Hard Mode Passed' : 'โŒ Hard Mode Failed', + `${passed}/${numQuestions} questions succeeded (${((passed / numQuestions) * 100).toFixed(0)}%)\n` + + `No file: ${noFileCount}, Duration mismatch: ${failed.length - noFileCount}\n` + + `Test duration: ${testDuration.toFixed(0)}s\n` + + `Avg finalize: ${avgFinalizeTime.toFixed(0)}ms\n\n` + + (failed.length > 0 + ? `Failed: Q${failed.map((f) => f.question).join(', Q')}` + : 'All questions passed!') + ); + }, [isRecording]); + + // Faster chunk test - 20 iterations with shorter timing + const stressTestFasterChunks = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿš€ Stress Test: Faster Chunks (20 iterations, 500ms each)'); + const results: { id: string; success: boolean; duration?: number }[] = []; + + for (let i = 1; i <= 20; i++) { + const chunkId = `fast-${i}`; + console.log(` Starting chunk ${chunkId}...`); + ScreenRecorder.markChunkStart(chunkId); + + // Very short recording (500ms) + await new Promise((r) => setTimeout(r, 500)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 200, + }); + const elapsed = (performance.now() - t0).toFixed(0); + results.push({ id: chunkId, success: !!file, duration: file?.duration }); + console.log( + ` Chunk ${chunkId}: ${file ? 'โœ…' : 'โŒ'} (${file?.duration?.toFixed(2) ?? 0}s) [${elapsed}ms]` + ); + + if (!file && Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after ${chunkId} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-8).forEach((log) => console.log(` ${log}`)); + } + } + + const passed = results.filter((r) => r.success).length; + setIsStressTesting(false); + + if (passed < 20 && Platform.OS === 'ios') { + console.log('๐Ÿ“œ Full extension logs:'); + const logs = ScreenRecorder.getExtensionLogs(); + logs.forEach((log) => console.log(` ${log}`)); + } + + Alert.alert('Faster Chunks', `${passed}/20 chunks retrieved successfully`); + }, [isRecording]); + + // Rapid mark spam test - multiple marks before finalize + const stressTestMarkSpam = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿ“จ Stress Test: Mark Spam (multiple marks before finalize)'); + console.log(' Testing that rapid marks are handled without crashing'); + const results: { round: number; success: boolean; duration?: number }[] = + []; + + for (let round = 1; round <= 5; round++) { + console.log(` Round ${round}: Spamming 5 marks rapidly...`); + + // Fire 5 marks rapidly - system should handle this gracefully + // The last one wins, so we'll use that ID for finalize + const lastChunkId = `spam-r${round}-m5`; + for (let i = 1; i <= 5; i++) { + ScreenRecorder.markChunkStart(`spam-r${round}-m${i}`); + await new Promise((r) => setTimeout(r, 10)); // tiny 10ms delay + } + + // Record for a bit after the spam + await new Promise((r) => setTimeout(r, 800)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(lastChunkId, { + settledTimeMs: 200, + }); + const elapsed = (performance.now() - t0).toFixed(0); + + results.push({ + round, + success: !!file, + duration: file?.duration, + }); + + console.log( + ` Round ${round}: ${file ? 'โœ…' : 'โŒ'} (${file?.duration?.toFixed(2) ?? 0}s) [${elapsed}ms]` + ); + + if (!file && Platform.OS === 'ios') { + console.log(` ๐Ÿ“œ Extension logs after round ${round} failure:`); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-8).forEach((log) => console.log(` ${log}`)); + } + } + + const passed = results.filter((r) => r.success).length; + setIsStressTesting(false); + + Alert.alert( + 'Mark Spam Results', + `${passed}/5 rounds retrieved successfully\n(5 rapid marks per round)` + ); + }, [isRecording]); + + // No-gap burst test - back-to-back without pause + const stressTestNoGapBurst = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + console.log('๐Ÿ’ฅ Stress Test: No-Gap Burst (back-to-back, no pause)'); + const results: { + id: string; + success: boolean; + duration?: number; + finalizeMs: number; + }[] = []; + + for (let i = 1; i <= 15; i++) { + const chunkId = `burst-${i}`; + + // Mark immediately (no delay from previous finalize) + ScreenRecorder.markChunkStart(chunkId); + + // Short recording + await new Promise((r) => setTimeout(r, 600)); + + const t0 = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: 150, + }); + const finalizeMs = performance.now() - t0; + + results.push({ + id: chunkId, + success: !!file, + duration: file?.duration, + finalizeMs, + }); + + console.log( + ` ${chunkId}: ${file ? 'โœ…' : 'โŒ'} (${file?.duration?.toFixed(2) ?? 0}s) [${finalizeMs.toFixed(0)}ms]` + ); + + // NO delay before next iteration - that's the point of this test + } + + const passed = results.filter((r) => r.success).length; + const avgFinalize = + results.reduce((sum, r) => sum + r.finalizeMs, 0) / results.length; + setIsStressTesting(false); + + if (passed < 15 && Platform.OS === 'ios') { + console.log('๐Ÿ“œ Full extension logs:'); + const logs = ScreenRecorder.getExtensionLogs(); + logs.slice(-30).forEach((log) => console.log(` ${log}`)); + } + + Alert.alert( + 'No-Gap Burst', + `${passed}/15 chunks retrieved\nAvg finalize: ${avgFinalize.toFixed(0)}ms` + ); + }, [isRecording]); + + /** + * GAUNTLET TEST - 50 Rapid Fire Chunks with Duration Validation + * + * The ultimate stress test for chunkId validation: + * - 50 chunks in rapid succession + * - VARYING durations (0.5s to 2s) to detect wrong-file returns + * - Validates each file has the EXPECTED duration (not just any file) + * - Catches the "got wrong file" bug by checking duration matches + */ + const stressTestGauntlet = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + ScreenRecorder.clearExtensionLogs(); + + const TOTAL_CHUNKS = 50; + const SETTLE_TIME_MS = 150; + const DURATION_TOLERANCE_S = 0.5; // Allow 0.5s tolerance for duration matching + + // Generate varying durations (alternating pattern to catch wrong-file bugs) + // Pattern: 0.5s, 1.5s, 0.8s, 1.2s, 0.6s, 1.8s, etc. + const getDuration = (i: number): number => { + const patterns = [0.5, 1.5, 0.8, 1.2, 0.6, 1.8, 1.0, 2.0, 0.7, 1.4]; + return patterns[i % patterns.length]; + }; + + console.log('๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ GAUNTLET TEST - DURATION VALIDATION ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ'); + console.log('================================================'); + console.log(`Chunks: ${TOTAL_CHUNKS}, Varying durations 0.5s-2s`); + console.log(`Duration tolerance: ยฑ${DURATION_TOLERANCE_S}s`); + + const results: { + id: string; + expectedDuration: number; + actualDuration: number; + fileName: string; + fileReturned: boolean; + chunkIdInFilename: boolean; + durationMatch: boolean; + markMs: number; + finalizeMs: number; + }[] = []; + + let consecutiveFailures = 0; + const MAX_CONSECUTIVE_FAILURES = 5; + + const startTime = performance.now(); + + for (let i = 1; i <= TOTAL_CHUNKS; i++) { + const chunkId = `gauntlet-${i}`; + const expectedDuration = getDuration(i); + + // Mark + const markStart = performance.now(); + await ScreenRecorder.markChunkStart(chunkId); + const markMs = performance.now() - markStart; + + // Record for the expected duration + await new Promise((r) => setTimeout(r, expectedDuration * 1000)); + + // Finalize with matching chunkId + const finalizeStart = performance.now(); + const file = await ScreenRecorder.finalizeChunk(chunkId, { + settledTimeMs: SETTLE_TIME_MS, + }); + const finalizeMs = performance.now() - finalizeStart; + + const fileReturned = file !== null; + const actualDuration = file?.duration ?? 0; + const fileName = file?.name ?? ''; + + // Verify chunkId is in the filename (bulletproof verification) + const chunkIdInFilename = fileName.includes(chunkId); + const durationMatch = + fileReturned && + Math.abs(actualDuration - expectedDuration) < DURATION_TOLERANCE_S; + + results.push({ + id: chunkId, + expectedDuration, + actualDuration, + fileName, + fileReturned, + chunkIdInFilename, + durationMatch, + markMs, + finalizeMs, + }); + + // Progress logging every 10 chunks + if (i % 10 === 0) { + const matchCount = results.filter((r) => r.durationMatch).length; + const avgFinalize = + results.slice(-10).reduce((sum, r) => sum + r.finalizeMs, 0) / 10; + console.log( + ` [${i}/${TOTAL_CHUNKS}] ${matchCount}/${i} duration match, avg finalize: ${avgFinalize.toFixed(0)}ms` + ); + } + + // Check for failures - filename check is the PRIMARY verification + if (!fileReturned) { + consecutiveFailures++; + console.log( + ` โŒ ${chunkId} NO FILE (consecutive: ${consecutiveFailures})` + ); + } else if (!chunkIdInFilename) { + consecutiveFailures++; + console.log( + ` ๐Ÿšจ ${chunkId} WRONG FILE! Filename: ${fileName}` + ); + console.log( + ` Expected chunkId "${chunkId}" in filename but not found!` + ); + console.log( + ` Duration: expected ${expectedDuration.toFixed(1)}s, got ${actualDuration.toFixed(1)}s` + ); + + // Dump logs on wrong file + if (Platform.OS === 'ios') { + const logs = ScreenRecorder.getExtensionLogs(); + const relevantLogs = logs + .filter((log) => log.includes('chunkId') || log.includes(chunkId)) + .slice(-5); + relevantLogs.forEach((log) => console.log(` ${log}`)); + } + } else if (!durationMatch) { + // Filename matches but duration is off - still suspicious + console.log( + ` โš ๏ธ ${chunkId} duration mismatch: expected ${expectedDuration.toFixed(1)}s, got ${actualDuration.toFixed(1)}s (file: ${fileName})` + ); + // Don't count as failure if filename matches - could be timing variance + } else { + consecutiveFailures = 0; + } + + // Abort if too many consecutive failures + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.log( + ` ๐Ÿ›‘ ABORTING: ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + ); + break; + } + } + + const totalTime = ((performance.now() - startTime) / 1000).toFixed(1); + const fileCount = results.filter((r) => r.fileReturned).length; + const filenameMatchCount = results.filter((r) => r.chunkIdInFilename).length; + const durationMatchCount = results.filter((r) => r.durationMatch).length; + const wrongFileCount = results.filter( + (r) => r.fileReturned && !r.chunkIdInFilename + ).length; + const avgMark = + results.reduce((sum, r) => sum + r.markMs, 0) / results.length; + const avgFinalize = + results.reduce((sum, r) => sum + r.finalizeMs, 0) / results.length; + + console.log('\n================================================'); + console.log('๐Ÿ“Š GAUNTLET RESULTS'); + console.log(` Files returned: ${fileCount}/${results.length}`); + console.log(` ChunkId in filename: ${filenameMatchCount}/${results.length} โœ…`); + console.log(` Duration matches: ${durationMatchCount}/${results.length}`); + console.log(` WRONG FILE (filename mismatch): ${wrongFileCount} โŒ`); + console.log(` Time: ${totalTime}s total`); + console.log( + ` Avg mark: ${avgMark.toFixed(0)}ms, Avg finalize: ${avgFinalize.toFixed(0)}ms` + ); + + // Log wrong files + const wrongFiles = results.filter( + (r) => r.fileReturned && !r.chunkIdInFilename + ); + if (wrongFiles.length > 0) { + console.log(`\n ๐Ÿšจ WRONG FILES (filename doesn't contain expected chunkId):`); + wrongFiles.forEach((w) => { + console.log( + ` ${w.id}: got file "${w.fileName}" (duration ${w.actualDuration.toFixed(1)}s)` + ); + }); + } + + setIsStressTesting(false); + + const allPassed = wrongFileCount === 0 && fileCount === results.length; + Alert.alert( + allPassed ? 'โœ… Gauntlet PASSED' : 'โŒ Gauntlet FAILED', + `Files: ${fileCount}/${results.length}\n` + + `Filename matches: ${filenameMatchCount}/${results.length}\n` + + `Wrong files: ${wrongFileCount}\n` + + `Total time: ${totalTime}s` + ); + }, [isRecording]); + + /** + * Comprehensive ChunkId Validation Test + * + * This test validates the entire chunkId flow: + * 1. Creates chunks with unique IDs and varying durations + * 2. Verifies extension logs show correct chunkId + * 3. Checks video/audio duration matches expected recording time + * 4. Tests that chunkId is properly passed through the system + * 5. Validates audio files are correctly paired with their chunks + */ + const stressTestChunkIdValidation = useCallback(async () => { + if (!isRecording) { + Alert.alert('Not Recording', 'Start a global recording first'); + return; + } + setIsStressTesting(true); + ScreenRecorder.clearExtensionLogs(); + console.log('๐Ÿงช COMPREHENSIVE CHUNKID VALIDATION TEST'); + console.log('========================================='); + + const testCases = [ + { + id: 'validate-short', + duration: 1.5, + description: 'Short chunk (1.5s)', + }, + { + id: 'validate-medium', + duration: 3.0, + description: 'Medium chunk (3s)', + }, + { id: 'validate-long', duration: 5.0, description: 'Long chunk (5s)' }, + ]; + + const results: { + id: string; + passed: boolean; + details: { + fileReturned: boolean; + videoDuration: number; + audioDuration: number; + expectedDuration: number; + videoMatchesExpected: boolean; + audioMatchesVideo: boolean; + chunkIdInLogs: boolean; + }; + }[] = []; + + for (const testCase of testCases) { + console.log(`\n๐Ÿ“ Test: ${testCase.description}`); + console.log(` ChunkId: ${testCase.id}`); + console.log(` Expected duration: ${testCase.duration}s`); + + // Clear logs before each chunk to isolate + ScreenRecorder.clearExtensionLogs(); + + // Mark chunk start with explicit ID + const markStart = performance.now(); + await ScreenRecorder.markChunkStart(testCase.id); + const markTime = (performance.now() - markStart).toFixed(0); + console.log(` Mark completed in ${markTime}ms`); + + // Record for the specified duration + await new Promise((r) => setTimeout(r, testCase.duration * 1000)); + + // Finalize with the SAME chunkId + const finalizeStart = performance.now(); + const file = await ScreenRecorder.finalizeChunk(testCase.id, { + settledTimeMs: 500, + }); + const finalizeTime = (performance.now() - finalizeStart).toFixed(0); + console.log(` Finalize completed in ${finalizeTime}ms`); + + // Check extension logs for chunkId + const logs = ScreenRecorder.getExtensionLogs(); + const chunkIdInLogs = logs.some( + (log) => + log.includes(`chunkId=${testCase.id}`) || + log.includes(`'${testCase.id}'`) + ); + + // Analyze results + const fileReturned = file !== null; + const videoDuration = file?.duration ?? 0; + const audioDuration = file?.audioFile?.duration ?? 0; + const appAudioDuration = file?.appAudioFile?.duration ?? 0; + + // Tolerances + const videoMatchesExpected = + Math.abs(videoDuration - testCase.duration) < 0.5; + const audioMatchesVideo = file?.audioFile + ? Math.abs(videoDuration - audioDuration) < 0.5 + : true; // OK if no audio file + + const passed = + fileReturned && + videoMatchesExpected && + audioMatchesVideo && + chunkIdInLogs; + + results.push({ + id: testCase.id, + passed, + details: { + fileReturned, + videoDuration, + audioDuration, + expectedDuration: testCase.duration, + videoMatchesExpected, + audioMatchesVideo, + chunkIdInLogs, }, }); - } catch (error) { - console.error('โŒ Error starting recording:', error); - } - }; - const handleStopInAppRecording = () => { - ScreenRecorder.stopInAppRecording(); - }; + // Log results + console.log(` Results:`); + console.log(` - File returned: ${fileReturned ? 'โœ…' : 'โŒ'}`); + console.log( + ` - Video duration: ${videoDuration.toFixed(2)}s (expected ${testCase.duration}s) ${videoMatchesExpected ? 'โœ…' : 'โŒ'}` + ); + if (file?.audioFile) { + console.log( + ` - Mic audio duration: ${audioDuration.toFixed(2)}s ${audioMatchesVideo ? 'โœ…' : 'โŒ'}` + ); + } + if (file?.appAudioFile) { + console.log(` - App audio duration: ${appAudioDuration.toFixed(2)}s`); + } + console.log(` - ChunkId in logs: ${chunkIdInLogs ? 'โœ…' : 'โŒ'}`); + console.log(` - Overall: ${passed ? 'โœ… PASSED' : 'โŒ FAILED'}`); - const handleCancelInAppRecording = () => { - ScreenRecorder.cancelInAppRecording(); - console.log('In-app recording cancelled'); - }; + // On failure, dump relevant logs + if (!passed) { + console.log(` ๐Ÿ“œ Extension logs:`); + logs + .filter( + (log) => + log.includes('handleMarkChunk') || + log.includes('handleFinalizeChunk') || + log.includes('saveChunkToContainer') || + log.includes('chunkId') + ) + .slice(-10) + .forEach((log) => console.log(` ${log}`)); + } + } - // Global Recording Functions - const handleStartGlobalRecording = () => { - ScreenRecorder.startGlobalRecording({ - options: { - enableMic: true, - }, - onRecordingError: (error) => { - console.log('Global recording error', error); - }, - }); - }; + // Summary + console.log('\n========================================='); + console.log('๐Ÿ“Š SUMMARY'); + const passedCount = results.filter((r) => r.passed).length; + console.log(` Total: ${passedCount}/${results.length} tests passed`); - const checkFileAccessibility = async ( - file: ScreenRecorder.ScreenRecordingFile | undefined - ) => { - if (file) { - try { - console.log('Checking file accessibility for path', file.path); - const data = await getVideoInfoAsync(file.path); - console.log(JSON.stringify(data, null, 2)); - } catch (error) { - console.error('Error', error); - } + for (const result of results) { + const d = result.details; + console.log( + ` ${result.id}: ${result.passed ? 'โœ…' : 'โŒ'} ` + + `(video=${d.videoDuration.toFixed(1)}s, audio=${d.audioDuration.toFixed(1)}s, ` + + `idInLogs=${d.chunkIdInLogs})` + ); } - }; - const handleStopGlobalRecording = async () => { - const newFile = await ScreenRecorder.stopGlobalRecording(); - setGlobalRecording(newFile); - await checkFileAccessibility(newFile); + setIsStressTesting(false); + + Alert.alert( + 'ChunkId Validation', + `${passedCount}/${results.length} tests passed\n\n` + + results.map((r) => `${r.id}: ${r.passed ? 'โœ…' : 'โŒ'}`).join('\n') + ); + }, [isRecording]); + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; }; - const handleGetGlobalRecordingFile = async () => { - const newFile = ScreenRecorder.retrieveLastGlobalRecording(); - setGlobalRecording(newFile); - await checkFileAccessibility(newFile); + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }; - const handleClearRecordingCache = () => { - ScreenRecorder.clearCache(); + // Camera overlay toggle handler + const handleToggleCameraOverlay = async () => { + if (!showCameraOverlay) { + // Request permission if not granted + if (!cameraPermission?.granted) { + const result = await requestCameraPermission(); + if (!result.granted) { + Alert.alert( + 'Camera Permission', + 'Camera permission is required for the overlay' + ); + return; + } + } + } + setShowCameraOverlay(!showCameraOverlay); }; + const isKitchenSinkActive = + isThermalStressActive && + isGpuStressActive && + isNetworkStressActive && + isMemoryStressActive; + return ( - - {/* Permissions Section */} - - Permissions - {Platform.OS === 'ios' && ( - <> - Camera - - -