react-native-frame-capture 1.0.1 → 1.1.0

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