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
@@ -12,12 +12,28 @@ 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
16
+ import kotlinx.coroutines.CoroutineScope
17
+ import kotlinx.coroutines.Dispatchers
18
+ import kotlinx.coroutines.Job
19
+ import kotlinx.coroutines.SupervisorJob
20
+ import kotlinx.coroutines.cancel
21
+ import kotlinx.coroutines.launch
15
22
 
16
23
  @CapacitorPlugin(
17
24
  name = "CameraView",
18
- 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
+ ]
19
29
  )
20
30
  class CameraViewPlugin : Plugin() {
31
+ // Coroutine scope for async operations
32
+ private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
33
+
34
+ // Job for barcode event subscription
35
+ private var barcodeJob: Job? = null
36
+
21
37
  private val implementation by lazy {
22
38
  CameraView(this)
23
39
  }
@@ -41,26 +57,42 @@ class CameraViewPlugin : Plugin() {
41
57
  }
42
58
 
43
59
  private fun startCamera(call: PluginCall) {
44
- implementation.startSession(
45
- config = sessionConfigFromPluginCall(call),
46
- callback = { error ->
47
- if (error != null) {
48
- call.reject("Failed to start camera preview: ${error.localizedMessage}", error)
49
- } else {
60
+ val config = sessionConfigFromPluginCall(call)
61
+
62
+ pluginScope.launch {
63
+ implementation.startSessionAsync(config).fold(
64
+ onSuccess = {
65
+ // Subscribe to barcode events if detection is enabled
66
+ if (config.enableBarcodeDetection) {
67
+ barcodeJob?.cancel()
68
+ barcodeJob = pluginScope.launch {
69
+ implementation.barcodeEvents.collect { result ->
70
+ notifyBarcodeDetected(result)
71
+ }
72
+ }
73
+ }
50
74
  call.resolve()
75
+ },
76
+ onError = { error ->
77
+ call.reject("Failed to start camera preview: ${error.localizedMessage}", error)
51
78
  }
52
- }
53
- )
79
+ )
80
+ }
54
81
  }
55
82
 
56
83
  @PluginMethod
57
84
  fun stop(call: PluginCall) {
58
- implementation.stopSession { error ->
59
- if (error != null) {
60
- call.reject("Failed to stop camera preview: ${error.localizedMessage}", error)
61
- } else {
62
- call.resolve()
63
- }
85
+ // Cancel barcode subscription
86
+ barcodeJob?.cancel()
87
+ barcodeJob = null
88
+
89
+ pluginScope.launch {
90
+ implementation.stopSessionAsync().fold(
91
+ onSuccess = { call.resolve() },
92
+ onError = { error ->
93
+ call.reject("Failed to stop camera preview: ${error.localizedMessage}", error)
94
+ }
95
+ )
64
96
  }
65
97
  }
66
98
 
@@ -74,7 +106,7 @@ class CameraViewPlugin : Plugin() {
74
106
 
75
107
  @PluginMethod
76
108
  fun capture(call: PluginCall) {
77
- val timeStart = System.currentTimeMillis();
109
+ val timeStart = System.currentTimeMillis()
78
110
  val quality = call.getInt("quality") ?: 90
79
111
  val saveToFile = call.getBoolean("saveToFile") ?: false
80
112
 
@@ -83,19 +115,23 @@ class CameraViewPlugin : Plugin() {
83
115
  return
84
116
  }
85
117
 
86
- implementation.capturePhoto(quality, saveToFile) { result, error ->
87
- when {
88
- error != null -> call.reject("Failed to capture image: ${error.message}", error)
89
- result == null -> call.reject("No image data")
90
- else -> call.resolve(result)
91
- }
92
- Log.d(TAG, "capture took ${System.currentTimeMillis() - timeStart}ms")
118
+ pluginScope.launch {
119
+ implementation.capturePhotoAsync(quality, saveToFile).fold(
120
+ onSuccess = { result ->
121
+ call.resolve(result)
122
+ Log.d(TAG, "capture took ${System.currentTimeMillis() - timeStart}ms")
123
+ },
124
+ onError = { error ->
125
+ call.reject("Failed to capture image: ${error.message}", error)
126
+ Log.d(TAG, "capture failed after ${System.currentTimeMillis() - timeStart}ms")
127
+ }
128
+ )
93
129
  }
94
130
  }
95
131
 
96
132
  @PluginMethod
97
133
  fun captureSample(call: PluginCall) {
98
- val timeStart = System.currentTimeMillis();
134
+ val timeStart = System.currentTimeMillis()
99
135
  val quality = call.getInt("quality") ?: 90
100
136
  val saveToFile = call.getBoolean("saveToFile") ?: false
101
137
 
@@ -104,13 +140,123 @@ class CameraViewPlugin : Plugin() {
104
140
  return
105
141
  }
106
142
 
107
- implementation.captureSampleFromPreview(quality, saveToFile) { result, error ->
108
- when {
109
- error != null -> call.reject("Failed to capture frame: ${error.message}", error)
110
- result == null -> call.reject("No frame data")
111
- else -> call.resolve(result)
112
- }
113
- Log.d(TAG, "captureSample took ${System.currentTimeMillis() - timeStart}ms")
143
+ pluginScope.launch {
144
+ implementation.captureSampleFromPreviewAsync(quality, saveToFile).fold(
145
+ onSuccess = { result ->
146
+ call.resolve(result)
147
+ Log.d(TAG, "captureSample took ${System.currentTimeMillis() - timeStart}ms")
148
+ },
149
+ onError = { error ->
150
+ call.reject("Failed to capture frame: ${error.message}", error)
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)
258
+ }
259
+ )
114
260
  }
115
261
  }
116
262
 
@@ -268,6 +414,10 @@ class CameraViewPlugin : Plugin() {
268
414
  }
269
415
 
270
416
  override fun handleOnDestroy() {
417
+ // Cancel barcode subscription and plugin scope
418
+ barcodeJob?.cancel()
419
+ pluginScope.cancel()
420
+
271
421
  implementation.cleanup()
272
422
  super.handleOnDestroy()
273
423
  }
@@ -0,0 +1,47 @@
1
+ package com.michaelwolz.capacitorcameraview.model
2
+
3
+ /**
4
+ * A Result type for consistent error handling throughout camera operations.
5
+ * Provides a functional approach to handling success and error cases.
6
+ */
7
+ sealed class CameraResult<out T> {
8
+ data class Success<T>(val value: T) : CameraResult<T>()
9
+ data class Error(val exception: Exception) : CameraResult<Nothing>()
10
+
11
+ /**
12
+ * Transforms this result using the provided functions.
13
+ */
14
+ inline fun <R> fold(
15
+ onSuccess: (T) -> R,
16
+ onError: (Exception) -> R
17
+ ): R = when (this) {
18
+ is Success -> onSuccess(value)
19
+ is Error -> onError(exception)
20
+ }
21
+
22
+ /**
23
+ * Returns the value if Success, null otherwise.
24
+ */
25
+ fun getOrNull(): T? = when (this) {
26
+ is Success -> value
27
+ is Error -> null
28
+ }
29
+
30
+ /**
31
+ * Returns the exception if Error, null otherwise.
32
+ */
33
+ fun exceptionOrNull(): Exception? = when (this) {
34
+ is Success -> null
35
+ is Error -> exception
36
+ }
37
+
38
+ /**
39
+ * Returns true if this is a Success.
40
+ */
41
+ val isSuccess: Boolean get() = this is Success
42
+
43
+ /**
44
+ * Returns true if this is an Error.
45
+ */
46
+ val isError: Boolean get() = this is Error
47
+ }
@@ -1,9 +1,19 @@
1
1
  package com.michaelwolz.capacitorcameraview.model
2
2
 
3
- /** Configuration for a camera session. */
3
+ /**
4
+ * Configuration for a camera session.
5
+ *
6
+ * @property deviceId Specific device ID to use. Takes precedence over position.
7
+ * @property enableBarcodeDetection Whether to enable barcode detection.
8
+ * @property barcodeTypes Optional list of specific barcode format codes to detect.
9
+ * If null, all supported formats are detected.
10
+ * @property position Camera position to use ("front" or "back").
11
+ * @property zoomFactor Initial zoom factor.
12
+ */
4
13
  data class CameraSessionConfiguration(
5
14
  val deviceId: String? = null,
6
15
  val enableBarcodeDetection: Boolean = false,
16
+ val barcodeTypes: List<Int>? = null,
7
17
  val position: String = "back",
8
18
  val zoomFactor: Float = 1.0f
9
19
  )
@@ -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,9 +13,53 @@ 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
 
20
+ /**
21
+ * Memory-efficient Base64 encoding utilities.
22
+ * Uses ThreadLocal ByteArrayOutputStream pool to reduce allocation churn.
23
+ */
24
+ object StreamingBase64Encoder {
25
+ // Reusable ByteArrayOutputStream to reduce allocation churn (per-thread)
26
+ private val outputStreamPool = object : ThreadLocal<ByteArrayOutputStream>() {
27
+ override fun initialValue(): ByteArrayOutputStream {
28
+ return ByteArrayOutputStream(256 * 1024) // 256KB initial capacity
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Encodes a bitmap to Base64 with memory optimization.
34
+ * Reuses ByteArrayOutputStream to reduce allocations.
35
+ *
36
+ * @param bitmap The bitmap to encode
37
+ * @param quality JPEG compression quality (0-100)
38
+ * @param format Compression format (default JPEG)
39
+ * @return Base64 encoded string
40
+ */
41
+ fun encodeToBase64(
42
+ bitmap: Bitmap,
43
+ quality: Int,
44
+ format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG
45
+ ): String {
46
+ val outputStream = outputStreamPool.get()!!
47
+ outputStream.reset() // Clear previous data
48
+
49
+ bitmap.compress(format, quality, outputStream)
50
+ val byteArray = outputStream.toByteArray()
51
+
52
+ return Base64.encodeToString(byteArray, Base64.NO_WRAP)
53
+ }
54
+
55
+ /**
56
+ * Encodes raw byte array to Base64.
57
+ */
58
+ fun encodeToBase64(bytes: ByteArray): String {
59
+ return Base64.encodeToString(bytes, Base64.NO_WRAP)
60
+ }
61
+ }
62
+
19
63
  /** Converts a barcode format code to a readable string. */
20
64
  fun getBarcodeFormatString(format: Int): String {
21
65
  return when (format) {
@@ -36,6 +80,43 @@ fun getBarcodeFormatString(format: Int): String {
36
80
  }
37
81
  }
38
82
 
83
+ /**
84
+ * Converts a string barcode type from JavaScript to ML Kit Barcode format constant.
85
+ *
86
+ * @param stringType The string barcode type from JavaScript.
87
+ * @return The corresponding ML Kit Barcode format constant, or null if not recognized.
88
+ */
89
+ fun convertToNativeBarcodeFormat(stringType: String): Int? {
90
+ return when (stringType) {
91
+ "qr" -> Barcode.FORMAT_QR_CODE
92
+ "aztec" -> Barcode.FORMAT_AZTEC
93
+ "codabar" -> Barcode.FORMAT_CODABAR
94
+ "code39" -> Barcode.FORMAT_CODE_39
95
+ "code39Mod43" -> Barcode.FORMAT_CODE_39 // ML Kit doesn't distinguish Mod43
96
+ "code93" -> Barcode.FORMAT_CODE_93
97
+ "code128" -> Barcode.FORMAT_CODE_128
98
+ "dataMatrix" -> Barcode.FORMAT_DATA_MATRIX
99
+ "ean8" -> Barcode.FORMAT_EAN_8
100
+ "ean13" -> Barcode.FORMAT_EAN_13
101
+ "interleaved2of5" -> Barcode.FORMAT_ITF
102
+ "itf14" -> Barcode.FORMAT_ITF
103
+ "pdf417" -> Barcode.FORMAT_PDF417
104
+ "upcA" -> Barcode.FORMAT_UPC_A
105
+ "upce" -> Barcode.FORMAT_UPC_E
106
+ else -> null
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Converts an array of string barcode types to ML Kit Barcode format constants.
112
+ *
113
+ * @param stringTypes List of string barcode types from JavaScript.
114
+ * @return List of ML Kit Barcode format constants (invalid types are filtered out).
115
+ */
116
+ fun convertToNativeBarcodeFormats(stringTypes: List<String>): List<Int> {
117
+ return stringTypes.mapNotNull { convertToNativeBarcodeFormat(it) }.distinct()
118
+ }
119
+
39
120
  /**
40
121
  * Converts the bounding box of a barcode detection result to a [WebBoundingRect]
41
122
  * suitable for use in the web view via regular CSS pixels.
@@ -84,9 +165,20 @@ fun calculateTopOffset(webView: View): Int {
84
165
 
85
166
  /** Maps a Capacitor plugin call to a [CameraSessionConfiguration]. */
86
167
  fun sessionConfigFromPluginCall(call: PluginCall): CameraSessionConfiguration {
168
+ // Parse barcode types if provided
169
+ val barcodeTypes: List<Int>? = call.getArray("barcodeTypes")?.let { jsonArray ->
170
+ val stringTypes = mutableListOf<String>()
171
+ for (i in 0 until jsonArray.length()) {
172
+ jsonArray.optString(i)?.let { stringTypes.add(it) }
173
+ }
174
+ val converted = convertToNativeBarcodeFormats(stringTypes)
175
+ converted.ifEmpty { null }
176
+ }
177
+
87
178
  return CameraSessionConfiguration(
88
179
  deviceId = call.getString("deviceId"),
89
180
  enableBarcodeDetection = call.getBoolean("enableBarcodeDetection") ?: false,
181
+ barcodeTypes = barcodeTypes,
90
182
  position = call.getString("position") ?: "back",
91
183
  zoomFactor = call.getFloat("zoomFactor") ?: 1.0f
92
184
  )
@@ -126,6 +218,7 @@ fun calculateImageRotationBasedOnDisplayRotation(
126
218
 
127
219
  /**
128
220
  * Converts an ImageProxy to a Base64 encoded string and applies rotation if necessary.
221
+ * Uses StreamingBase64Encoder for memory-efficient encoding.
129
222
  *
130
223
  * @param image The ImageProxy to convert.
131
224
  * @param quality The JPEG compression quality (0-100).
@@ -152,13 +245,29 @@ fun imageProxyToBase64(image: ImageProxy, quality: Int, rotationDegrees: Int): S
152
245
  bitmap = rotatedBitmap
153
246
  }
154
247
 
155
- val outputStream = ByteArrayOutputStream()
156
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
157
- val byteArray = outputStream.toByteArray()
158
-
159
- return Base64.encodeToString(byteArray, Base64.NO_WRAP)
248
+ // Use streaming encoder for memory efficiency
249
+ return StreamingBase64Encoder.encodeToBase64(bitmap, quality)
160
250
  } finally {
161
251
  // Ensure bitmap is always recycled
162
252
  bitmap.recycle()
163
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
+ }
164
273
  }