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.
- package/FrameCapture.podspec +21 -0
- package/LICENSE +20 -0
- package/README.md +158 -0
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +20 -0
- package/android/src/main/java/com/framecapture/CaptureManager.kt +831 -0
- package/android/src/main/java/com/framecapture/Constants.kt +196 -0
- package/android/src/main/java/com/framecapture/ErrorHandler.kt +165 -0
- package/android/src/main/java/com/framecapture/FrameCaptureModule.kt +653 -0
- package/android/src/main/java/com/framecapture/FrameCapturePackage.kt +32 -0
- package/android/src/main/java/com/framecapture/OverlayRenderer.kt +423 -0
- package/android/src/main/java/com/framecapture/PermissionHandler.kt +150 -0
- package/android/src/main/java/com/framecapture/ScreenCaptureService.kt +366 -0
- package/android/src/main/java/com/framecapture/StorageManager.kt +221 -0
- package/android/src/main/java/com/framecapture/capture/BitmapProcessor.kt +157 -0
- package/android/src/main/java/com/framecapture/capture/CaptureEventEmitter.kt +120 -0
- package/android/src/main/java/com/framecapture/models/CaptureModels.kt +302 -0
- package/android/src/main/java/com/framecapture/models/EnumsAndExtensions.kt +60 -0
- package/android/src/main/java/com/framecapture/models/OverlayModels.kt +154 -0
- package/android/src/main/java/com/framecapture/service/CaptureNotificationManager.kt +286 -0
- package/android/src/main/java/com/framecapture/storage/StorageStrategies.kt +317 -0
- package/android/src/main/java/com/framecapture/utils/ValidationUtils.kt +379 -0
- package/app.plugin.js +1 -0
- package/ios/FrameCapture.h +5 -0
- package/ios/FrameCapture.mm +21 -0
- package/lib/module/NativeFrameCapture.js +24 -0
- package/lib/module/NativeFrameCapture.js.map +1 -0
- package/lib/module/api.js +146 -0
- package/lib/module/api.js.map +1 -0
- package/lib/module/constants.js +67 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/errors.js +19 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +58 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +24 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/normalize.js +51 -0
- package/lib/module/normalize.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +165 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validation.js +190 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/plugin/src/index.d.ts +4 -0
- package/lib/typescript/plugin/src/index.d.ts.map +1 -0
- package/lib/typescript/src/NativeFrameCapture.d.ts +75 -0
- package/lib/typescript/src/NativeFrameCapture.d.ts.map +1 -0
- package/lib/typescript/src/api.d.ts +66 -0
- package/lib/typescript/src/api.d.ts.map +1 -0
- package/lib/typescript/src/constants.d.ts +41 -0
- package/lib/typescript/src/constants.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +14 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +30 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +12 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/normalize.d.ts +43 -0
- package/lib/typescript/src/normalize.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +247 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +15 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +196 -0
- package/plugin/build/index.js +48 -0
- package/src/NativeFrameCapture.ts +86 -0
- package/src/api.ts +189 -0
- package/src/constants.ts +69 -0
- package/src/errors.ts +21 -0
- package/src/events.ts +61 -0
- package/src/index.tsx +31 -0
- package/src/normalize.ts +81 -0
- package/src/types.ts +327 -0
- 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
|
+
}
|