capacitor-camera-view 2.2.0 → 2.3.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.
@@ -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
@@ -38,7 +48,9 @@ import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
38
48
  import com.michaelwolz.capacitorcameraview.model.CameraDevice
39
49
  import com.michaelwolz.capacitorcameraview.model.CameraResult
40
50
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
51
+ import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
41
52
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
53
+ import kotlinx.coroutines.CancellableContinuation
42
54
  import kotlinx.coroutines.CoroutineScope
43
55
  import kotlinx.coroutines.Dispatchers
44
56
  import kotlinx.coroutines.SupervisorJob
@@ -55,6 +67,7 @@ import java.io.File
55
67
  import java.io.FileOutputStream
56
68
  import java.util.concurrent.ExecutorService
57
69
  import java.util.concurrent.Executors
70
+ import java.util.concurrent.atomic.AtomicBoolean
58
71
  import java.util.concurrent.atomic.AtomicLong
59
72
  import java.util.concurrent.atomic.AtomicReference
60
73
  import kotlin.coroutines.resume
@@ -72,7 +85,9 @@ class CameraView(plugin: Plugin) {
72
85
  // Camera components (using atomic reference for thread safety)
73
86
  private var cameraController: LifecycleCameraController?
74
87
  get() = cameraControllerRef.get()
75
- set(value) { cameraControllerRef.set(value) }
88
+ set(value) {
89
+ cameraControllerRef.set(value)
90
+ }
76
91
 
77
92
  private val cameraExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
78
93
  private var previewView: PreviewView? = null
@@ -81,6 +96,21 @@ class CameraView(plugin: Plugin) {
81
96
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
82
97
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
83
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
+
111
+ // Enabled CameraX use cases before video recording temporarily changes them.
112
+ private var enabledUseCasesBeforeRecording: Int? = null
113
+
84
114
  // Plugin context
85
115
  private var lifecycleOwner: LifecycleOwner? = null
86
116
  private var pluginDelegate: Plugin = plugin
@@ -130,7 +160,17 @@ class CameraView(plugin: Plugin) {
130
160
  /** Stop the camera session and release resources. */
131
161
  suspend fun stopSessionAsync(): CameraResult<Unit> = withContext(Dispatchers.Main) {
132
162
  try {
163
+ // Stop any active recording before unbinding
164
+ activeRecording?.stop()
165
+ activeRecording = null
166
+ pendingStopCallback?.invoke(
167
+ CameraResult.Error(Exception("Recording was interrupted because the camera session stopped"))
168
+ )
169
+ pendingStopCallback = null
170
+ currentRecordingFile = null
171
+
133
172
  cameraController?.unbind()
173
+ cameraController = null
134
174
 
135
175
  previewView?.let { view ->
136
176
  try {
@@ -242,11 +282,15 @@ class CameraView(plugin: Plugin) {
242
282
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
243
283
  )
244
284
  try {
245
- val base64String = imageProxyToBase64(image, quality, imageRotationDegrees)
285
+ val base64String =
286
+ imageProxyToBase64(image, quality, imageRotationDegrees)
246
287
  val result = JSObject().apply {
247
288
  put("photo", base64String)
248
289
  }
249
- Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
290
+ Log.d(
291
+ TAG,
292
+ "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
293
+ )
250
294
  continuation.resume(CameraResult.Success(result))
251
295
  } catch (e: Exception) {
252
296
  Log.e(TAG, "Error processing captured image", e)
@@ -346,6 +390,263 @@ class CameraView(plugin: Plugin) {
346
390
  }
347
391
  }
348
392
 
393
+ /**
394
+ * Starts video recording to a temporary file.
395
+ */
396
+ suspend fun startRecordingAsync(
397
+ enableAudio: Boolean,
398
+ videoQuality: VideoRecordingQuality,
399
+ ): CameraResult<Unit> = suspendCancellableCoroutine { continuation ->
400
+ mainHandler.post {
401
+ startRecordingOnMainThread(enableAudio, videoQuality, continuation)
402
+ }
403
+ }
404
+
405
+ private fun startRecordingOnMainThread(
406
+ enableAudio: Boolean,
407
+ videoQuality: VideoRecordingQuality,
408
+ continuation: CancellableContinuation<CameraResult<Unit>>
409
+ ) {
410
+ val controller = validateRecordingPreconditions(continuation) ?: return
411
+
412
+ try {
413
+ controller.videoCaptureQualitySelector = videoQuality.toQualitySelector()
414
+
415
+ // Enable VIDEO_CAPTURE use case alongside IMAGE_CAPTURE
416
+ enabledUseCasesBeforeRecording = controller.currentEnabledUseCases()
417
+ controller.setEnabledUseCases(
418
+ CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE
419
+ )
420
+
421
+ val outputOptions = createRecordingOutputOptions()
422
+ val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
423
+
424
+ startCameraRecording(controller, outputOptions, audioConfig, continuation)
425
+ } catch (e: SecurityException) {
426
+ Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
427
+ restoreUseCasesAfterRecording()
428
+ continuation.resume(CameraResult.Error(e))
429
+ } catch (e: Exception) {
430
+ Log.e(TAG, "Error starting recording", e)
431
+ restoreUseCasesAfterRecording()
432
+ continuation.resume(CameraResult.Error(e))
433
+ }
434
+ }
435
+
436
+ private fun validateRecordingPreconditions(
437
+ continuation: CancellableContinuation<CameraResult<Unit>>
438
+ ): LifecycleCameraController? {
439
+ val controller = cameraController
440
+ if (controller == null) {
441
+ continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
442
+ return null
443
+ }
444
+
445
+ if (activeRecording != null) {
446
+ continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
447
+ return null
448
+ }
449
+
450
+ return controller
451
+ }
452
+
453
+ private fun createRecordingOutputOptions(): FileOutputOptions {
454
+ val tempFile = File.createTempFile(
455
+ "camera_recording_",
456
+ ".mp4",
457
+ context.cacheDir
458
+ )
459
+ currentRecordingFile = tempFile
460
+ return FileOutputOptions.Builder(tempFile).build()
461
+ }
462
+
463
+ private fun resolveAudioConfig(
464
+ enableAudio: Boolean,
465
+ continuation: CancellableContinuation<CameraResult<Unit>>
466
+ ): AudioConfig? {
467
+ if (!enableAudio) {
468
+ return AudioConfig.AUDIO_DISABLED
469
+ }
470
+
471
+ if (hasMicrophonePermission()) {
472
+ return try {
473
+ AudioConfig.create(true)
474
+ } catch (e: SecurityException) {
475
+ continuation.resume(CameraResult.Error(e))
476
+ null
477
+ }
478
+ }
479
+
480
+ continuation.resume(
481
+ CameraResult.Error(
482
+ SecurityException("Microphone permission is required for audio recording")
483
+ )
484
+ )
485
+ return null
486
+ }
487
+
488
+ private fun hasMicrophonePermission(): Boolean {
489
+ return ContextCompat.checkSelfPermission(
490
+ context,
491
+ Manifest.permission.RECORD_AUDIO
492
+ ) == PackageManager.PERMISSION_GRANTED
493
+ }
494
+
495
+ private fun startCameraRecording(
496
+ controller: LifecycleCameraController,
497
+ outputOptions: FileOutputOptions,
498
+ audioConfig: AudioConfig,
499
+ continuation: CancellableContinuation<CameraResult<Unit>>
500
+ ) {
501
+ val startResumed = AtomicBoolean(false)
502
+ activeRecording = controller.startRecording(
503
+ outputOptions,
504
+ audioConfig,
505
+ cameraExecutor
506
+ ) { event ->
507
+ when (event) {
508
+ is VideoRecordEvent.Start -> handleRecordingStartEvent(startResumed, continuation)
509
+ is VideoRecordEvent.Finalize -> {
510
+ handleRecordingFinalizeEvent(event, startResumed, continuation)
511
+ }
512
+
513
+ else -> Unit
514
+ }
515
+ }
516
+ }
517
+
518
+ private fun handleRecordingStartEvent(
519
+ startResumed: AtomicBoolean,
520
+ continuation: CancellableContinuation<CameraResult<Unit>>
521
+ ) {
522
+ Log.d(TAG, "Video recording started")
523
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
524
+ continuation.resume(CameraResult.Success(Unit))
525
+ }
526
+ }
527
+
528
+ private fun handleRecordingFinalizeEvent(
529
+ event: VideoRecordEvent.Finalize,
530
+ startResumed: AtomicBoolean,
531
+ continuation: CancellableContinuation<CameraResult<Unit>>
532
+ ) {
533
+ // If recording finalized before Start was emitted, resume the
534
+ // startRecording continuation with an error
535
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
536
+ continuation.resume(
537
+ CameraResult.Error(
538
+ Exception("Recording failed to start: error code ${event.error}")
539
+ )
540
+ )
541
+ }
542
+
543
+ finalizeRecordingAndNotifyStopCallback(event)
544
+ }
545
+
546
+ private fun finalizeRecordingAndNotifyStopCallback(event: VideoRecordEvent.Finalize) {
547
+ mainHandler.post {
548
+ // CameraX requires use case changes on the main thread.
549
+ restoreUseCasesAfterRecording()
550
+
551
+ val callback = pendingStopCallback
552
+ pendingStopCallback = null
553
+ // Always clean up recording state
554
+ activeRecording = null
555
+
556
+ if (event.hasError()) {
557
+ Log.e(TAG, "Recording error: ${event.error}")
558
+ currentRecordingFile = null
559
+ callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
560
+ return@post
561
+ }
562
+
563
+ val file = currentRecordingFile
564
+ currentRecordingFile = null
565
+ if (file == null) {
566
+ callback?.invoke(CameraResult.Error(Exception("Recording file not found")))
567
+ return@post
568
+ }
569
+
570
+ val capacitorFilePath = FileUtils.getPortablePath(
571
+ context,
572
+ pluginDelegate.bridge.localUrl,
573
+ Uri.fromFile(file)
574
+ )
575
+ val result = JSObject().apply {
576
+ put("webPath", capacitorFilePath)
577
+ }
578
+ callback?.invoke(CameraResult.Success(result))
579
+ }
580
+ }
581
+
582
+ private fun LifecycleCameraController.currentEnabledUseCases(): Int {
583
+ var enabledUseCases = 0
584
+ if (isImageCaptureEnabled) {
585
+ enabledUseCases = enabledUseCases or CameraController.IMAGE_CAPTURE
586
+ }
587
+ if (isImageAnalysisEnabled) {
588
+ enabledUseCases = enabledUseCases or CameraController.IMAGE_ANALYSIS
589
+ }
590
+ if (isVideoCaptureEnabled) {
591
+ enabledUseCases = enabledUseCases or CameraController.VIDEO_CAPTURE
592
+ }
593
+ return enabledUseCases
594
+ }
595
+
596
+ private fun restoreUseCasesAfterRecording() {
597
+ val useCases = enabledUseCasesBeforeRecording ?: CameraController.IMAGE_CAPTURE
598
+ enabledUseCasesBeforeRecording = null
599
+ cameraController?.setEnabledUseCases(useCases)
600
+ }
601
+
602
+ private fun VideoRecordingQuality.toQualitySelector(): QualitySelector {
603
+ return when (this) {
604
+ VideoRecordingQuality.LOWEST -> QualitySelector.from(Quality.LOWEST)
605
+ VideoRecordingQuality.SD -> QualitySelector.from(
606
+ Quality.SD,
607
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
608
+ )
609
+
610
+ VideoRecordingQuality.HD -> QualitySelector.from(
611
+ Quality.HD,
612
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
613
+ )
614
+
615
+ VideoRecordingQuality.FHD -> QualitySelector.from(
616
+ Quality.FHD,
617
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
618
+ )
619
+
620
+ VideoRecordingQuality.UHD -> QualitySelector.from(
621
+ Quality.UHD,
622
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
623
+ )
624
+
625
+ VideoRecordingQuality.HIGHEST -> QualitySelector.from(Quality.HIGHEST)
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Stops the current video recording and returns the file path.
631
+ */
632
+ suspend fun stopRecordingAsync(): CameraResult<JSObject> =
633
+ suspendCancellableCoroutine { continuation ->
634
+ mainHandler.post {
635
+ val recording = activeRecording
636
+ if (recording == null) {
637
+ continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
638
+ return@post
639
+ }
640
+
641
+ pendingStopCallback = { result ->
642
+ continuation.resume(result)
643
+ }
644
+
645
+ activeRecording = null
646
+ recording.stop()
647
+ }
648
+ }
649
+
349
650
  /** Flip between front and back cameras */
350
651
  fun flipCamera(callback: (Exception?) -> Unit) {
351
652
  currentCameraSelector = when (currentCameraSelector) {
@@ -533,6 +834,12 @@ class CameraView(plugin: Plugin) {
533
834
 
534
835
  mainHandler.post {
535
836
  try {
837
+ // Stop any active recording before cleanup
838
+ activeRecording?.stop()
839
+ activeRecording = null
840
+ pendingStopCallback = null
841
+ currentRecordingFile = null
842
+
536
843
  // Stop camera session
537
844
  cameraController?.unbind()
538
845
  cameraController = null
@@ -12,6 +12,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
12
12
  import com.getcapacitor.annotation.Permission
13
13
  import com.getcapacitor.annotation.PermissionCallback
14
14
  import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
15
+ import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
15
16
  import kotlinx.coroutines.CoroutineScope
16
17
  import kotlinx.coroutines.Dispatchers
17
18
  import kotlinx.coroutines.Job
@@ -21,7 +22,10 @@ import kotlinx.coroutines.launch
21
22
 
22
23
  @CapacitorPlugin(
23
24
  name = "CameraView",
24
- permissions = [Permission(strings = [Manifest.permission.CAMERA], alias = "camera")]
25
+ permissions = [
26
+ Permission(strings = [Manifest.permission.CAMERA], alias = "camera"),
27
+ Permission(strings = [Manifest.permission.RECORD_AUDIO], alias = "microphone")
28
+ ]
25
29
  )
26
30
  class CameraViewPlugin : Plugin() {
27
31
  // Coroutine scope for async operations
@@ -144,7 +148,113 @@ class CameraViewPlugin : Plugin() {
144
148
  },
145
149
  onError = { error ->
146
150
  call.reject("Failed to capture frame: ${error.message}", error)
147
- Log.d(TAG, "captureSample failed after ${System.currentTimeMillis() - timeStart}ms")
151
+ Log.d(
152
+ TAG,
153
+ "captureSample failed after ${System.currentTimeMillis() - timeStart}ms"
154
+ )
155
+ }
156
+ )
157
+ }
158
+ }
159
+
160
+ @PluginMethod
161
+ override fun requestPermissions(call: PluginCall) {
162
+ val permissionsList = call.getArray("permissions")
163
+ ?.toList<String>()
164
+ ?: listOf("camera")
165
+
166
+ // Determine which aliases still need to be requested
167
+ val aliasesToRequest = permissionsList.filter { alias ->
168
+ getPermissionState(alias) != PermissionState.GRANTED
169
+ }
170
+
171
+ if (aliasesToRequest.isEmpty()) {
172
+ checkPermissions(call)
173
+ return
174
+ }
175
+
176
+ // Store which permissions to request so callback can continue the chain
177
+ call.data.put("_pendingAliases", com.getcapacitor.JSArray(aliasesToRequest))
178
+ requestPermissionForAlias(aliasesToRequest.first(), call, "requestedPermsCallback")
179
+ }
180
+
181
+ @PermissionCallback
182
+ private fun requestedPermsCallback(call: PluginCall) {
183
+ val pendingAliases = call.getArray("_pendingAliases")?.toList<String>() ?: emptyList()
184
+
185
+ // Find remaining aliases that still need requesting
186
+ val remaining = pendingAliases.drop(1).filter { alias ->
187
+ getPermissionState(alias) != PermissionState.GRANTED
188
+ }
189
+
190
+ if (remaining.isNotEmpty()) {
191
+ call.data.put("_pendingAliases", com.getcapacitor.JSArray(remaining))
192
+ requestPermissionForAlias(remaining.first(), call, "requestedPermsCallback")
193
+ } else {
194
+ checkPermissions(call)
195
+ }
196
+ }
197
+
198
+ @PluginMethod
199
+ fun startRecording(call: PluginCall) {
200
+ val enableAudio = call.getBoolean("enableAudio") ?: false
201
+ val videoQuality =
202
+ parseVideoRecordingQuality(call.getString("videoQuality"))
203
+ ?: run {
204
+ call.reject("Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest")
205
+ return
206
+ }
207
+
208
+ if (enableAudio && getPermissionState("microphone") != PermissionState.GRANTED) {
209
+ requestPermissionForAlias("microphone", call, "microphonePermsCallback")
210
+ return
211
+ }
212
+
213
+ doStartRecording(call, enableAudio, videoQuality)
214
+ }
215
+
216
+ @PermissionCallback
217
+ private fun microphonePermsCallback(call: PluginCall) {
218
+ if (getPermissionState("microphone") == PermissionState.GRANTED) {
219
+ val enableAudio = call.getBoolean("enableAudio") ?: false
220
+ val videoQuality =
221
+ parseVideoRecordingQuality(call.getString("videoQuality"))
222
+ ?: run {
223
+ call.reject("Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest")
224
+ return
225
+ }
226
+ doStartRecording(call, enableAudio, videoQuality)
227
+ } else {
228
+ call.reject("Microphone permission is required for audio recording")
229
+ }
230
+ }
231
+
232
+
233
+ /**
234
+ * Helper method to start recording after ensuring permissions are granted.
235
+ */
236
+ private fun doStartRecording(
237
+ call: PluginCall,
238
+ enableAudio: Boolean,
239
+ videoQuality: VideoRecordingQuality
240
+ ) {
241
+ pluginScope.launch {
242
+ implementation.startRecordingAsync(enableAudio, videoQuality).fold(
243
+ onSuccess = { call.resolve() },
244
+ onError = { error ->
245
+ call.reject("Failed to start recording: ${error.message}", error)
246
+ }
247
+ )
248
+ }
249
+ }
250
+
251
+ @PluginMethod
252
+ fun stopRecording(call: PluginCall) {
253
+ pluginScope.launch {
254
+ implementation.stopRecordingAsync().fold(
255
+ onSuccess = { result -> call.resolve(result) },
256
+ onError = { error ->
257
+ call.reject("Failed to stop recording: ${error.message}", error)
148
258
  }
149
259
  )
150
260
  }
@@ -0,0 +1,10 @@
1
+ package com.michaelwolz.capacitorcameraview.model
2
+
3
+ enum class VideoRecordingQuality {
4
+ LOWEST,
5
+ SD,
6
+ HD,
7
+ FHD,
8
+ UHD,
9
+ HIGHEST,
10
+ }
@@ -13,6 +13,7 @@ import androidx.camera.view.PreviewView
13
13
  import com.getcapacitor.PluginCall
14
14
  import com.google.mlkit.vision.barcode.common.Barcode
15
15
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
16
+ import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
16
17
  import com.michaelwolz.capacitorcameraview.model.WebBoundingRect
17
18
  import java.io.ByteArrayOutputStream
18
19
 
@@ -171,7 +172,7 @@ fun sessionConfigFromPluginCall(call: PluginCall): CameraSessionConfiguration {
171
172
  jsonArray.optString(i)?.let { stringTypes.add(it) }
172
173
  }
173
174
  val converted = convertToNativeBarcodeFormats(stringTypes)
174
- if (converted.isNotEmpty()) converted else null
175
+ converted.ifEmpty { null }
175
176
  }
176
177
 
177
178
  return CameraSessionConfiguration(
@@ -250,4 +251,23 @@ fun imageProxyToBase64(image: ImageProxy, quality: Int, rotationDegrees: Int): S
250
251
  // Ensure bitmap is always recycled
251
252
  bitmap.recycle()
252
253
  }
254
+ }
255
+
256
+ /**
257
+ * Parses a string representation of video recording quality into a [VideoRecordingQuality] enum.
258
+ */
259
+ fun parseVideoRecordingQuality(rawValue: String?): VideoRecordingQuality? {
260
+ if (rawValue == null) {
261
+ return VideoRecordingQuality.HIGHEST
262
+ }
263
+
264
+ return when (rawValue) {
265
+ "lowest" -> VideoRecordingQuality.LOWEST
266
+ "sd" -> VideoRecordingQuality.SD
267
+ "hd" -> VideoRecordingQuality.HD
268
+ "fhd" -> VideoRecordingQuality.FHD
269
+ "uhd" -> VideoRecordingQuality.UHD
270
+ "highest" -> VideoRecordingQuality.HIGHEST
271
+ else -> null
272
+ }
253
273
  }