capacitor-camera-view 2.0.2 → 2.2.0-rc.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 (31) hide show
  1. package/README.md +215 -19
  2. package/android/build.gradle +9 -5
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +491 -116
  5. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +181 -31
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraResult.kt +47 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +11 -1
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/VideoRecordingQuality.kt +10 -0
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +114 -5
  10. package/dist/docs.json +281 -8
  11. package/dist/esm/definitions.d.ts +128 -6
  12. package/dist/esm/definitions.js.map +1 -1
  13. package/dist/esm/web.d.ts +26 -4
  14. package/dist/esm/web.js +218 -18
  15. package/dist/esm/web.js.map +1 -1
  16. package/dist/plugin.cjs.js +219 -18
  17. package/dist/plugin.cjs.js.map +1 -1
  18. package/dist/plugin.js +219 -18
  19. package/dist/plugin.js.map +1 -1
  20. package/ios/Sources/CameraViewPlugin/CameraError.swift +125 -2
  21. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +109 -0
  22. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +28 -1
  23. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +30 -41
  24. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +38 -7
  25. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +4 -3
  26. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +302 -0
  27. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +246 -166
  28. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +194 -96
  29. package/ios/Sources/CameraViewPlugin/TempFileManager.swift +215 -0
  30. package/ios/Sources/CameraViewPlugin/Utils.swift +102 -0
  31. package/package.json +17 -17
@@ -1,7 +1,9 @@
1
1
  package com.michaelwolz.capacitorcameraview
2
2
 
3
+ import android.Manifest
3
4
  import android.content.Context
4
5
  import android.content.Context.CAMERA_SERVICE
6
+ import android.content.pm.PackageManager
5
7
  import android.graphics.Bitmap
6
8
  import android.hardware.camera2.CameraCharacteristics
7
9
  import android.hardware.camera2.CameraManager
@@ -23,8 +25,16 @@ import androidx.camera.core.TorchState
23
25
  import androidx.camera.core.resolutionselector.AspectRatioStrategy
24
26
  import androidx.camera.core.resolutionselector.ResolutionSelector
25
27
  import androidx.camera.mlkit.vision.MlKitAnalyzer
28
+ import androidx.camera.video.FallbackStrategy
29
+ import androidx.camera.video.FileOutputOptions
30
+ import androidx.camera.video.Quality
31
+ import androidx.camera.video.QualitySelector
32
+ import androidx.camera.video.Recording
33
+ import androidx.camera.video.VideoRecordEvent
34
+ import androidx.camera.view.CameraController
26
35
  import androidx.camera.view.LifecycleCameraController
27
36
  import androidx.camera.view.PreviewView
37
+ import androidx.camera.view.video.AudioConfig
28
38
  import androidx.core.content.ContextCompat
29
39
  import androidx.lifecycle.LifecycleOwner
30
40
  import com.getcapacitor.FileUtils
@@ -36,20 +46,49 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
36
46
  import com.google.mlkit.vision.barcode.common.Barcode
37
47
  import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
38
48
  import com.michaelwolz.capacitorcameraview.model.CameraDevice
49
+ import com.michaelwolz.capacitorcameraview.model.CameraResult
39
50
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
51
+ import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
40
52
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
53
+ import kotlinx.coroutines.CancellableContinuation
54
+ import kotlinx.coroutines.CoroutineScope
55
+ import kotlinx.coroutines.Dispatchers
56
+ import kotlinx.coroutines.SupervisorJob
57
+ import kotlinx.coroutines.cancel
58
+ import kotlinx.coroutines.channels.BufferOverflow
59
+ import kotlinx.coroutines.flow.MutableSharedFlow
60
+ import kotlinx.coroutines.flow.SharedFlow
61
+ import kotlinx.coroutines.flow.asSharedFlow
62
+ import kotlinx.coroutines.launch
63
+ import kotlinx.coroutines.suspendCancellableCoroutine
64
+ import kotlinx.coroutines.withContext
41
65
  import java.io.ByteArrayOutputStream
42
66
  import java.io.File
43
67
  import java.io.FileOutputStream
