react-native-frame-capture 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/FrameCapture.podspec +21 -0
  2. package/LICENSE +20 -0
  3. package/README.md +158 -0
  4. package/android/build.gradle +77 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +20 -0
  7. package/android/src/main/java/com/framecapture/CaptureManager.kt +831 -0
  8. package/android/src/main/java/com/framecapture/Constants.kt +196 -0
  9. package/android/src/main/java/com/framecapture/ErrorHandler.kt +165 -0
  10. package/android/src/main/java/com/framecapture/FrameCaptureModule.kt +653 -0
  11. package/android/src/main/java/com/framecapture/FrameCapturePackage.kt +32 -0
  12. package/android/src/main/java/com/framecapture/OverlayRenderer.kt +423 -0
  13. package/android/src/main/java/com/framecapture/PermissionHandler.kt +150 -0
  14. package/android/src/main/java/com/framecapture/ScreenCaptureService.kt +366 -0
  15. package/android/src/main/java/com/framecapture/StorageManager.kt +221 -0
  16. package/android/src/main/java/com/framecapture/capture/BitmapProcessor.kt +157 -0
  17. package/android/src/main/java/com/framecapture/capture/CaptureEventEmitter.kt +120 -0
  18. package/android/src/main/java/com/framecapture/models/CaptureModels.kt +302 -0
  19. package/android/src/main/java/com/framecapture/models/EnumsAndExtensions.kt +60 -0
  20. package/android/src/main/java/com/framecapture/models/OverlayModels.kt +154 -0
  21. package/android/src/main/java/com/framecapture/service/CaptureNotificationManager.kt +286 -0
  22. package/android/src/main/java/com/framecapture/storage/StorageStrategies.kt +317 -0
  23. package/android/src/main/java/com/framecapture/utils/ValidationUtils.kt +379 -0
  24. package/app.plugin.js +1 -0
  25. package/ios/FrameCapture.h +5 -0
  26. package/ios/FrameCapture.mm +21 -0
  27. package/lib/module/NativeFrameCapture.js +24 -0
  28. package/lib/module/NativeFrameCapture.js.map +1 -0
  29. package/lib/module/api.js +146 -0
  30. package/lib/module/api.js.map +1 -0
  31. package/lib/module/constants.js +67 -0
  32. package/lib/module/constants.js.map +1 -0
  33. package/lib/module/errors.js +19 -0
  34. package/lib/module/errors.js.map +1 -0
  35. package/lib/module/events.js +58 -0
  36. package/lib/module/events.js.map +1 -0
  37. package/lib/module/index.js +24 -0
  38. package/lib/module/index.js.map +1 -0
  39. package/lib/module/normalize.js +51 -0
  40. package/lib/module/normalize.js.map +1 -0
  41. package/lib/module/package.json +1 -0
  42. package/lib/module/types.js +165 -0
  43. package/lib/module/types.js.map +1 -0
  44. package/lib/module/validation.js +190 -0
  45. package/lib/module/validation.js.map +1 -0
  46. package/lib/typescript/package.json +1 -0
  47. package/lib/typescript/plugin/src/index.d.ts +4 -0
  48. package/lib/typescript/plugin/src/index.d.ts.map +1 -0
  49. package/lib/typescript/src/NativeFrameCapture.d.ts +75 -0
  50. package/lib/typescript/src/NativeFrameCapture.d.ts.map +1 -0
  51. package/lib/typescript/src/api.d.ts +66 -0
  52. package/lib/typescript/src/api.d.ts.map +1 -0
  53. package/lib/typescript/src/constants.d.ts +41 -0
  54. package/lib/typescript/src/constants.d.ts.map +1 -0
  55. package/lib/typescript/src/errors.d.ts +14 -0
  56. package/lib/typescript/src/errors.d.ts.map +1 -0
  57. package/lib/typescript/src/events.d.ts +30 -0
  58. package/lib/typescript/src/events.d.ts.map +1 -0
  59. package/lib/typescript/src/index.d.ts +12 -0
  60. package/lib/typescript/src/index.d.ts.map +1 -0
  61. package/lib/typescript/src/normalize.d.ts +43 -0
  62. package/lib/typescript/src/normalize.d.ts.map +1 -0
  63. package/lib/typescript/src/types.d.ts +247 -0
  64. package/lib/typescript/src/types.d.ts.map +1 -0
  65. package/lib/typescript/src/validation.d.ts +15 -0
  66. package/lib/typescript/src/validation.d.ts.map +1 -0
  67. package/package.json +196 -0
  68. package/plugin/build/index.js +48 -0
  69. package/src/NativeFrameCapture.ts +86 -0
  70. package/src/api.ts +189 -0
  71. package/src/constants.ts +69 -0
  72. package/src/errors.ts +21 -0
  73. package/src/events.ts +61 -0
  74. package/src/index.tsx +31 -0
  75. package/src/normalize.ts +81 -0
  76. package/src/types.ts +327 -0
  77. package/src/validation.ts +321 -0
