react-native-frame-capture 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/FrameCapture.podspec +21 -0
  2. package/LICENSE +20 -0
  3. package/README.md +158 -0
  4. package/android/build.gradle +77 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +20 -0
  7. package/android/src/main/java/com/framecapture/CaptureManager.kt +831 -0
  8. package/android/src/main/java/com/framecapture/Constants.kt +196 -0
  9. package/android/src/main/java/com/framecapture/ErrorHandler.kt +165 -0
  10. package/android/src/main/java/com/framecapture/FrameCaptureModule.kt +653 -0
  11. package/android/src/main/java/com/framecapture/FrameCapturePackage.kt +32 -0
  12. package/android/src/main/java/com/framecapture/OverlayRenderer.kt +423 -0
  13. package/android/src/main/java/com/framecapture/PermissionHandler.kt +150 -0
  14. package/android/src/main/java/com/framecapture/ScreenCaptureService.kt +366 -0
  15. package/android/src/main/java/com/framecapture/StorageManager.kt +221 -0
  16. package/android/src/main/java/com/framecapture/capture/BitmapProcessor.kt +157 -0
  17. package/android/src/main/java/com/framecapture/capture/CaptureEventEmitter.kt +120 -0
  18. package/android/src/main/java/com/framecapture/models/CaptureModels.kt +302 -0
  19. package/android/src/main/java/com/framecapture/models/EnumsAndExtensions.kt +60 -0
  20. package/android/src/main/java/com/framecapture/models/OverlayModels.kt +154 -0
  21. package/android/src/main/java/com/framecapture/service/CaptureNotificationManager.kt +286 -0
  22. package/android/src/main/java/com/framecapture/storage/StorageStrategies.kt +317 -0
  23. package/android/src/main/java/com/framecapture/utils/ValidationUtils.kt +379 -0
  24. package/app.plugin.js +1 -0
  25. package/ios/FrameCapture.h +5 -0
  26. package/ios/FrameCapture.mm +21 -0
  27. package/lib/module/NativeFrameCapture.js +24 -0
  28. package/lib/module/NativeFrameCapture.js.map +1 -0
  29. package/lib/module/api.js +146 -0
  30. package/lib/module/api.js.map +1 -0
  31. package/lib/module/constants.js +67 -0
  32. package/lib/module/constants.js.map +1 -0
  33. package/lib/module/errors.js +19 -0
  34. package/lib/module/errors.js.map +1 -0
  35. package/lib/module/events.js +58 -0
  36. package/lib/module/events.js.map +1 -0
  37. package/lib/module/index.js +24 -0
  38. package/lib/module/index.js.map +1 -0
  39. package/lib/module/normalize.js +51 -0
  40. package/lib/module/normalize.js.map +1 -0
  41. package/lib/module/package.json +1 -0
  42. package/lib/module/types.js +165 -0
  43. package/lib/module/types.js.map +1 -0
  44. package/lib/module/validation.js +190 -0
  45. package/lib/module/validation.js.map +1 -0
  46. package/lib/typescript/package.json +1 -0
  47. package/lib/typescript/plugin/src/index.d.ts +4 -0
  48. package/lib/typescript/plugin/src/index.d.ts.map +1 -0
  49. package/lib/typescript/src/NativeFrameCapture.d.ts +75 -0
  50. package/lib/typescript/src/NativeFrameCapture.d.ts.map +1 -0
  51. package/lib/typescript/src/api.d.ts +66 -0
  52. package/lib/typescript/src/api.d.ts.map +1 -0
  53. package/lib/typescript/src/constants.d.ts +41 -0
  54. package/lib/typescript/src/constants.d.ts.map +1 -0
  55. package/lib/typescript/src/errors.d.ts +14 -0
  56. package/lib/typescript/src/errors.d.ts.map +1 -0
  57. package/lib/typescript/src/events.d.ts +30 -0
  58. package/lib/typescript/src/events.d.ts.map +1 -0
  59. package/lib/typescript/src/index.d.ts +12 -0
  60. package/lib/typescript/src/index.d.ts.map +1 -0
  61. package/lib/typescript/src/normalize.d.ts +43 -0
  62. package/lib/typescript/src/normalize.d.ts.map +1 -0
  63. package/lib/typescript/src/types.d.ts +247 -0
  64. package/lib/typescript/src/types.d.ts.map +1 -0
  65. package/lib/typescript/src/validation.d.ts +15 -0
  66. package/lib/typescript/src/validation.d.ts.map +1 -0
  67. package/package.json +196 -0
  68. package/plugin/build/index.js +48 -0
  69. package/src/NativeFrameCapture.ts +86 -0
  70. package/src/api.ts +189 -0
  71. package/src/constants.ts +69 -0
  72. package/src/errors.ts +21 -0
  73. package/src/events.ts +61 -0
  74. package/src/index.tsx +31 -0
  75. package/src/normalize.ts +81 -0
  76. package/src/types.ts +327 -0
  77. package/src/validation.ts +321 -0