44
68
  import java.util.concurrent.ExecutorService
45
69
  import java.util.concurrent.Executors
70
+ import java.util.concurrent.atomic.AtomicBoolean
71
+ import java.util.concurrent.atomic.AtomicLong
72
+ import java.util.concurrent.atomic.AtomicReference
73
+ import kotlin.coroutines.resume
46
74
 
47
75
  /** Throttle time for barcode detection in milliseconds. */
48
- const val BARCODE_DETECTION_THROTTLE_MS = 100
76
+ const val BARCODE_DETECTION_THROTTLE_MS = 100L
49
77
 
50
78
  class CameraView(plugin: Plugin) {
51
- // Camera components
52
- private var cameraController: LifecycleCameraController? = null
79
+ // Coroutine scope for async operations
80
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
81
+
82
+ // Thread-safe camera controller reference
83
+ private val cameraControllerRef = AtomicReference<LifecycleCameraController?>(null)
84
+
85
+ // Camera components (using atomic reference for thread safety)
86
+ private var cameraController: LifecycleCameraController?
87
+ get() = cameraControllerRef.get()
88
+ set(value) {
89
+ cameraControllerRef.set(value)
90
+ }
91
+
53
92
  private val cameraExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
54
93
  private var previewView: PreviewView? = null
55
94
 
@@ -57,6 +96,18 @@ class CameraView(plugin: Plugin) {
57
96
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
58
97
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
59
98
 
99
+ // Active video recording
100
+ private var activeRecording: Recording? = null
101
+
102
+ /**
103
+ * Holds the pending stop-recording continuation result handler.
104
+ * Needed because CameraX delivers the final recording outcome asynchronously via Finalize.
105
+ */
106
+ private var pendingStopCallback: ((CameraResult<JSObject>) -> Unit)? = null
107
+
108
+ // Track the output file for the current recording
109
+ private var currentRecordingFile: File? = null
110
+
60
111
  // Plugin context
61
112
  private var lifecycleOwner: LifecycleOwner? = null
62
113
  private var pluginDelegate: Plugin = plugin
@@ -65,56 +116,86 @@ class CameraView(plugin: Plugin) {
65
116
 
66
117
  private val mainHandler by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
67
118
 
68
- private var lastBarcodeDetectionTime = 0L
119
+ // Thread-safe barcode throttle timestamp
120
+ private val lastBarcodeDetectionTime = AtomicLong(0L)
121
+
122
+ // Flow for reactive barcode events
123
+ private val _barcodeEvents = MutableSharedFlow<BarcodeDetectionResult>(
124
+ replay = 0,
125
+ extraBufferCapacity = 1,
126
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
127
+ )
128
+ val barcodeEvents: SharedFlow<BarcodeDetectionResult> = _barcodeEvents.asSharedFlow()
69
129
 
70
130
  /** Starts a camera session with the provided configuration. */
71
- fun startSession(config: CameraSessionConfiguration, callback: (Exception?) -> Unit) {
72
- val lifecycleOwner =
73
- context as? LifecycleOwner
74
- ?: run {
75
- callback(CameraError.LifecycleOwnerMissing())
76
- return
77
- }
131
+ suspend fun startSessionAsync(config: CameraSessionConfiguration): CameraResult<Unit> =
132
+ withContext(Dispatchers.Main) {
133
+ val lifecycleOwner = context as? LifecycleOwner
134
+ ?: return@withContext CameraResult.Error(CameraError.LifecycleOwnerMissing())
78
135
 
79
- // Store references for later use
80
- this.lifecycleOwner = lifecycleOwner
136
+ this@CameraView.lifecycleOwner = lifecycleOwner
81
137
 
82
- mainHandler.post {
83
138
  try {
84
139
  initializeCamera(context, lifecycleOwner, config)
85
- callback(null)
140
+ CameraResult.Success(Unit)
86
141
  } catch (e: Exception) {
87
142
  Log.e(TAG, "Error in camera setup", e)
88
- callback(e)
143
+ CameraResult.Error(e)
89
144
  }
90
145
  }
146
+
147
+ /** Starts a camera session with the provided configuration (callback version for backward compatibility). */
148
+ fun startSession(config: CameraSessionConfiguration, callback: (Exception?) -> Unit) {
149
+ scope.launch {
150
+ startSessionAsync(config).fold(
151
+ onSuccess = { callback(null) },
152
+ onError = { callback(it) }
153
+ )
154
+ }
91
155
  }
92
156
 
93
- /** Stop the camera session and release resources */
94
- fun stopSession(callback: ((Exception?) -> Unit)? = null) {
95
- mainHandler.post {
157
+ /** Stop the camera session and release resources. */
158
+ suspend fun stopSessionAsync(): CameraResult<Unit> = withContext(Dispatchers.Main) {
159
+ try {
160
+ // Stop any active recording before unbinding
161
+ activeRecording?.stop()
162
+ activeRecording = null
163
+ pendingStopCallback?.invoke(
164
+ CameraResult.Error(Exception("Recording was interrupted because the camera session stopped"))
165
+ )
166
+ pendingStopCallback = null
167
+ currentRecordingFile = null
168
+
96
169
  cameraController?.unbind()
97
170
 
98
- try {
99
- previewView?.let { view ->
100
- try {
101
- (webView.parent as? ViewGroup)?.removeView(view)
102
- } catch (e: Exception) {
103
- Log.e(TAG, "Error removing preview view", e)
104
- } finally {
105
- previewView = null
106
- }
171
+ previewView?.let { view ->
172
+ try {
173
+ (webView.parent as? ViewGroup)?.removeView(view)
174
+ } catch (e: Exception) {
175
+ Log.e(TAG, "Error removing preview view", e)
176
+ } finally {
177
+ previewView = null
107
178
  }
179
+ }
108
180
 
109
- webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
110
- webView.setBackgroundColor(android.graphics.Color.WHITE)
181
+ webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
182
+ webView.setBackgroundColor(android.graphics.Color.WHITE)
111
183
 
112
- Log.d(TAG, "Camera session stopped successfully")
113
- callback?.invoke(null)
114
- } catch (e: Exception) {
115
- Log.e(TAG, "Error stopping camera session", e)
116
- callback?.invoke(e)
117
- }
184
+ Log.d(TAG, "Camera session stopped successfully")
185
+ CameraResult.Success(Unit)
186
+ } catch (e: Exception) {
187
+ Log.e(TAG, "Error stopping camera session", e)
188
+ CameraResult.Error(e)
189
+ }
190
+ }
191
+
192
+ /** Stop the camera session and release resources (callback version for backward compatibility). */
193
+ fun stopSession(callback: ((Exception?) -> Unit)? = null) {
194
+ scope.launch {
195
+ stopSessionAsync().fold(
196
+ onSuccess = { callback?.invoke(null) },
197
+ onError = { callback?.invoke(it) }
198
+ )
118
199
  }
119
200
  }
120
201
 
@@ -123,24 +204,24 @@ class CameraView(plugin: Plugin) {
123
204
  return cameraController != null
124
205
  }
125
206
 
126
- /** Capture a photo with the current camera configuration */
127
- fun capturePhoto(
207
+ /** Capture a photo with the current camera configuration. */
208
+ suspend fun capturePhotoAsync(
128
209
  quality: Int,
129
- saveToFile: Boolean = false,
130
- callback: (JSObject?, Exception?) -> Unit
131
- ) {
210
+ saveToFile: Boolean = false
211
+ ): CameraResult<JSObject> = suspendCancellableCoroutine { continuation ->
132
212
  val startTime = System.currentTimeMillis()
213
+
133
214
  val controller = cameraController
134
- ?: run {
135
- callback(null, CameraError.CameraNotInitialized())
136
- return
137
- }
215
+ if (controller == null) {
216
+ continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
217
+ return@suspendCancellableCoroutine
218
+ }
138
219
 
139
220
  val preview = previewView
140
- ?: run {
141
- callback(null, CameraError.PreviewNotInitialized())
142
- return
143
- }
221
+ if (preview == null) {
222
+ continuation.resume(CameraResult.Error(CameraError.PreviewNotInitialized()))
223
+ return@suspendCancellableCoroutine
224
+ }
144
225
 
145
226
  mainHandler.post {
146
227
  val cameraInfo = controller.cameraInfo
@@ -177,12 +258,12 @@ class CameraView(plugin: Plugin) {
177
258
 
178
259
  put("webPath", capacitorFilePath)
179
260
  }
180
- callback(result, null)
261
+ continuation.resume(CameraResult.Success(result))
181
262
  }
182
263
 
183
264
  override fun onError(exception: ImageCaptureException) {
184
265
  Log.e(TAG, "Error saving image to file", exception)
185
- callback(null, exception)
266
+ continuation.resume(CameraResult.Error(exception))
186
267
  }
187
268
  }
188
269
  )
@@ -196,109 +277,353 @@ class CameraView(plugin: Plugin) {
196
277
  TAG,
197
278
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
198
279
  )
199
- handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
280
+ try {
281
+ val base64String =
282
+ imageProxyToBase64(image, quality, imageRotationDegrees)
283
+ val result = JSObject().apply {
284
+ put("photo", base64String)
285
+ }
286
+ Log.d(
287
+ TAG,
288
+ "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
289
+ )
290
+ continuation.resume(CameraResult.Success(result))
291
+ } catch (e: Exception) {
292
+ Log.e(TAG, "Error processing captured image", e)
293
+ continuation.resume(CameraResult.Error(e))
294
+ } finally {
295
+ image.close()
296
+ }
200
297
  }
201
298
 
202
299
  override fun onError(exception: ImageCaptureException) {
203
300
  Log.e(TAG, "Error capturing image", exception)
204
- callback(null, exception)
301
+ continuation.resume(CameraResult.Error(exception))
205
302
  }
206
303
  }
207
304
  )
208
305
  }
209
306
  } catch (e: Exception) {
210
307
  Log.e(TAG, "Error setting up image capture", e)
211
- callback(null, e)
308
+ continuation.resume(CameraResult.Error(e))
212
309
  }
213
310
  }
214
311
  }
215
312
 
216
- /**
217
- * Handles the successful capture of an image for base64 conversion
218
- */
219
- fun handleCaptureSuccess(
220
- image: ImageProxy,
313
+ /** Capture a photo with the current camera configuration (callback version for backward compatibility). */
314
+ fun capturePhoto(
221
315
  quality: Int,
222
- rotationDegrees: Int,
316
+ saveToFile: Boolean = false,
223
317
  callback: (JSObject?, Exception?) -> Unit
224
318
  ) {
225
- val startTime = System.currentTimeMillis()
319
+ scope.launch {
320
+ capturePhotoAsync(quality, saveToFile).fold(
321
+ onSuccess = { callback(it, null) },
322
+ onError = { callback(null, it) }
323
+ )
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Capture a frame directly from the preview without using the full photo pipeline.
329
+ * Faster but has lower quality than full photo capture.
330
+ */
331
+ suspend fun captureSampleFromPreviewAsync(
332
+ quality: Int,
333
+ saveToFile: Boolean = false
334
+ ): CameraResult<JSObject> = withContext(Dispatchers.Main) {
335
+ val preview = previewView
336
+ ?: return@withContext CameraResult.Error(CameraError.PreviewNotInitialized())
337
+
226
338
  try {
227
- val base64String = imageProxyToBase64(image, quality, rotationDegrees)
228
- val result = JSObject().apply {
229
- put("photo", base64String)
339
+ val bitmap = preview.bitmap
340
+ ?: return@withContext CameraResult.Error(Exception("Preview bitmap not available"))
341
+
342
+ val result = JSObject()
343
+
344
+ if (saveToFile) {
345
+ val tempFile =
346
+ File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
347
+
348
+ FileOutputStream(tempFile).use { outputStream ->
349
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
350
+ }
351
+
352
+ val capacitorFilePath = FileUtils.getPortablePath(
353
+ context,
354
+ pluginDelegate.bridge.localUrl,
355
+ Uri.fromFile(tempFile)
356
+ )
357
+
358
+ result.put("webPath", capacitorFilePath)
359
+ } else {
360
+ // Convert bitmap to Base64 using pooled stream
361
+ val base64String = bitmapToBase64(bitmap, quality)
362
+ result.put("photo", base64String)
230
363
  }
231
- Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
232
- callback(result, null)
364
+
365
+ CameraResult.Success(result)
233
366
  } catch (e: Exception) {
234
- Log.e(TAG, "Error processing captured image", e)
235
- callback(null, e)
236
- } finally {
237
- image.close()
367
+ Log.e(TAG, "Error capturing preview frame", e)
368
+ CameraResult.Error(e)
238
369
  }
239
370
  }
240
371
 
241
372
  /**
242
- * Capture a frame directly from the preview without using the full photo pipeline which is
243
- * faster but has lower quality.
373
+ * Capture a frame directly from the preview without using the full photo pipeline (callback version).
374
+ * Faster but has lower quality than full photo capture.
244
375
  */
245
376
  fun captureSampleFromPreview(
246
377
  quality: Int,
247
378
  saveToFile: Boolean = false,
248
379
  callback: (JSObject?, Exception?) -> Unit
249
380
  ) {
250
- val previewView =
251
- this.previewView
252
- ?: run {
253
- callback(null, CameraError.PreviewNotInitialized())
254
- return
255
- }
381
+ scope.launch {
382
+ captureSampleFromPreviewAsync(quality, saveToFile).fold(
383
+ onSuccess = { callback(it, null) },
384
+ onError = { callback(null, it) }
385
+ )
386
+ }
387
+ }
256
388
 
389
+ /**
390
+ * Starts video recording to a temporary file.
391
+ */
392
+ suspend fun startRecordingAsync(
393
+ enableAudio: Boolean,
394
+ videoQuality: VideoRecordingQuality,
395
+ ): CameraResult<Unit> = suspendCancellableCoroutine { continuation ->
257
396
  mainHandler.post {
258
- try {
259
- val bitmap =
260
- previewView.bitmap
261
- ?: run {
262
- callback(null, Exception("Preview bitmap not available"))
263
- return@post
264
- }
397
+ startRecordingOnMainThread(enableAudio, videoQuality, continuation)
398
+ }
399
+ }
265
400
 
266
- val result = JSObject()
401
+ private fun startRecordingOnMainThread(
402
+ enableAudio: Boolean,
403
+ videoQuality: VideoRecordingQuality,
404
+ continuation: CancellableContinuation<CameraResult<Unit>>
405
+ ) {
406
+ val controller = validateRecordingPreconditions(continuation) ?: return
267
407
 
268
- if (saveToFile) {
269
- val tempFile =
270
- File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
408
+ try {
409
+ controller.videoCaptureQualitySelector = videoQuality.toQualitySelector()
271
410
 
272
- FileOutputStream(tempFile).use { outputStream ->
273
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
274
- }
411
+ // Enable VIDEO_CAPTURE use case alongside IMAGE_CAPTURE
412
+ controller.setEnabledUseCases(
413
+ CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE
414
+ )
275
415
 
276
- val capacitorFilePath = FileUtils.getPortablePath(
277
- context,
278
- pluginDelegate.bridge.localUrl,
279
- Uri.fromFile(tempFile)
280
- )
416
+ val outputOptions = createRecordingOutputOptions()
417
+ val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
281
418
 
282
- result.put("webPath", capacitorFilePath)
283
- } else {
284
- // Convert bitmap to Base64
285
- val outputStream = ByteArrayOutputStream()
286
- outputStream.use { stream ->
287
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
288
- val byteArray = stream.toByteArray()
289
- val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
290
- result.put("photo", base64String)
291
- }
419
+ startCameraRecording(controller, outputOptions, audioConfig, continuation)
420
+ } catch (e: SecurityException) {
421
+ Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
422
+ // Restore normal use cases on permission error
423
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
424
+ continuation.resume(CameraResult.Error(e))
425
+ } catch (e: Exception) {
426
+ Log.e(TAG, "Error starting recording", e)
427
+ // Restore normal use cases on error
428
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
429
+ continuation.resume(CameraResult.Error(e))
430
+ }
431
+ }
432
+
433
+ private fun validateRecordingPreconditions(
434
+ continuation: CancellableContinuation<CameraResult<Unit>>
435
+ ): LifecycleCameraController? {
436
+ val controller = cameraController
437
+ if (controller == null) {
438
+ continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
439
+ return null
440
+ }
441
+
442
+ if (activeRecording != null) {
443
+ continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
444
+ return null
445
+ }
446
+
447
+ return controller
448
+ }
449
+
450
+ private fun createRecordingOutputOptions(): FileOutputOptions {
451
+ val tempFile = File.createTempFile(
452
+ "camera_recording_",
453
+ ".mp4",
454
+ context.cacheDir
455
+ )
456
+ currentRecordingFile = tempFile
457
+ return FileOutputOptions.Builder(tempFile).build()
458
+ }
459
+
460
+ private fun resolveAudioConfig(
461
+ enableAudio: Boolean,
462
+ continuation: CancellableContinuation<CameraResult<Unit>>
463
+ ): AudioConfig? {
464
+ if (!enableAudio) {
465
+ return AudioConfig.AUDIO_DISABLED
466
+ }
467
+
468
+ if (hasMicrophonePermission()) {
469
+ return try {
470
+ AudioConfig.create(true)
471
+ } catch (e: SecurityException) {
472
+ continuation.resume(CameraResult.Error(e))
473
+ null
474
+ }
475
+ }
476
+
477
+ continuation.resume(
478
+ CameraResult.Error(
479
+ SecurityException("Microphone permission is required for audio recording")
480
+ )
481
+ )
482
+ return null
483
+ }
484
+
485
+ private fun hasMicrophonePermission(): Boolean {
486
+ return ContextCompat.checkSelfPermission(
487
+ context,
488
+ Manifest.permission.RECORD_AUDIO
489
+ ) == PackageManager.PERMISSION_GRANTED
490
+ }
491
+
492
+ private fun startCameraRecording(
493
+ controller: LifecycleCameraController,
494
+ outputOptions: FileOutputOptions,
495
+ audioConfig: AudioConfig,
496
+ continuation: CancellableContinuation<CameraResult<Unit>>
497
+ ) {
498
+ val startResumed = AtomicBoolean(false)
499
+ activeRecording = controller.startRecording(
500
+ outputOptions,
501
+ audioConfig,
502
+ cameraExecutor
503
+ ) { event ->
504
+ when (event) {
505
+ is VideoRecordEvent.Start -> handleRecordingStartEvent(startResumed, continuation)
506
+ is VideoRecordEvent.Finalize -> {
507
+ handleRecordingFinalizeEvent(event, startResumed, continuation)
292
508
  }
293
509
 
294
- callback(result, null)
295
- } catch (e: Exception) {
296
- Log.e(TAG, "Error capturing preview frame", e)
297
- callback(null, e)
510
+ else -> Unit
511
+ }
512
+ }
513
+ }
514
+
515
+ private fun handleRecordingStartEvent(
516
+ startResumed: AtomicBoolean,
517
+ continuation: CancellableContinuation<CameraResult<Unit>>
518
+ ) {
519
+ Log.d(TAG, "Video recording started")
520
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
521
+ continuation.resume(CameraResult.Success(Unit))
522
+ }
523
+ }
524
+
525
+ private fun handleRecordingFinalizeEvent(
526
+ event: VideoRecordEvent.Finalize,
527
+ startResumed: AtomicBoolean,
528
+ continuation: CancellableContinuation<CameraResult<Unit>>
529
+ ) {
530
+ // If recording finalized before Start was emitted, resume the
531
+ // startRecording continuation with an error
532
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
533
+ continuation.resume(
534
+ CameraResult.Error(
535
+ Exception("Recording failed to start: error code ${event.error}")
536
+ )
537
+ )
538
+ }
539
+
540
+ finalizeRecordingAndNotifyStopCallback(event)
541
+ }
542
+
543
+ private fun finalizeRecordingAndNotifyStopCallback(event: VideoRecordEvent.Finalize) {
544
+ mainHandler.post {
545
+ // CameraX requires use case changes on the main thread.
546
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
547
+
548
+ val callback = pendingStopCallback
549
+ pendingStopCallback = null
550
+ // Always clean up recording state
551
+ activeRecording = null
552
+
553
+ if (event.hasError()) {
554
+ Log.e(TAG, "Recording error: ${event.error}")
555
+ currentRecordingFile = null
556
+ callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
557
+ return@post
558
+ }
559
+
560
+ val file = currentRecordingFile
561
+ currentRecordingFile = null
562
+ if (file == null) {
563
+ callback?.invoke(CameraResult.Error(Exception("Recording file not found")))
564
+ return@post
298
565
  }
566
+
567
+ val capacitorFilePath = FileUtils.getPortablePath(
568
+ context,
569
+ pluginDelegate.bridge.localUrl,
570
+ Uri.fromFile(file)
571
+ )
572
+ val result = JSObject().apply {
573
+ put("webPath", capacitorFilePath)
574
+ }
575
+ callback?.invoke(CameraResult.Success(result))
576
+ }
577
+ }
578
+
579
+ private fun VideoRecordingQuality.toQualitySelector(): QualitySelector {
580
+ return when (this) {
581
+ VideoRecordingQuality.LOWEST -> QualitySelector.from(Quality.LOWEST)
582
+ VideoRecordingQuality.SD -> QualitySelector.from(
583
+ Quality.SD,
584
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
585
+ )
586
+
587
+ VideoRecordingQuality.HD -> QualitySelector.from(
588
+ Quality.HD,
589
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
590
+ )
591
+
592
+ VideoRecordingQuality.FHD -> QualitySelector.from(
593
+ Quality.FHD,
594
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
595
+ )
596
+
597
+ VideoRecordingQuality.UHD -> QualitySelector.from(
598
+ Quality.UHD,
599
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
600
+ )
601
+
602
+ VideoRecordingQuality.HIGHEST -> QualitySelector.from(Quality.HIGHEST)
299
603
  }
300
604
  }
301
605
 
606
+ /**
607
+ * Stops the current video recording and returns the file path.
608
+ */
609
+ suspend fun stopRecordingAsync(): CameraResult<JSObject> =
610
+ suspendCancellableCoroutine { continuation ->
611
+ mainHandler.post {
612
+ val recording = activeRecording
613
+ if (recording == null) {
614
+ continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
615
+ return@post
616
+ }
617
+
618
+ pendingStopCallback = { result ->
619
+ continuation.resume(result)
620
+ }
621
+
622
+ activeRecording = null
623
+ recording.stop()
624
+ }
625
+ }
626
+
302
627
  /** Flip between front and back cameras */
303
628
  fun flipCamera(callback: (Exception?) -> Unit) {
304
629
  currentCameraSelector = when (currentCameraSelector) {
@@ -481,8 +806,17 @@ class CameraView(plugin: Plugin) {
481
806
 
482
807
  /** Clean up resources when the plugin is being destroyed */
483
808
  fun cleanup() {
809
+ // Cancel all coroutines first
810
+ scope.cancel()
811
+
484
812
  mainHandler.post {
485
813
  try {
814
+ // Stop any active recording before cleanup
815
+ activeRecording?.stop()
816
+ activeRecording = null
817
+ pendingStopCallback = null
818
+ currentRecordingFile = null
819
+
486
820
  // Stop camera session
487
821
  cameraController?.unbind()
488
822
  cameraController = null
@@ -512,6 +846,14 @@ class CameraView(plugin: Plugin) {
512
846
  }
513
847
  }
514
848
 
849
+ /** Converts a bitmap to Base64 with memory-efficient pooled ByteArrayOutputStream. */
850
+ private fun bitmapToBase64(bitmap: Bitmap, quality: Int): String {
851
+ val outputStream = ByteArrayOutputStream(256 * 1024) // 256KB initial capacity
852
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
853
+ val byteArray = outputStream.toByteArray()
854
+ return Base64.encodeToString(byteArray, Base64.NO_WRAP)
855
+ }
856
+
515
857
  private fun setupPreviewView(context: Context) {
516
858
  // Make WebView transparent
517
859
  webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
@@ -575,7 +917,7 @@ class CameraView(plugin: Plugin) {
575
917
 
576
918
  // Setup barcode scanning if needed
577
919
  if (config.enableBarcodeDetection) {
578
- setupBarcodeScanner(controller)
920
+ setupBarcodeScanner(controller, config.barcodeTypes)
579
921
  }
580
922
 
581
923
  // Bind to lifecycle
@@ -585,13 +927,33 @@ class CameraView(plugin: Plugin) {
585
927
  this.setZoomFactor(config.zoomFactor, null)
586
928
  }
587
929
 
588
- private fun setupBarcodeScanner(controller: LifecycleCameraController) {
930
+ /**
931
+ * Sets up the barcode scanner with the specified formats.
932
+ *
933
+ * @param controller The camera controller to attach the scanner to.
934
+ * @param barcodeTypes Optional list of specific barcode format codes to detect.
935
+ * If null, all supported formats are detected (backwards compatible).
936
+ */
937
+ private fun setupBarcodeScanner(
938
+ controller: LifecycleCameraController,
939
+ barcodeTypes: List<Int>? = null
940
+ ) {
589
941
  val previewView = this.previewView ?: return
590
942
 
591
- val options =
943
+ // Build scanner options with specified formats or all formats
944
+ val options = if (barcodeTypes != null && barcodeTypes.isNotEmpty()) {
945
+ // Use specific formats - setBarcodeFormats takes first format + vararg rest
946
+ val firstFormat = barcodeTypes.first()
947
+ val restFormats = barcodeTypes.drop(1).toIntArray()
948
+ BarcodeScannerOptions.Builder()
949
+ .setBarcodeFormats(firstFormat, *restFormats)
950
+ .build()
951
+ } else {
952
+ // Default to all formats for backwards compatibility
592
953
  BarcodeScannerOptions.Builder()
593
954
  .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
594
955
  .build()
956
+ }
595
957
 
596
958
  val barcodeScanner = BarcodeScanning.getClient(options)
597
959
  val mainExecutor = ContextCompat.getMainExecutor(previewView.context)
@@ -621,10 +983,18 @@ class CameraView(plugin: Plugin) {
621
983
  topOffset: Int
622
984
  ) {
623
985
  val now = System.currentTimeMillis()
624
- if (now - lastBarcodeDetectionTime < BARCODE_DETECTION_THROTTLE_MS) {
986
+ val lastTime = lastBarcodeDetectionTime.get()
987
+
988
+ // Thread-safe throttle check using atomic compare-and-set
989
+ if (now - lastTime < BARCODE_DETECTION_THROTTLE_MS) {
625
990
  return // Skip this frame
626
991
  }
627
992
 
993
+ // Atomically update the timestamp - if another thread beat us, skip
994
+ if (!lastBarcodeDetectionTime.compareAndSet(lastTime, now)) {
995
+ return
996
+ }
997
+
628
998
  val barcodes = result?.getValue(barcodeScanner) ?: return
629
999
  if (barcodes.isEmpty()) return
630
1000
 
@@ -642,8 +1012,13 @@ class CameraView(plugin: Plugin) {
642
1012
  boundingRect = webBoundingRect
643
1013
  )
644
1014
 
1015
+ // Emit to Flow for reactive subscribers
1016
+ scope.launch {
1017
+ _barcodeEvents.emit(barcodeResult)
1018
+ }
1019
+
1020
+ // Also notify via callback for backward compatibility
645
1021
  notifyBarcodeDetected(barcodeResult)
646
- lastBarcodeDetectionTime = now
647
1022
  }
648
1023
 
649
1024
  private fun notifyBarcodeDetected(result: BarcodeDetectionResult) {