@@ -0,0 +1,831 @@
1
+ package com.framecapture
2
+
3
+ import android.content.Intent
4
+ import android.graphics.Bitmap
5
+ import android.graphics.PixelFormat
6
+ import android.hardware.display.DisplayManager
7
+ import android.hardware.display.VirtualDisplay
8
+ import android.media.Image
9
+ import android.media.ImageReader
10
+ import android.media.projection.MediaProjection
11
+ import android.media.projection.MediaProjectionManager
12
+ import android.os.Handler
13
+ import android.os.HandlerThread
14
+ import android.util.DisplayMetrics
15
+ import android.util.Log
16
+ import android.view.WindowManager
17
+ import com.facebook.react.bridge.Arguments
18
+ import com.facebook.react.bridge.WritableMap
19
+ import com.framecapture.models.CaptureOptions
20
+ import com.framecapture.models.CaptureSession
21
+ import com.framecapture.models.CaptureState
22
+ import com.framecapture.models.CaptureStatus
23
+ import com.framecapture.models.ErrorCode
24
+ import com.framecapture.models.FrameInfo
25
+ import com.framecapture.capture.CaptureEventEmitter
26
+ import com.framecapture.capture.BitmapProcessor
27
+ import java.util.UUID
28
+ import java.util.concurrent.ExecutorService
29
+ import java.util.concurrent.Executors
30
+ import java.util.concurrent.TimeUnit
31
+
32
+ /**
33
+ * Manages screen capture operations using Android's MediaProjection API
34
+ *
35
+ * This class orchestrates the entire screen capture process, including:
36
+ * - MediaProjection and VirtualDisplay lifecycle management
37
+ * - ImageReader for frame acquisition
38
+ * - Interval-based periodic capture
39
+ * - Frame processing (bitmap conversion, overlays, storage)
40
+ * - Event emission to JavaScript
41
+ *
42
+ * Delegates specialized tasks to:
43
+ * - CaptureEventEmitter: Event emission
44
+ * - BitmapProcessor: Image processing and cropping
45
+ */
46
+ class CaptureManager(
47
+ private val context: android.content.Context,
48
+ private val storageManager: StorageManager,
49
+ private val eventEmitter: (String, WritableMap?) -> Unit
50
+ ) {
51
+
52
+ // MediaProjection components
53
+ private var mediaProjectionManager: MediaProjectionManager? = null
54
+ private var mediaProjection: MediaProjection? = null
55
+ private var virtualDisplay: VirtualDisplay? = null
56
+ private var imageReader: ImageReader? = null
57
+
58
+ // Capture state
59
+ private var captureOptions: CaptureOptions? = null
60
+ private var sessionId: String? = null
61
+ private var sessionStartTime: Long = 0
62
+ private var frameCount: Int = 0
63
+ private var isCapturing: Boolean = false
64
+ private var isPaused: Boolean = false
65
+
66
+ // Threading for capture operations
67
+ private val captureThread: HandlerThread = HandlerThread("CaptureThread").apply { start() }
68
+ private val captureHandler: Handler = Handler(captureThread.looper)
69
+
70
+ // Background processing for image conversion and saving
71
+ private val processingExecutor: ExecutorService = Executors.newSingleThreadExecutor()
72
+
73
+ // Timing for throttling
74
+ private var lastCaptureTime: Long = 0
75
+
76
+ // Runnable for periodic capture
77
+ private var captureRunnable: Runnable? = null
78
+
79
+ // Overlay renderer for adding watermarks and metadata
80
+ private val overlayRenderer: OverlayRenderer by lazy {
81
+ OverlayRenderer(context, eventEmitter)
82
+ }
83
+
84
+ // Cached status bar height
85
+ private var statusBarHeight: Int = 0
86
+
87
+ // Specialized managers
88
+ private lateinit var eventEmitterManager: CaptureEventEmitter
89
+ private lateinit var bitmapProcessor: BitmapProcessor
90
+
91
+ companion object {
92
+ private const val TAG = "CaptureManager"
93
+ private const val VIRTUAL_DISPLAY_NAME = "ScreenCapture"
94
+ }
95
+
96
+ init {
97
+ // Initialize MediaProjectionManager
98
+ mediaProjectionManager = context.getSystemService(android.content.Context.MEDIA_PROJECTION_SERVICE)
99
+ as? MediaProjectionManager
100
+
101
+ // Calculate status bar height once
102
+ statusBarHeight = getStatusBarHeight()
103
+
104
+ // Initialize specialized managers
105
+ eventEmitterManager = CaptureEventEmitter(eventEmitter)
106
+ bitmapProcessor = BitmapProcessor(statusBarHeight)
107
+ }
108
+
109
+ /**
110
+ * Gets the status bar height in pixels
111
+ *
112
+ * Uses Android's resource system to retrieve the standard status bar height.
113
+ * This is cached and used for cropping when excludeStatusBar is enabled.
114
+ *
115
+ * @return Status bar height in pixels, or 0 if not found
116
+ */
117
+ private fun getStatusBarHeight(): Int {
118
+ var result = 0
119
+ val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
120
+ if (resourceId > 0) {
121
+ result = context.resources.getDimensionPixelSize(resourceId)
122
+ }
123
+ return result
124
+ }
125
+
126
+ /**
127
+ * Initializes the capture manager with MediaProjection data and options
128
+ *
129
+ * @param projectionData Intent containing MediaProjection permission result
130
+ * @param options Capture configuration options
131
+ * @throws SecurityException if MediaProjection permission not granted
132
+ * @throws IllegalStateException if already initialized
133
+ */
134
+ fun initialize(projectionData: Intent, options: CaptureOptions) {
135
+ try {
136
+ // Check if already initialized
137
+ if (mediaProjection != null) {
138
+ throw IllegalStateException("CaptureManager already initialized")
139
+ }
140
+
141
+ // Store capture options
142
+ this.captureOptions = options
143
+
144
+ // Generate unique session ID and record start time
145
+ this.sessionId = UUID.randomUUID().toString()
146
+ this.sessionStartTime = System.currentTimeMillis()
147
+
148
+ // Get MediaProjection from the permission result
149
+ mediaProjection = mediaProjectionManager?.getMediaProjection(
150
+ android.app.Activity.RESULT_OK,
151
+ projectionData
152
+ ) ?: throw SecurityException("Failed to obtain MediaProjection")
153
+
154
+ // Register callback to handle projection stop events
155
+ mediaProjection?.registerCallback(projectionCallback, captureHandler)
156
+
157
+ } catch (e: Exception) {
158
+ Log.e(TAG, "Failed to initialize CaptureManager", e)
159
+ cleanup()
160
+ throw e
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Callback for MediaProjection lifecycle events
166
+ *
167
+ * Handles system-initiated MediaProjection stops (e.g., user revokes permission,
168
+ * system kills projection). Emits stop event and cleans up resources.
169
+ */
170
+ private val projectionCallback = object : MediaProjection.Callback() {
171
+ override fun onStop() {
172
+ // Emit capture stop event
173
+ try {
174
+ val session = getCurrentSession()
175
+ val duration = System.currentTimeMillis() - (session?.startTime ?: 0)
176
+
177
+ val params = Arguments.createMap().apply {
178
+ putString("sessionId", sessionId ?: "")
179
+ putInt("totalFrames", frameCount)
180
+ putDouble("duration", duration.toDouble())
181
+ }
182
+
183
+ eventEmitter(Constants.EVENT_CAPTURE_STOP, params)
184
+ } catch (e: Exception) {
185
+ Log.e(TAG, "Failed to emit capture stop event", e)
186
+ }
187
+
188
+ // Clean up resources
189
+ cleanup()
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Gets the current capture session information
195
+ *
196
+ * @return CaptureSession with current state, or null if no active session
197
+ */
198
+ private fun getCurrentSession(): CaptureSession? {
199
+ val id = sessionId ?: return null
200
+ val options = captureOptions ?: return null
201
+
202
+ return CaptureSession(
203
+ id = id,
204
+ startTime = sessionStartTime,
205
+ frameCount = frameCount,
206
+ options = options
207
+ )
208
+ }
209
+
210
+ /**
211
+ * Data class to hold screen metrics
212
+ */
213
+ private data class ScreenMetrics(
214
+ val width: Int,
215
+ val height: Int,
216
+ val density: Int
217
+ )
218
+
219
+ /**
220
+ * Gets the device screen dimensions and density
221
+ *
222
+ * Retrieves real screen metrics and applies resolution scaling if configured.
223
+ *
224
+ * @return ScreenMetrics with width, height, and density
225
+ */
226
+ private fun getScreenMetrics(): ScreenMetrics {
227
+ val windowManager = context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager
228
+ val displayMetrics = DisplayMetrics()
229
+
230
+ windowManager.defaultDisplay.getRealMetrics(displayMetrics)
231
+
232
+ var width = displayMetrics.widthPixels
233
+ var height = displayMetrics.heightPixels
234
+ val density = displayMetrics.densityDpi
235
+
236
+ // Apply resolution scaling if specified
237
+ captureOptions?.scaleResolution?.let { scale ->
238
+ if (scale > 0 && scale <= 1.0f) {
239
+ width = (width * scale).toInt()
240
+ height = (height * scale).toInt()
241
+ }
242
+ }
243
+
244
+ return ScreenMetrics(width, height, density)
245
+ }
246
+
247
+ /**
248
+ * Sets up the ImageReader for capturing frames
249
+ *
250
+ * Creates an ImageReader with RGBA_8888 format for interval-based capture.
251
+ * No listener is configured as frames are captured periodically.
252
+ *
253
+ * @param width Screen width in pixels
254
+ * @param height Screen height in pixels
255
+ */
256
+ private fun setupImageReader(width: Int, height: Int) {
257
+ try {
258
+ // Create ImageReader with RGBA_8888 format
259
+ val bufferCount = captureOptions?.advanced?.performance?.imageReaderBuffers
260
+ ?: Constants.DEFAULT_IMAGE_READER_MAX_IMAGES
261
+
262
+ imageReader = ImageReader.newInstance(
263
+ width,
264
+ height,
265
+ PixelFormat.RGBA_8888,
266
+ bufferCount
267
+ )
268
+ // No listener needed for interval mode - periodic capture handles timing
269
+
270
+ } catch (e: Exception) {
271
+ Log.e(TAG, "Failed to setup ImageReader", e)
272
+ throw e
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Creates the VirtualDisplay for screen capture
278
+ *
279
+ * The VirtualDisplay mirrors the device screen to the ImageReader's surface,
280
+ * allowing frame capture without affecting the actual display.
281
+ *
282
+ * @param metrics Screen dimensions and density for the virtual display
283
+ */
284
+ private fun createVirtualDisplay(metrics: ScreenMetrics) {
285
+ try {
286
+ val surface = imageReader?.surface
287
+ ?: throw IllegalStateException("ImageReader not initialized")
288
+
289
+ virtualDisplay = mediaProjection?.createVirtualDisplay(
290
+ VIRTUAL_DISPLAY_NAME,
291
+ metrics.width,
292
+ metrics.height,
293
+ metrics.density,
294
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
295
+ surface,
296
+ null,
297
+ captureHandler
298
+ ) ?: throw IllegalStateException("Failed to create VirtualDisplay")
299
+
300
+ } catch (e: Exception) {
301
+ Log.e(TAG, "Failed to create VirtualDisplay", e)
302
+ throw e
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Starts periodic frame capture at the configured interval
308
+ *
309
+ * Posts a recurring runnable that captures frames at the specified interval.
310
+ * This is the only capture mechanism for interval-based capture.
311
+ */
312
+ private fun startPeriodicCapture() {
313
+ try {
314
+ val interval = captureOptions?.interval ?: Constants.DEFAULT_INTERVAL
315
+
316
+ captureRunnable = object : Runnable {
317
+ override fun run() {
318
+ try {
319
+ // Check if we should continue running
320
+ if (isCapturing) {
321
+ // Only capture if not paused
322
+ if (!isPaused && shouldCapture()) {
323
+ captureFrame()
324
+ }
325
+
326
+ // Always schedule next capture while isCapturing is true
327
+ captureHandler.postDelayed(this, interval)
328
+ }
329
+ } catch (e: Exception) {
330
+ Log.e(TAG, "Error in capture runnable", e)
331
+ handleError(e)
332
+ }
333
+ }
334
+ }
335
+
336
+ // Start the periodic capture
337
+ captureHandler.post(captureRunnable!!)
338
+
339
+ } catch (e: Exception) {
340
+ Log.e(TAG, "Failed to start periodic capture", e)
341
+ throw e
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Stops periodic frame capture
347
+ * Removes the recurring capture runnable from the handler
348
+ */
349
+ private fun stopPeriodicCapture() {
350
+ try {
351
+ captureRunnable?.let { runnable ->
352
+ captureHandler.removeCallbacks(runnable)
353
+ captureRunnable = null
354
+ }
355
+ } catch (e: Exception) {
356
+ Log.e(TAG, "Error stopping periodic capture", e)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Checks if a frame should be captured based on throttling logic
362
+ *
363
+ * Enforces minimum interval between captures to prevent excessive frame rates.
364
+ *
365
+ * @return true if enough time has passed since last capture, false otherwise
366
+ */
367
+ private fun shouldCapture(): Boolean {
368
+ val currentTime = System.currentTimeMillis()
369
+ val interval = captureOptions?.interval ?: Constants.DEFAULT_INTERVAL
370
+ val timeSinceLastCapture = currentTime - lastCaptureTime
371
+
372
+ // Throttle: ensure minimum interval has passed
373
+ return timeSinceLastCapture >= interval
374
+ }
375
+
376
+ /**
377
+ * Captures a single frame from the ImageReader
378
+ *
379
+ * Acquires the latest image and submits it to the background executor
380
+ * for processing (bitmap conversion, overlay rendering, storage).
381
+ */
382
+ private fun captureFrame() {
383
+ try {
384
+ // Acquire the latest image from ImageReader
385
+ val image = imageReader?.acquireLatestImage()
386
+
387
+ if (image != null) {
388
+ // Update last capture time
389
+ lastCaptureTime = System.currentTimeMillis()
390
+
391
+ // Submit image processing to background executor
392
+ processingExecutor.execute {
393
+ try {
394
+ processImage(image)
395
+ } catch (e: Exception) {
396
+ Log.e(TAG, "Error processing image", e)
397
+ handleError(e)
398
+ } finally {
399
+ // Always close the image to release the buffer
400
+ image.close()
401
+ }
402
+ }
403
+ }
404
+
405
+ } catch (e: Exception) {
406
+ Log.e(TAG, "Error capturing frame", e)
407
+ handleError(e)
408
+ }
409
+ }
410
+
411
+
412
+
413
+ /**
414
+ * Processes a captured image: converts to Bitmap and saves to storage
415
+ *
416
+ * Runs on background thread to avoid blocking capture. Performs:
417
+ * 1. Image to Bitmap conversion (with cropping if configured)
418
+ * 2. Overlay rendering (if configured)
419
+ * 3. Storage (temp or permanent based on saveFrames option)
420
+ * 4. Event emission to JavaScript
421
+ *
422
+ * @param image The captured Image from ImageReader
423
+ */
424
+ private fun processImage(image: Image) {
425
+ var bitmap: Bitmap? = null
426
+
427
+ try {
428
+ // Convert Image to Bitmap
429
+ bitmap = bitmapProcessor.imageToBitmap(image, captureOptions)
430
+
431
+ // Get current session info
432
+ val currentSessionId = sessionId ?: throw IllegalStateException("No active session")
433
+ val currentOptions = captureOptions ?: throw IllegalStateException("No capture options")
434
+
435
+ // Increment frame count
436
+ frameCount++
437
+
438
+ // Render overlays if configured
439
+ val overlays = currentOptions.overlays
440
+ if (!overlays.isNullOrEmpty()) {
441
+ overlayRenderer.renderOverlays(
442
+ bitmap = bitmap,
443
+ overlays = overlays,
444
+ frameNumber = frameCount - 1,
445
+ sessionId = currentSessionId
446
+ )
447
+ }
448
+
449
+ // Check storage space and emit warning if low (doesn't block save)
450
+ val threshold = currentOptions.advanced.storage.warningThreshold
451
+ storageManager.isStorageAvailable(threshold)
452
+
453
+ // Save frame (temp or permanent based on saveFrames option)
454
+ val frameInfo = storageManager.saveFrame(
455
+ bitmap = bitmap,
456
+ sessionId = currentSessionId,
457
+ frameNumber = frameCount - 1,
458
+ options = currentOptions
459
+ )
460
+
461
+ // Emit frame captured event to JavaScript
462
+ eventEmitterManager.emitFrameCaptured(frameInfo, frameCount - 1)
463
+
464
+ } catch (e: Exception) {
465
+ Log.e(TAG, "Failed to process image", e)
466
+ handleError(e)
467
+ } finally {
468
+ // Always recycle bitmap to free memory
469
+ bitmap?.recycle()
470
+ }
471
+ }
472
+
473
+
474
+
475
+ /**
476
+ * Starts the capture session
477
+ *
478
+ * Sets up VirtualDisplay and ImageReader, then begins interval-based periodic capture.
479
+ *
480
+ * @throws IllegalStateException if already capturing or not initialized
481
+ */
482
+ fun start() {
483
+ try {
484
+ // Check if already capturing
485
+ if (isCapturing) {
486
+ throw IllegalStateException("Capture already in progress")
487
+ }
488
+
489
+ // Check if initialized
490
+ if (mediaProjection == null || captureOptions == null) {
491
+ throw IllegalStateException("CaptureManager not initialized")
492
+ }
493
+
494
+ // Set state
495
+ isCapturing = true
496
+ isPaused = false
497
+ frameCount = 0
498
+ lastCaptureTime = 0
499
+
500
+ // Get screen metrics
501
+ val metrics = getScreenMetrics()
502
+
503
+ // Setup ImageReader
504
+ setupImageReader(metrics.width, metrics.height)
505
+
506
+ // Create VirtualDisplay
507
+ createVirtualDisplay(metrics)
508
+
509
+ // Start periodic capture (interval mode only)
510
+ startPeriodicCapture()
511
+
512
+ // Emit capture start event to JavaScript
513
+ val currentSessionId = sessionId ?: throw IllegalStateException("No session ID")
514
+ val currentOptions = captureOptions ?: throw IllegalStateException("No capture options")
515
+ eventEmitterManager.emitCaptureStart(currentSessionId, currentOptions)
516
+
517
+ } catch (e: Exception) {
518
+ Log.e(TAG, "Failed to start capture", e)
519
+ isCapturing = false
520
+ cleanup()
521
+ throw e
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Stops the capture session
527
+ *
528
+ * Stops periodic capture, emits final statistics (total frames, duration),
529
+ * and triggers cleanup of all resources.
530
+ */
531
+ fun stop() {
532
+ try {
533
+ if (!isCapturing) {
534
+ return
535
+ }
536
+
537
+ // Set state
538
+ isCapturing = false
539
+
540
+ // Stop periodic capture
541
+ stopPeriodicCapture()
542
+
543
+ // Emit capture stop event with statistics to JavaScript
544
+ val currentSessionId = sessionId ?: ""
545
+ val session = getCurrentSession()
546
+ val duration = if (session != null) {
547
+ System.currentTimeMillis() - session.startTime
548
+ } else {
549
+ 0L
550
+ }
551
+ eventEmitterManager.emitCaptureStop(currentSessionId, frameCount, duration)
552
+
553
+ } catch (e: Exception) {
554
+ Log.e(TAG, "Error stopping capture", e)
555
+ handleError(e)
556
+ } finally {
557
+ // Always cleanup resources
558
+ cleanup()
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Pauses the capture session
564
+ *
565
+ * Stops capturing frames but keeps MediaProjection and VirtualDisplay active.
566
+ * The session can be resumed without re-initialization.
567
+ *
568
+ * @throws IllegalStateException if no active capture session
569
+ */
570
+ fun pause() {
571
+ try {
572
+ if (!isCapturing) {
573
+ throw IllegalStateException("No active capture session to pause")
574
+ }
575
+
576
+ if (isPaused) {
577
+ return
578
+ }
579
+
580
+ // Set paused state
581
+ isPaused = true
582
+
583
+ } catch (e: Exception) {
584
+ Log.e(TAG, "Failed to pause capture", e)
585
+ throw e
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Resumes a paused capture session
591
+ *
592
+ * Restarts frame capture at the configured interval. Resets the last capture
593
+ * time to avoid immediate capture on resume.
594
+ *
595
+ * @throws IllegalStateException if no active capture session or not paused
596
+ */
597
+ fun resume() {
598
+ try {
599
+ if (!isCapturing) {
600
+ throw IllegalStateException("No active capture session to resume")
601
+ }
602
+
603
+ if (!isPaused) {
604
+ return
605
+ }
606
+
607
+ // Clear paused state
608
+ isPaused = false
609
+
610
+ // Reset last capture time to avoid immediate capture
611
+ lastCaptureTime = System.currentTimeMillis()
612
+
613
+ } catch (e: Exception) {
614
+ Log.e(TAG, "Failed to resume capture", e)
615
+ throw e
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Gets the current capture status
621
+ *
622
+ * @return CaptureStatus with current state (IDLE/CAPTURING/PAUSED) and session info
623
+ */
624
+ fun getStatus(): CaptureStatus {
625
+ val state = when {
626
+ !isCapturing -> CaptureState.IDLE
627
+ isPaused -> CaptureState.PAUSED
628
+ else -> CaptureState.CAPTURING
629
+ }
630
+
631
+ val session = if (isCapturing && sessionId != null && captureOptions != null) {
632
+ CaptureSession(
633
+ id = sessionId!!,
634
+ startTime = System.currentTimeMillis(),
635
+ frameCount = frameCount,
636
+ options = captureOptions!!
637
+ )
638
+ } else {
639
+ null
640
+ }
641
+
642
+ return CaptureStatus(
643
+ state = state,
644
+ session = session,
645
+ isPaused = isPaused
646
+ )
647
+ }
648
+
649
+
650
+
651
+ /**
652
+ * Cleans up all resources
653
+ *
654
+ * Releases VirtualDisplay, ImageReader, MediaProjection, shuts down executors,
655
+ * clears overlay caches, and resets all state variables. Safe to call multiple times.
656
+ */
657
+ fun cleanup() {
658
+ try {
659
+ // Stop periodic capture
660
+ stopPeriodicCapture()
661
+
662
+ // Release VirtualDisplay
663
+ try {
664
+ virtualDisplay?.release()
665
+ virtualDisplay = null
666
+ } catch (e: Exception) {
667
+ Log.e(TAG, "Error releasing VirtualDisplay", e)
668
+ }
669
+
670
+ // Close ImageReader
671
+ try {
672
+ imageReader?.close()
673
+ imageReader = null
674
+ } catch (e: Exception) {
675
+ Log.e(TAG, "Error closing ImageReader", e)
676
+ }
677
+
678
+ // Unregister callback and stop MediaProjection
679
+ try {
680
+ mediaProjection?.unregisterCallback(projectionCallback)
681
+ mediaProjection?.stop()
682
+ mediaProjection = null
683
+ } catch (e: Exception) {
684
+ Log.e(TAG, "Error stopping MediaProjection", e)
685
+ }
686
+
687
+ // Shutdown ExecutorService with timeout
688
+ try {
689
+ processingExecutor.shutdown()
690
+
691
+ val shutdownTimeout = captureOptions?.advanced?.performance?.executorShutdownTimeout
692
+ ?: Constants.DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT
693
+ val forcedTimeout = captureOptions?.advanced?.performance?.executorForcedShutdownTimeout
694
+ ?: Constants.DEFAULT_EXECUTOR_FORCED_SHUTDOWN_TIMEOUT
695
+
696
+ if (!processingExecutor.awaitTermination(shutdownTimeout, TimeUnit.MILLISECONDS)) {
697
+ processingExecutor.shutdownNow()
698
+
699
+ // Wait a bit more for forced shutdown
700
+ if (!processingExecutor.awaitTermination(forcedTimeout, TimeUnit.MILLISECONDS)) {
701
+ Log.e(TAG, "ExecutorService did not terminate after forced shutdown")
702
+ }
703
+ }
704
+ } catch (e: InterruptedException) {
705
+ Log.e(TAG, "Interrupted while shutting down ExecutorService", e)
706
+ processingExecutor.shutdownNow()
707
+ Thread.currentThread().interrupt()
708
+ } catch (e: Exception) {
709
+ Log.e(TAG, "Error shutting down ExecutorService", e)
710
+ }
711
+
712
+ // Clear overlay caches
713
+ try {
714
+ overlayRenderer.clearCaches()
715
+ } catch (e: Exception) {
716
+ Log.e(TAG, "Error clearing overlay caches", e)
717
+ }
718
+
719
+ // Clear all state variables
720
+ captureOptions = null
721
+ sessionId = null
722
+ frameCount = 0
723
+ isCapturing = false
724
+ isPaused = false
725
+ lastCaptureTime = 0
726
+ captureRunnable = null
727
+
728
+ } catch (e: Exception) {
729
+ Log.e(TAG, "Error during cleanup", e)
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Releases the capture thread
735
+ *
736
+ * Should be called when the CaptureManager is no longer needed (e.g., module
737
+ * invalidation). Performs cleanup and quits the background capture thread.
738
+ */
739
+ fun release() {
740
+ try {
741
+ // Cleanup resources first
742
+ cleanup()
743
+
744
+ // Quit the capture thread
745
+ captureThread.quitSafely()
746
+ } catch (e: Exception) {
747
+ Log.e(TAG, "Error releasing CaptureManager", e)
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Handles errors that occur during capture operations
753
+ *
754
+ * Classifies exceptions into appropriate error codes, emits error events
755
+ * to JavaScript, and performs cleanup if capture is active.
756
+ *
757
+ * @param error The exception that occurred
758
+ */
759
+ private fun handleError(error: Exception) {
760
+ try {
761
+ // Classify the error and determine error code
762
+ val (errorCode, errorMessage, errorDetails) = classifyError(error)
763
+
764
+ Log.e(TAG, "Capture error: $errorMessage (code: ${errorCode.value})", error)
765
+
766
+ // Emit error event
767
+ eventEmitterManager.emitError(errorCode, errorMessage, errorDetails)
768
+
769
+ // Cleanup resources on error
770
+ if (isCapturing) {
771
+ isCapturing = false
772
+ cleanup()
773
+ }
774
+
775
+ } catch (e: Exception) {
776
+ Log.e(TAG, "Error in error handler", e)
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Classifies an exception into an error code, message, and details
782
+ *
783
+ * Maps common exceptions to appropriate ErrorCode values and provides
784
+ * contextual details for debugging.
785
+ *
786
+ * @param error The exception to classify
787
+ * @return Triple of (ErrorCode, error message, optional details map)
788
+ */
789
+ private fun classifyError(error: Exception): Triple<ErrorCode, String, Map<String, Any>?> {
790
+ return when (error) {
791
+ is SecurityException -> Triple(
792
+ ErrorCode.PERMISSION_DENIED,
793
+ "Permission denied: ${error.message}",
794
+ mapOf(Constants.ERROR_DETAIL_PERMISSION to "MEDIA_PROJECTION")
795
+ )
796
+
797
+ is IllegalStateException -> Triple(
798
+ ErrorCode.ALREADY_CAPTURING,
799
+ "Invalid state: ${error.message}",
800
+ mapOf(Constants.ERROR_DETAIL_CURRENT_STATE to getStatus().state.value)
801
+ )
802
+
803
+ is IllegalArgumentException -> Triple(
804
+ ErrorCode.INVALID_OPTIONS,
805
+ "Invalid configuration: ${error.message}",
806
+ null
807
+ )
808
+
809
+ is java.io.IOException -> Triple(
810
+ ErrorCode.STORAGE_ERROR,
811
+ "Storage error: ${error.message}",
812
+ mapOf(Constants.ERROR_DETAIL_AVAILABLE_SPACE to storageManager.checkStorageSpace())
813
+ )
814
+
815
+ is OutOfMemoryError -> Triple(
816
+ ErrorCode.SYSTEM_ERROR,
817
+ "Out of memory during capture",
818
+ mapOf(
819
+ Constants.ERROR_DETAIL_HEAP_SIZE to Runtime.getRuntime().maxMemory(),
820
+ Constants.ERROR_DETAIL_USED_MEMORY to (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory())
821
+ )
822
+ )
823
+
824
+ else -> Triple(
825
+ ErrorCode.SYSTEM_ERROR,
826
+ "Unexpected error: ${error.message ?: error.javaClass.simpleName}",
827
+ mapOf(Constants.ERROR_DETAIL_ERROR_TYPE to error.javaClass.simpleName)
828
+ )
829
+ }
830
+ }
831
+ }