@@ -0,0 +1,423 @@
1
+ package com.framecapture
2
+
3
+ import android.content.Context
4
+ import android.graphics.*
5
+ import android.net.Uri
6
+ import android.util.Log
7
+ import android.util.LruCache
8
+ import com.framecapture.models.OverlayConfig
9
+ import com.framecapture.models.OverlayPosition
10
+ import com.framecapture.models.TextStyle
11
+ import java.io.File
12
+ import java.text.SimpleDateFormat
13
+ import java.util.*
14
+
15
+ /**
16
+ * Renders overlays (text and images) on captured frame bitmaps
17
+ * Uses caching for performance optimization
18
+ */
19
+ class OverlayRenderer(
20
+ private val context: Context,
21
+ private val eventEmitter: ((String, com.facebook.react.bridge.WritableMap?) -> Unit)? = null,
22
+ cacheSize: Int = Constants.DEFAULT_OVERLAY_IMAGE_CACHE_SIZE
23
+ ) {
24
+
25
+ companion object {
26
+ private const val TAG = "OverlayRenderer"
27
+ }
28
+
29
+ // Image cache to avoid repeated file I/O
30
+ private var imageCache: LruCache<String, Bitmap> = object : LruCache<String, Bitmap>(cacheSize) {
31
+ override fun sizeOf(key: String, bitmap: Bitmap): Int {
32
+ return bitmap.byteCount
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Updates the cache size (recreates the cache)
38
+ */
39
+ fun updateCacheSize(newSize: Int) {
40
+ clearCaches()
41
+ imageCache = object : LruCache<String, Bitmap>(newSize) {
42
+ override fun sizeOf(key: String, bitmap: Bitmap): Int {
43
+ return bitmap.byteCount
44
+ }
45
+ }
46
+ }
47
+
48
+ // Paint object pool for text rendering
49
+ private val textPaintCache = mutableMapOf<String, Paint>()
50
+
51
+ /**
52
+ * Renders all overlays on the bitmap
53
+ *
54
+ * @param bitmap The bitmap to draw overlays on (modified in-place)
55
+ * @param overlays List of overlay configurations
56
+ * @param frameNumber Current frame number for variable substitution
57
+ * @param sessionId Current session ID for variable substitution
58
+ */
59
+ fun renderOverlays(
60
+ bitmap: Bitmap,
61
+ overlays: List<OverlayConfig>,
62
+ frameNumber: Int,
63
+ sessionId: String
64
+ ) {
65
+ if (overlays.isEmpty()) return
66
+
67
+ val canvas = Canvas(bitmap)
68
+
69
+ overlays.forEachIndexed { index, overlay ->
70
+ try {
71
+ when (overlay) {
72
+ is OverlayConfig.Text -> {
73
+ // Validate text overlay has content
74
+ if (overlay.content.isEmpty()) {
75
+ return@forEachIndexed
76
+ }
77
+ renderTextOverlay(canvas, overlay, frameNumber, sessionId)
78
+ }
79
+ is OverlayConfig.Image -> {
80
+ // Validate image overlay has source
81
+ if (overlay.source.isEmpty()) {
82
+ return@forEachIndexed
83
+ }
84
+ renderImageOverlay(canvas, overlay, bitmap.width, bitmap.height)
85
+ }
86
+ }
87
+ } catch (e: Exception) {
88
+ Log.e(TAG, "Failed to render overlay at index $index (type: ${overlay.type}): ${e.message}", e)
89
+ emitOverlayError(index, overlay.type, e.message ?: "Unknown error")
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Substitutes template variables in text content
96
+ */
97
+ private fun substituteVariables(content: String, frameNumber: Int, sessionId: String): String {
98
+ var result = content
99
+
100
+ // {frameNumber}
101
+ result = result.replace(Constants.TEMPLATE_VAR_FRAME_NUMBER, frameNumber.toString())
102
+
103
+ // {sessionId}
104
+ result = result.replace(Constants.TEMPLATE_VAR_SESSION_ID, sessionId)
105
+
106
+ // {timestamp}
107
+ val timestamp = SimpleDateFormat(Constants.OVERLAY_TIMESTAMP_FORMAT, Locale.US).apply {
108
+ timeZone = TimeZone.getTimeZone(Constants.OVERLAY_TIMESTAMP_TIMEZONE)
109
+ }.format(Date())
110
+ result = result.replace(Constants.TEMPLATE_VAR_TIMESTAMP, timestamp)
111
+
112
+ return result
113
+ }
114
+
115
+ /**
116
+ * Calculates absolute pixel position from OverlayPosition
117
+ */
118
+ private fun calculatePosition(
119
+ position: OverlayPosition,
120
+ canvasWidth: Int,
121
+ canvasHeight: Int,
122
+ overlayWidth: Int,
123
+ overlayHeight: Int
124
+ ): Pair<Int, Int> {
125
+ return when (position) {
126
+ is OverlayPosition.Preset -> {
127
+ val padding = Constants.OVERLAY_DEFAULT_PADDING
128
+ when (position.value) {
129
+ Constants.POSITION_TOP_LEFT -> padding to padding
130
+ Constants.POSITION_TOP_RIGHT -> (canvasWidth - overlayWidth - padding) to padding
131
+ Constants.POSITION_BOTTOM_LEFT -> padding to (canvasHeight - overlayHeight - padding)
132
+ Constants.POSITION_BOTTOM_RIGHT -> (canvasWidth - overlayWidth - padding) to (canvasHeight - overlayHeight - padding)
133
+ Constants.POSITION_CENTER -> ((canvasWidth - overlayWidth) / 2) to ((canvasHeight - overlayHeight) / 2)
134
+ else -> padding to padding
135
+ }
136
+ }
137
+ is OverlayPosition.Coordinates -> {
138
+ val x = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
139
+ (position.x * canvasWidth).toInt()
140
+ } else {
141
+ position.x.toInt()
142
+ }
143
+
144
+ val y = if (position.unit == Constants.POSITION_UNIT_PERCENTAGE) {
145
+ (position.y * canvasHeight).toInt()
146
+ } else {
147
+ position.y.toInt()
148
+ }
149
+
150
+ // Clamp to canvas bounds
151
+ val clampedX = x.coerceIn(0, canvasWidth - overlayWidth)
152
+ val clampedY = y.coerceIn(0, canvasHeight - overlayHeight)
153
+
154
+ clampedX to clampedY
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Renders a text overlay on the canvas
161
+ */
162
+ private fun renderTextOverlay(
163
+ canvas: Canvas,
164
+ overlay: OverlayConfig.Text,
165
+ frameNumber: Int,
166
+ sessionId: String
167
+ ) {
168
+ try {
169
+ // Substitute template variables
170
+ val text = substituteVariables(overlay.content, frameNumber, sessionId)
171
+
172
+ // Get or create paint object
173
+ val paint = getTextPaint(overlay.style)
174
+
175
+ // Measure text bounds
176
+ val bounds = Rect()
177
+ paint.getTextBounds(text, 0, text.length, bounds)
178
+
179
+ val textWidth = bounds.width()
180
+ val textHeight = bounds.height()
181
+
182
+ // Calculate position
183
+ val (x, y) = calculatePosition(
184
+ overlay.position,
185
+ canvas.width,
186
+ canvas.height,
187
+ textWidth + overlay.style.padding * 2,
188
+ textHeight + overlay.style.padding * 2
189
+ )
190
+
191
+ // Draw background if specified
192
+ if (overlay.style.backgroundColor.isNotEmpty()) {
193
+ val parsedColor = parseColor(overlay.style.backgroundColor)
194
+
195
+ val bgPaint = Paint().apply {
196
+ color = parsedColor
197
+ style = Paint.Style.FILL
198
+ }
199
+
200
+ canvas.drawRect(
201
+ x.toFloat(),
202
+ y.toFloat(),
203
+ x + textWidth + overlay.style.padding * 2f,
204
+ y + textHeight + overlay.style.padding * 2f,
205
+ bgPaint
206
+ )
207
+ }
208
+
209
+ // Draw text
210
+ canvas.drawText(
211
+ text,
212
+ x + overlay.style.padding.toFloat(),
213
+ y + textHeight + overlay.style.padding.toFloat(),
214
+ paint
215
+ )
216
+ } catch (e: Exception) {
217
+ Log.e(TAG, "Error rendering text overlay: ${e.message}", e)
218
+ throw e
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Renders an image overlay on the canvas
224
+ */
225
+ private fun renderImageOverlay(
226
+ canvas: Canvas,
227
+ overlay: OverlayConfig.Image,
228
+ canvasWidth: Int,
229
+ canvasHeight: Int
230
+ ) {
231
+ try {
232
+ // Load image (from cache or file)
233
+ val image = loadImage(overlay.source)
234
+ ?: throw IllegalArgumentException("Failed to load image from source: ${overlay.source}")
235
+
236
+ // Determine image size
237
+ val (width, height) = overlay.size?.let { it.width to it.height }
238
+ ?: (image.width to image.height)
239
+
240
+ // Calculate position
241
+ val (x, y) = calculatePosition(
242
+ overlay.position,
243
+ canvasWidth,
244
+ canvasHeight,
245
+ width,
246
+ height
247
+ )
248
+
249
+ // Create paint with opacity
250
+ val paint = Paint().apply {
251
+ alpha = (overlay.opacity * 255).toInt()
252
+ }
253
+
254
+ // Scale image if needed
255
+ val scaledImage = if (overlay.size != null) {
256
+ Bitmap.createScaledBitmap(image, width, height, true)
257
+ } else {
258
+ image
259
+ }
260
+
261
+ // Draw image
262
+ canvas.drawBitmap(scaledImage, x.toFloat(), y.toFloat(), paint)
263
+
264
+ // Clean up scaled bitmap if created
265
+ if (scaledImage != image) {
266
+ scaledImage.recycle()
267
+ }
268
+ } catch (e: Exception) {
269
+ Log.e(TAG, "Error rendering image overlay from ${overlay.source}: ${e.message}", e)
270
+ throw e
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Gets or creates a Paint object for text rendering
276
+ */
277
+ private fun getTextPaint(style: TextStyle): Paint {
278
+ val cacheKey = "${style.fontSize}_${style.color}_${style.fontWeight}_${style.textAlign}"
279
+
280
+ return textPaintCache.getOrPut(cacheKey) {
281
+ Paint().apply {
282
+ color = parseColor(style.color)
283
+ textSize = style.fontSize.toFloat()
284
+ isAntiAlias = true
285
+ isFakeBoldText = style.fontWeight == Constants.TEXT_WEIGHT_BOLD
286
+ textAlign = when (style.textAlign) {
287
+ Constants.TEXT_ALIGN_CENTER -> Paint.Align.CENTER
288
+ Constants.TEXT_ALIGN_RIGHT -> Paint.Align.RIGHT
289
+ else -> Paint.Align.LEFT
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Loads an image from drawable resource, file, or cache
297
+ *
298
+ * Supports:
299
+ * - Drawable resource names: "logo", "ic_watermark" (looks in app's drawable folder)
300
+ * - File URIs: "file:///path/to/image.png"
301
+ * - Content URIs: "content://media/external/images/media/123"
302
+ */
303
+ private fun loadImage(source: String): Bitmap? {
304
+ // Check cache first
305
+ imageCache.get(source)?.let { return it }
306
+
307
+ return try {
308
+ val bitmap = when {
309
+ // Check if it's a drawable resource name (no scheme, just a name)
310
+ !source.contains("://") && !source.startsWith("/") -> {
311
+ loadImageFromDrawable(source)
312
+ }
313
+ // File or content URI
314
+ else -> {
315
+ loadImageFromUri(source)
316
+ }
317
+ }
318
+
319
+ // Cache the loaded image
320
+ bitmap?.let { imageCache.put(source, it) }
321
+
322
+ bitmap
323
+ } catch (e: Exception) {
324
+ null
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Loads an image from drawable resources by name
330
+ */
331
+ private fun loadImageFromDrawable(resourceName: String): Bitmap? {
332
+ return try {
333
+ val resourceId = context.resources.getIdentifier(
334
+ resourceName,
335
+ Constants.RESOURCE_TYPE_DRAWABLE,
336
+ context.packageName
337
+ )
338
+
339
+ if (resourceId == 0) {
340
+ return null
341
+ }
342
+
343
+ BitmapFactory.decodeResource(context.resources, resourceId)
344
+ } catch (e: Exception) {
345
+ null
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Loads an image from file or content URI
351
+ */
352
+ private fun loadImageFromUri(source: String): Bitmap? {
353
+ return try {
354
+ val uri = Uri.parse(source)
355
+ val path = when (uri.scheme) {
356
+ Constants.URI_SCHEME_FILE -> uri.path
357
+ Constants.URI_SCHEME_CONTENT -> uri.toString()
358
+ else -> source
359
+ }
360
+
361
+ if (path != null && File(path).exists()) {
362
+ BitmapFactory.decodeFile(path)
363
+ } else {
364
+ context.contentResolver.openInputStream(uri)?.use {
365
+ BitmapFactory.decodeStream(it)
366
+ }
367
+ }
368
+ } catch (e: Exception) {
369
+ null
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Parses hex color string to Android Color int
375
+ * Supports both #RRGGBB and #RRGGBBAA formats
376
+ */
377
+ private fun parseColor(colorString: String): Int {
378
+ return try {
379
+ var color = colorString.trim()
380
+
381
+ // Handle #RRGGBBAA format (8 chars including #)
382
+ if (color.length == 9 && color.startsWith("#")) {
383
+ // Extract components
384
+ val r = color.substring(1, 3).toInt(16)
385
+ val g = color.substring(3, 5).toInt(16)
386
+ val b = color.substring(5, 7).toInt(16)
387
+ val a = color.substring(7, 9).toInt(16)
388
+
389
+ // Construct ARGB color
390
+ return Color.argb(a, r, g, b)
391
+ }
392
+
393
+ // For other formats, use standard parser
394
+ Color.parseColor(color)
395
+ } catch (e: Exception) {
396
+ Color.WHITE
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Emits overlay error event to JavaScript
402
+ */
403
+ private fun emitOverlayError(overlayIndex: Int, overlayType: String, errorMessage: String) {
404
+ try {
405
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
406
+ putInt("overlayIndex", overlayIndex)
407
+ putString("overlayType", overlayType)
408
+ putString("message", errorMessage)
409
+ }
410
+ eventEmitter?.invoke(Constants.EVENT_OVERLAY_ERROR, params)
411
+ } catch (e: Exception) {
412
+ // Silently fail - event emission is not critical
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Clears all caches
418
+ */
419
+ fun clearCaches() {
420
+ imageCache.evictAll()
421
+ textPaintCache.clear()
422
+ }
423
+ }
@@ -0,0 +1,150 @@
1
+ package com.framecapture
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.media.projection.MediaProjectionManager
6
+ import com.facebook.react.bridge.Promise
7
+ import com.framecapture.models.ErrorCode
8
+ import com.framecapture.models.PermissionStatus
9
+
10
+ /**
11
+ * Handles MediaProjection permission requests and status checks
12
+ *
13
+ * Manages the complete MediaProjection permission flow:
14
+ * - Requests permission via system dialog
15
+ * - Handles activity result callbacks
16
+ * - Stores permission data for later use
17
+ * - Checks permission status
18
+ *
19
+ * Note: MediaProjection permission cannot be checked programmatically on Android.
20
+ * This handler only tracks whether permission data has been granted and stored.
21
+ */
22
+ class PermissionHandler(
23
+ private val getActivity: () -> Activity?
24
+ ) {
25
+
26
+ // Permission request promise
27
+ private var permissionPromise: Promise? = null
28
+
29
+ // Stored projection data after permission granted
30
+ var mediaProjectionData: Intent? = null
31
+ private set
32
+
33
+ /**
34
+ * Requests MediaProjection permission from the user
35
+ *
36
+ * Opens the Android system permission dialog for screen capture access.
37
+ * The result is handled asynchronously via handleActivityResult().
38
+ *
39
+ * @param promise Resolves with PermissionStatus.GRANTED or rejects with error
40
+ */
41
+ fun requestPermission(promise: Promise) {
42
+ try {
43
+ val activity = getActivity()
44
+ if (activity == null) {
45
+ promise.reject(
46
+ ErrorCode.SYSTEM_ERROR.value,
47
+ "Activity is null, cannot request permission"
48
+ )
49
+ return
50
+ }
51
+
52
+ // Check if already requesting permission
53
+ if (permissionPromise != null) {
54
+ promise.reject(
55
+ ErrorCode.SYSTEM_ERROR.value,
56
+ "Permission request already in progress"
57
+ )
58
+ return
59
+ }
60
+
61
+ // Store promise for later resolution
62
+ permissionPromise = promise
63
+
64
+ // Create MediaProjection intent
65
+ val mediaProjectionManager = activity.getSystemService(
66
+ Activity.MEDIA_PROJECTION_SERVICE
67
+ ) as? MediaProjectionManager
68
+
69
+ if (mediaProjectionManager == null) {
70
+ permissionPromise = null
71
+ promise.reject(
72
+ ErrorCode.NOT_SUPPORTED.value,
73
+ "MediaProjection service not available"
74
+ )
75
+ return
76
+ }
77
+
78
+ val intent = mediaProjectionManager.createScreenCaptureIntent()
79
+ activity.startActivityForResult(intent, Constants.REQUEST_MEDIA_PROJECTION, null)
80
+
81
+ } catch (e: Exception) {
82
+ permissionPromise = null
83
+ throw e
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Checks current permission status
89
+ *
90
+ * Note: MediaProjection permission cannot be checked programmatically on Android.
91
+ * This only verifies if permission data has been stored from a previous grant.
92
+ *
93
+ * @return PermissionStatus.GRANTED if data exists, NOT_DETERMINED otherwise
94
+ */
95
+ fun checkPermission(): PermissionStatus {
96
+ return if (mediaProjectionData != null) {
97
+ PermissionStatus.GRANTED
98
+ } else {
99
+ PermissionStatus.NOT_DETERMINED
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Handles activity result from permission request
105
+ *
106
+ * Should be called from the activity event listener. Resolves or rejects
107
+ * the stored promise based on the user's permission decision.
108
+ *
109
+ * @param requestCode Activity request code
110
+ * @param resultCode Activity result code (RESULT_OK or RESULT_CANCELED)
111
+ * @param data Intent containing MediaProjection permission data
112
+ * @return true if this was a MediaProjection request, false otherwise
113
+ */
114
+ fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
115
+ if (requestCode != Constants.REQUEST_MEDIA_PROJECTION) {
116
+ return false
117
+ }
118
+
119
+ val promise = permissionPromise
120
+ permissionPromise = null
121
+
122
+ if (promise == null) {
123
+ // No promise stored - result already handled or unexpected callback
124
+ return true
125
+ }
126
+
127
+ if (resultCode == Activity.RESULT_OK && data != null) {
128
+ // Store projection data for later use
129
+ mediaProjectionData = data
130
+ promise.resolve(PermissionStatus.GRANTED.value)
131
+ } else {
132
+ // User denied permission
133
+ promise.reject(
134
+ ErrorCode.PERMISSION_DENIED.value,
135
+ "User denied MediaProjection permission"
136
+ )
137
+ }
138
+
139
+ return true
140
+ }
141
+
142
+ /**
143
+ * Clears stored permission data
144
+ * Called during module cleanup to reset state
145
+ */
146
+ fun clear() {
147
+ mediaProjectionData = null
148
+ permissionPromise = null
149
+ }
150
+ }