capacitor-camera-view 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +160,15 @@ 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()
134
173
 
135
174
  previewView?.let { view ->
@@ -242,11 +281,15 @@ class CameraView(plugin: Plugin) {
242
281
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
243
282
  )
244
283
  try {
245
- val base64String = imageProxyToBase64(image, quality, imageRotationDegrees)
284
+ val base64String =
285
+ imageProxyToBase64(image, quality, imageRotationDegrees)
246
286
  val result = JSObject().apply {
247
287
  put("photo", base64String)
248
288
  }
249
- Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
289
+ Log.d(
290
+ TAG,
291
+ "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
292
+ )
250
293
  continuation.resume(CameraResult.Success(result))
251
294
  } catch (e: Exception) {
252
295
  Log.e(TAG, "Error processing captured image", e)
@@ -346,6 +389,263 @@ class CameraView(plugin: Plugin) {
346
389
  }
347
390
  }
348
391
 
392
+ /**
393
+ * Starts video recording to a temporary file.
394
+ */
395
+ suspend fun startRecordingAsync(
396
+ enableAudio: Boolean,
397
+ videoQuality: VideoRecordingQuality,
398
+ ): CameraResult<Unit> = suspendCancellableCoroutine { continuation ->
399
+ mainHandler.post {
400
+ startRecordingOnMainThread(enableAudio, videoQuality, continuation)
401
+ }
402
+ }
403
+
404
+ private fun startRecordingOnMainThread(
405
+ enableAudio: Boolean,
406
+ videoQuality: VideoRecordingQuality,
407
+ continuation: CancellableContinuation<CameraResult<Unit>>
408
+ ) {
409
+ val controller = validateRecordingPreconditions(continuation) ?: return
410
+
411
+ try {
412
+ controller.videoCaptureQualitySelector = videoQuality.toQualitySelector()
413
+
414
+ // Enable VIDEO_CAPTURE use case alongside IMAGE_CAPTURE
415
+ enabledUseCasesBeforeRecording = controller.currentEnabledUseCases()
416
+ controller.setEnabledUseCases(
417
+ CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE
418
+ )
419
+
420
+ val outputOptions = createRecordingOutputOptions()
421
+ val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
422
+
423
+ startCameraRecording(controller, outputOptions, audioConfig, continuation)
424
+ } catch (e: SecurityException) {
425
+ Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
426
+ restoreUseCasesAfterRecording()
427
+ continuation.resume(CameraResult.Error(e))
428
+ } catch (e: Exception) {
429
+ Log.e(TAG, "Error starting recording", e)
430
+ restoreUseCasesAfterRecording()
431
+ continuation.resume(CameraResult.Error(e))
432
+ }
433
+ }
434
+
435
+ private fun validateRecordingPreconditions(
436
+ continuation: CancellableContinuation<CameraResult<Unit>>
437
+ ): LifecycleCameraController? {
438
+ val controller = cameraController
439
+ if (controller == null) {
440
+ continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
441
+ return null
442
+ }
443
+
444
+ if (activeRecording != null) {
445
+ continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
446
+ return null
447
+ }
448
+
449
+ return controller
450
+ }
451
+
452
+ private fun createRecordingOutputOptions(): FileOutputOptions {
453
+ val tempFile = File.createTempFile(
454
+ "camera_recording_",
455
+ ".mp4",
456
+ context.cacheDir
457
+ )
458
+ currentRecordingFile = tempFile
459
+ return FileOutputOptions.Builder(tempFile).build()
460
+ }
461
+
462
+ private fun resolveAudioConfig(
463
+ enableAudio: Boolean,
464
+ continuation: CancellableContinuation<CameraResult<Unit>>
465
+ ): AudioConfig? {
466
+ if (!enableAudio) {
467
+ return AudioConfig.AUDIO_DISABLED
468
+ }
469
+
470
+ if (hasMicrophonePermission()) {
471
+ return try {
472
+ AudioConfig.create(true)
473
+ } catch (e: SecurityException) {
474
+ continuation.resume(CameraResult.Error(e))
475
+ null
476
+ }
477
+ }
478
+
479
+ continuation.resume(
480
+ CameraResult.Error(
481
+ SecurityException("Microphone permission is required for audio recording")
482
+ )
483
+ )
484
+ return null
485
+ }
486
+
487
+ private fun hasMicrophonePermission(): Boolean {
488
+ return ContextCompat.checkSelfPermission(
489
+ context,
490
+ Manifest.permission.RECORD_AUDIO
491
+ ) == PackageManager.PERMISSION_GRANTED
492
+ }
493
+
494
+ private fun startCameraRecording(
495
+ controller: LifecycleCameraController,
496
+ outputOptions: FileOutputOptions,
497
+ audioConfig: AudioConfig,
498
+ continuation: CancellableContinuation<CameraResult<Unit>>
499
+ ) {
500
+ val startResumed = AtomicBoolean(false)
501
+ activeRecording = controller.startRecording(
502
+ outputOptions,
503
+ audioConfig,
504
+ cameraExecutor
505
+ ) { event ->
506
+ when (event) {
507
+ is VideoRecordEvent.Start -> handleRecordingStartEvent(startResumed, continuation)
508
+ is VideoRecordEvent.Finalize -> {
509
+ handleRecordingFinalizeEvent(event, startResumed, continuation)
510
+ }
511
+
512
+ else -> Unit
513
+ }
514
+ }
515
+ }
516
+
517
+ private fun handleRecordingStartEvent(
518
+ startResumed: AtomicBoolean,
519
+ continuation: CancellableContinuation<CameraResult<Unit>>
520
+ ) {
521
+ Log.d(TAG, "Video recording started")
522
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
523
+ continuation.resume(CameraResult.Success(Unit))
524
+ }
525
+ }
526
+
527
+ private fun handleRecordingFinalizeEvent(
528
+ event: VideoRecordEvent.Finalize,
529
+ startResumed: AtomicBoolean,
530
+ continuation: CancellableContinuation<CameraResult<Unit>>
531
+ ) {
532
+ // If recording finalized before Start was emitted, resume the
533
+ // startRecording continuation with an error
534
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
535
+ continuation.resume(
536
+ CameraResult.Error(
537
+ Exception("Recording failed to start: error code ${event.error}")
538
+ )
539
+ )
540
+ }
541
+
542
+ finalizeRecordingAndNotifyStopCallback(event)
543
+ }
544
+
545
+ private fun finalizeRecordingAndNotifyStopCallback(event: VideoRecordEvent.Finalize) {
546
+ mainHandler.post {
547
+ // CameraX requires use case changes on the main thread.
548
+ restoreUseCasesAfterRecording()
549
+
550
+ val callback = pendingStopCallback
551
+ pendingStopCallback = null
552
+ // Always clean up recording state
553
+ activeRecording = null
554
+
555
+ if (event.hasError()) {
556
+ Log.e(TAG, "Recording error: ${event.error}")
557
+ currentRecordingFile = null
558
+ callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
559
+ return@post
560
+ }
561
+
562
+ val file = currentRecordingFile
563
+ currentRecordingFile = null
564
+ if (file == null) {
565
+ callback?.invoke(CameraResult.Error(Exception("Recording file not found")))
566
+ return@post
567
+ }
568
+
569
+ val capacitorFilePath = FileUtils.getPortablePath(
570
+ context,
571
+ pluginDelegate.bridge.localUrl,
572
+ Uri.fromFile(file)
573
+ )
574
+ val result = JSObject().apply {
575
+ put("webPath", capacitorFilePath)
576
+ }
577
+ callback?.invoke(CameraResult.Success(result))
578
+ }
579
+ }
580
+
581
+ private fun LifecycleCameraController.currentEnabledUseCases(): Int {
582
+ var enabledUseCases = 0
583
+ if (isImageCaptureEnabled) {
584
+ enabledUseCases = enabledUseCases or CameraController.IMAGE_CAPTURE
585
+ }
586
+ if (isImageAnalysisEnabled) {
587
+ enabledUseCases = enabledUseCases or CameraController.IMAGE_ANALYSIS
588
+ }
589
+ if (isVideoCaptureEnabled) {
590
+ enabledUseCases = enabledUseCases or CameraController.VIDEO_CAPTURE
591
+ }
592
+ return enabledUseCases
593
+ }
594
+
595
+ private fun restoreUseCasesAfterRecording() {
596
+ val useCases = enabledUseCasesBeforeRecording ?: CameraController.IMAGE_CAPTURE
597
+ enabledUseCasesBeforeRecording = null
598
+ cameraController?.setEnabledUseCases(useCases)
599
+ }
600
+
601
+ private fun VideoRecordingQuality.toQualitySelector(): QualitySelector {
602
+ return when (this) {
603
+ VideoRecordingQuality.LOWEST -> QualitySelector.from(Quality.LOWEST)
604
+ VideoRecordingQuality.SD -> QualitySelector.from(
605
+ Quality.SD,
606
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
607
+ )
608
+
609
+ VideoRecordingQuality.HD -> QualitySelector.from(
610
+ Quality.HD,
611
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
612
+ )
613
+
614
+ VideoRecordingQuality.FHD -> QualitySelector.from(
615
+ Quality.FHD,
616
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
617
+ )
618
+
619
+ VideoRecordingQuality.UHD -> QualitySelector.from(
620
+ Quality.UHD,
621
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
622
+ )
623
+
624
+ VideoRecordingQuality.HIGHEST -> QualitySelector.from(Quality.HIGHEST)
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Stops the current video recording and returns the file path.
630
+ */
631
+ suspend fun stopRecordingAsync(): CameraResult<JSObject> =
632
+ suspendCancellableCoroutine { continuation ->
633
+ mainHandler.post {
634
+ val recording = activeRecording
635
+ if (recording == null) {
636
+ continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
637
+ return@post
638
+ }
639
+
640
+ pendingStopCallback = { result ->
641
+ continuation.resume(result)
642
+ }
643
+
644
+ activeRecording = null
645
+ recording.stop()
646
+ }
647
+ }
648
+
349
649
  /** Flip between front and back cameras */
350
650
  fun flipCamera(callback: (Exception?) -> Unit) {
351
651
  currentCameraSelector = when (currentCameraSelector) {
@@ -533,6 +833,12 @@ class CameraView(plugin: Plugin) {
533
833
 
534
834
  mainHandler.post {
535
835
  try {
836
+ // Stop any active recording before cleanup
837
+ activeRecording?.stop()
838
+ activeRecording = null
839
+ pendingStopCallback = null
840
+ currentRecordingFile = null
841
+
536
842
  // Stop camera session
537
843
  cameraController?.unbind()
538
844
  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
  }