capacitor-camera-view 2.4.0 → 3.0.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.
Files changed (44) hide show
  1. package/CapacitorCameraView.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +341 -49
  4. package/android/build.gradle +0 -1
  5. package/android/src/main/AndroidManifest.xml +0 -1
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
  10. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
  11. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
  12. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
  13. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
  14. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
  15. package/dist/docs.json +475 -30
  16. package/dist/esm/definitions.d.ts +478 -26
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/utils.d.ts +59 -15
  19. package/dist/esm/utils.js +79 -38
  20. package/dist/esm/utils.js.map +1 -1
  21. package/dist/esm/web.d.ts +191 -13
  22. package/dist/esm/web.js +624 -141
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +711 -179
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +711 -179
  27. package/dist/plugin.js.map +1 -1
  28. package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
  29. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
  30. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
  31. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
  32. package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
  33. package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
  34. package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
  35. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
  36. package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
  37. package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +195 -0
  38. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
  39. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
  40. package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
  41. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
  42. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
@@ -52,7 +52,7 @@ class CameraViewPlugin : Plugin() {
52
52
  if (getPermissionState("camera") == PermissionState.GRANTED) {
53
53
  startCamera(call)
54
54
  } else {
55
- call.reject("Permission is required to take a picture")
55
+ call.reject("Permission is required to take a picture", CameraError.PERMISSION_DENIED)
56
56
  }
57
57
  }
58
58
 
@@ -60,21 +60,35 @@ class CameraViewPlugin : Plugin() {
60
60
  val config = sessionConfigFromPluginCall(call)
61
61
 
62
62
  pluginScope.launch {
63
+ // Cancel any previous collector and, if this session wants barcode
64
+ // detection, subscribe a fresh one *before* starting the session.
65
+ // `barcodeEvents` has replay = 0, so a detection emitted between
66
+ // analyzer attachment (inside startSessionAsync -> bindToLifecycle)
67
+ // and collector subscription would otherwise be silently dropped -
68
+ // subscribing here, before startSessionAsync even runs, closes
69
+ // that window entirely. pluginScope uses Dispatchers.Main.immediate,
70
+ // so this nested launch's `collect` call registers its slot on the
71
+ // SharedFlow synchronously before control returns here.
72
+ barcodeJob?.cancel()
73
+ barcodeJob = if (config.enableBarcodeDetection) {
74
+ pluginScope.launch {
75
+ implementation.barcodeEvents.collect { result ->
76
+ notifyBarcodeDetected(result)
77
+ }
78
+ }
79
+ } else {
80
+ null
81
+ }
82
+
63
83
  implementation.startSessionAsync(config).fold(
64
84
  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
- }
74
85
  call.resolve()
75
86
  },
76
87
  onError = { error ->
77
- call.reject("Failed to start camera preview: ${error.localizedMessage}", error)
88
+ // The session never started - don't leak a live collector.
89
+ barcodeJob?.cancel()
90
+ barcodeJob = null
91
+ call.reject("Failed to start camera preview: ${error.localizedMessage}", error.cameraErrorCode, error)
78
92
  }
79
93
  )
80
94
  }
@@ -90,7 +104,7 @@ class CameraViewPlugin : Plugin() {
90
104
  implementation.stopSessionAsync().fold(
91
105
  onSuccess = { call.resolve() },
92
106
  onError = { error ->
93
- call.reject("Failed to stop camera preview: ${error.localizedMessage}", error)
107
+ call.reject("Failed to stop camera preview: ${error.localizedMessage}", error.cameraErrorCode, error)
94
108
  }
95
109
  )
96
110
  }
@@ -111,7 +125,7 @@ class CameraViewPlugin : Plugin() {
111
125
  val saveToFile = call.getBoolean("saveToFile") ?: false
112
126
 
113
127
  if (quality !in 0..100) {
114
- call.reject("Quality must be between 0 and 100")
128
+ call.reject("Quality must be between 0 and 100", CameraError.INVALID_ARGUMENT)
115
129
  return
116
130
  }
117
131
 
@@ -122,7 +136,7 @@ class CameraViewPlugin : Plugin() {
122
136
  Log.d(TAG, "capture took ${System.currentTimeMillis() - timeStart}ms")
123
137
  },
124
138
  onError = { error ->
125
- call.reject("Failed to capture image: ${error.message}", error)
139
+ call.reject("Failed to capture image: ${error.message}", error.cameraErrorCode, error)
126
140
  Log.d(TAG, "capture failed after ${System.currentTimeMillis() - timeStart}ms")
127
141
  }
128
142
  )
@@ -136,7 +150,7 @@ class CameraViewPlugin : Plugin() {
136
150
  val saveToFile = call.getBoolean("saveToFile") ?: false
137
151
 
138
152
  if (quality !in 0..100) {
139
- call.reject("Quality must be between 0 and 100")
153
+ call.reject("Quality must be between 0 and 100", CameraError.INVALID_ARGUMENT)
140
154
  return
141
155
  }
142
156
 
@@ -147,7 +161,7 @@ class CameraViewPlugin : Plugin() {
147
161
  Log.d(TAG, "captureSample took ${System.currentTimeMillis() - timeStart}ms")
148
162
  },
149
163
  onError = { error ->
150
- call.reject("Failed to capture frame: ${error.message}", error)
164
+ call.reject("Failed to capture frame: ${error.message}", error.cameraErrorCode, error)
151
165
  Log.d(
152
166
  TAG,
153
167
  "captureSample failed after ${System.currentTimeMillis() - timeStart}ms"
@@ -201,7 +215,10 @@ class CameraViewPlugin : Plugin() {
201
215
  val videoQuality =
202
216
  parseVideoRecordingQuality(call.getString("videoQuality"))
203
217
  ?: run {
204
- call.reject("Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest")
218
+ call.reject(
219
+ "Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest",
220
+ CameraError.INVALID_ARGUMENT
221
+ )
205
222
  return
206
223
  }
207
224
 
@@ -220,12 +237,15 @@ class CameraViewPlugin : Plugin() {
220
237
  val videoQuality =
221
238
  parseVideoRecordingQuality(call.getString("videoQuality"))
222
239
  ?: run {
223
- call.reject("Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest")
240
+ call.reject(
241
+ "Invalid videoQuality. Use one of: lowest, sd, hd, fhd, uhd, highest",
242
+ CameraError.INVALID_ARGUMENT
243
+ )
224
244
  return
225
245
  }
226
246
  doStartRecording(call, enableAudio, videoQuality)
227
247
  } else {
228
- call.reject("Microphone permission is required for audio recording")
248
+ call.reject("Microphone permission is required for audio recording", CameraError.PERMISSION_DENIED)
229
249
  }
230
250
  }
231
251
 
@@ -242,7 +262,7 @@ class CameraViewPlugin : Plugin() {
242
262
  implementation.startRecordingAsync(enableAudio, videoQuality).fold(
243
263
  onSuccess = { call.resolve() },
244
264
  onError = { error ->
245
- call.reject("Failed to start recording: ${error.message}", error)
265
+ call.reject("Failed to start recording: ${error.message}", error.cameraErrorCode, error)
246
266
  }
247
267
  )
248
268
  }
@@ -254,7 +274,7 @@ class CameraViewPlugin : Plugin() {
254
274
  implementation.stopRecordingAsync().fold(
255
275
  onSuccess = { result -> call.resolve(result) },
256
276
  onError = { error ->
257
- call.reject("Failed to stop recording: ${error.message}", error)
277
+ call.reject("Failed to stop recording: ${error.message}", error.cameraErrorCode, error)
258
278
  }
259
279
  )
260
280
  }
@@ -269,6 +289,7 @@ class CameraViewPlugin : Plugin() {
269
289
  put("id", device.id)
270
290
  put("name", device.name)
271
291
  put("position", device.position)
292
+ device.deviceType?.let { put("deviceType", it) }
272
293
  })
273
294
  }
274
295
  }
@@ -280,7 +301,7 @@ class CameraViewPlugin : Plugin() {
280
301
  fun flipCamera(call: PluginCall) {
281
302
  implementation.flipCamera { error ->
282
303
  if (error != null) {
283
- call.reject("Failed to flip camera: ${error.localizedMessage}", error)
304
+ call.reject("Failed to flip camera: ${error.localizedMessage}", error.cameraErrorCode, error)
284
305
  } else {
285
306
  call.resolve()
286
307
  }
@@ -302,13 +323,32 @@ class CameraViewPlugin : Plugin() {
302
323
  fun setZoom(call: PluginCall) {
303
324
  val level = call.getFloat("level")
304
325
  if (level == null) {
305
- call.reject("Zoom level must be provided")
326
+ call.reject("Zoom level must be provided", CameraError.INVALID_ARGUMENT)
306
327
  return
307
328
  }
308
329
 
309
330
  implementation.setZoomFactor(level) { error ->
310
331
  if (error != null) {
311
- call.reject(error.localizedMessage)
332
+ call.reject(error.localizedMessage ?: "Failed to set zoom level", error.cameraErrorCode, error)
333
+ } else {
334
+ call.resolve()
335
+ }
336
+ }
337
+ }
338
+
339
+ @PluginMethod
340
+ fun setFocusPoint(call: PluginCall) {
341
+ val x = call.getFloat("x")
342
+ val y = call.getFloat("y")
343
+
344
+ if (x == null || y == null) {
345
+ call.reject("Focus point x and y must be provided", CameraError.INVALID_ARGUMENT)
346
+ return
347
+ }
348
+
349
+ implementation.setFocusPoint(x, y) { error ->
350
+ if (error != null) {
351
+ call.reject("Failed to set focus point: ${error.localizedMessage}", error.cameraErrorCode, error)
312
352
  } else {
313
353
  call.resolve()
314
354
  }
@@ -337,21 +377,22 @@ class CameraViewPlugin : Plugin() {
337
377
  fun setFlashMode(call: PluginCall) {
338
378
  val mode = call.getString("mode")
339
379
  if (mode == null) {
340
- call.reject("Flash mode must be provided")
380
+ call.reject("Flash mode must be provided", CameraError.INVALID_ARGUMENT)
341
381
  return
342
382
  }
343
383
 
344
384
  val validModes = listOf("off", "on", "auto")
345
385
  if (!validModes.contains(mode)) {
346
- call.reject("Invalid flash mode. Must be one of: ${validModes.joinToString(", ")}")
386
+ call.reject("Invalid flash mode. Must be one of: ${validModes.joinToString(", ")}", CameraError.INVALID_ARGUMENT)
347
387
  return
348
388
  }
349
389
 
350
- try {
351
- implementation.setFlashMode(mode)
352
- call.resolve()
353
- } catch (e: Exception) {
354
- call.reject("Failed to set flash mode: ${e.localizedMessage}", e)
390
+ implementation.setFlashMode(mode) { error ->
391
+ if (error != null) {
392
+ call.reject("Failed to set flash mode: ${error.localizedMessage}", error.cameraErrorCode, error)
393
+ } else {
394
+ call.resolve()
395
+ }
355
396
  }
356
397
  }
357
398
 
@@ -366,14 +407,10 @@ class CameraViewPlugin : Plugin() {
366
407
 
367
408
  @PluginMethod
368
409
  fun getTorchMode(call: PluginCall) {
369
- implementation.getTorchMode { enabled ->
410
+ implementation.getTorchMode { state ->
370
411
  call.resolve(JSObject().apply {
371
- put("enabled", enabled)
372
- put(
373
- "level",
374
- // Android always uses full intensity when enabled
375
- if (enabled) 1.0f else 0.0f
376
- )
412
+ put("enabled", state.enabled)
413
+ put("level", state.level)
377
414
  })
378
415
  }
379
416
  }
@@ -382,13 +419,19 @@ class CameraViewPlugin : Plugin() {
382
419
  fun setTorchMode(call: PluginCall) {
383
420
  val enabled = call.getBoolean("enabled")
384
421
  if (enabled == null) {
385
- call.reject("Enabled parameter is required")
422
+ call.reject("Enabled parameter is required", CameraError.INVALID_ARGUMENT)
423
+ return
424
+ }
425
+
426
+ val level = call.getFloat("level")
427
+ if (level != null && (level < 0.0f || level > 1.0f)) {
428
+ call.reject("Level must be between 0.0 and 1.0", CameraError.INVALID_ARGUMENT)
386
429
  return
387
430
  }
388
431
 
389
- implementation.setTorchMode(enabled) { error ->
432
+ implementation.setTorchMode(enabled, level) { error ->
390
433
  if (error != null) {
391
- call.reject("Failed to set torch mode: ${error.localizedMessage}", error)
434
+ call.reject("Failed to set torch mode: ${error.localizedMessage}", error.cameraErrorCode, error)
392
435
  } else {
393
436
  call.resolve()
394
437
  }
@@ -396,9 +439,10 @@ class CameraViewPlugin : Plugin() {
396
439
  }
397
440
 
398
441
  /**
399
- * Called by the CameraView when a barcode is detected.
442
+ * Notifies JS listeners of a barcode detection collected from
443
+ * [CameraView.barcodeEvents].
400
444
  */
401
- fun notifyBarcodeDetected(result: BarcodeDetectionResult) {
445
+ private fun notifyBarcodeDetected(result: BarcodeDetectionResult) {
402
446
  val rawBytesArray = JSArray().apply {
403
447
  result.rawBytes.forEach { put(it.toInt() and 0xFF) }
404
448
  }
@@ -1,7 +1,13 @@
1
1
  package com.michaelwolz.capacitorcameraview.model
2
2
 
3
3
  /**
4
- * Barcode detection result containing the value, format type, and normalized bounding rectangle.
4
+ * Barcode detection result containing the value, format type, and the bounding
5
+ * rectangle in display/CSS pixels within the webview coordinate space.
6
+ *
7
+ * Overrides `equals`/`hashCode` explicitly because [rawBytes] is an array: the
8
+ * data-class-generated versions would otherwise compare/hash it by reference identity
9
+ * rather than content, silently breaking equality (and any hash-based collection use)
10
+ * for two results with the same content but different array instances.
5
11
  */
6
12
  data class BarcodeDetectionResult(
7
13
  val value: String,
@@ -9,4 +15,24 @@ data class BarcodeDetectionResult(
9
15
  val displayValue: String,
10
16
  val type: String,
11
17
  val boundingRect: WebBoundingRect
12
- )
18
+ ) {
19
+ override fun equals(other: Any?): Boolean {
20
+ if (this === other) return true
21
+ if (other !is BarcodeDetectionResult) return false
22
+
23
+ return value == other.value &&
24
+ rawBytes.contentEquals(other.rawBytes) &&
25
+ displayValue == other.displayValue &&
26
+ type == other.type &&
27
+ boundingRect == other.boundingRect
28
+ }
29
+
30
+ override fun hashCode(): Int {
31
+ var result = value.hashCode()
32
+ result = 31 * result + rawBytes.contentHashCode()
33
+ result = 31 * result + displayValue.hashCode()
34
+ result = 31 * result + type.hashCode()
35
+ result = 31 * result + boundingRect.hashCode()
36
+ return result
37
+ }
38
+ }
@@ -6,9 +6,14 @@ package com.michaelwolz.capacitorcameraview.model
6
6
  * @property id Unique identifier for the camera
7
7
  * @property name Human-readable name of the camera
8
8
  * @property position Position of the camera ("front" or "back")
9
+ * @property deviceType Lens type ("wideAngle", "ultraWide", or "telephoto") derived from
10
+ * camera characteristics where derivable, matching the
11
+ * iOS-populated field of the same name. `null` when it can't be
12
+ * determined (e.g. focal length/sensor size not reported).
9
13
  */
10
14
  data class CameraDevice(
11
15
  val id: String,
12
16
  val name: String,
13
- val position: String
17
+ val position: String,
18
+ val deviceType: String? = null
14
19
  )
@@ -9,12 +9,26 @@ package com.michaelwolz.capacitorcameraview.model
9
9
  * If null, all supported formats are detected.
10
10
  * @property position Camera position to use ("front" or "back").
11
11
  * @property zoomFactor Initial zoom factor.
12
+ * @property aspectRatio Desired sensor aspect ratio ("4:3" or "16:9") applied to both
13
+ * the preview and photo capture. If null, the long-standing
14
+ * defaults are kept (16:9-with-fallback for capture, automatic
15
+ * preview resolution).
16
+ * @property captureMaxDimension Optional upper bound, in pixels, for the longer edge
17
+ * of captured photos. If null, the default capture
18
+ * resolution is kept.
19
+ * @property previewScaleMode How the preview is scaled into its container. "fit"
20
+ * letterboxes the whole frame (FIT_CENTER); any other
21
+ * value (including null) keeps the default cover
22
+ * behavior (FILL_CENTER).
12
23
  */
13
24
  data class CameraSessionConfiguration(
14
25
  val deviceId: String? = null,
15
26
  val enableBarcodeDetection: Boolean = false,
16
27
  val barcodeTypes: List<Int>? = null,
17
28
  val position: String = "back",
18
- val zoomFactor: Float = 1.0f
29
+ val zoomFactor: Float = 1.0f,
30
+ val aspectRatio: String? = null,
31
+ val captureMaxDimension: Int? = null,
32
+ val previewScaleMode: String? = null
19
33
  )
20
34
 
@@ -0,0 +1,15 @@
1
+ package com.michaelwolz.capacitorcameraview.model
2
+
3
+ /**
4
+ * Represents the current torch (flashlight) mode and intensity.
5
+ *
6
+ * @property enabled Whether the torch is currently enabled.
7
+ * @property level The current torch intensity level, normalized to 0.0-1.0. Only meaningful
8
+ * as a continuous value on API 33+ devices with multi-level torch hardware - below API 33, or
9
+ * on single-level hardware, the torch is binary and this is always 1.0 when enabled and 0.0
10
+ * when off.
11
+ */
12
+ data class TorchModeState(
13
+ val enabled: Boolean,
14
+ val level: Float
15
+ )
@@ -1,6 +1,6 @@
1
1
  package com.michaelwolz.capacitorcameraview.model
2
2
 
3
- /** Normalized rectangle for barcode bounds. */
3
+ /** Rectangle for barcode bounds in display/CSS pixels within the webview coordinate space. */
4
4
  data class WebBoundingRect(
5
5
  /** Top left x coordinate of the rectangle. */
6
6
  val x: Float,
@@ -5,9 +5,11 @@ import android.graphics.BitmapFactory
5
5
  import android.graphics.Matrix
6
6
  import android.graphics.Rect
7
7
  import android.util.Base64
8
+ import android.util.Base64OutputStream
8
9
  import android.view.Surface
9
10
  import android.view.View
10
11
  import android.view.ViewGroup.MarginLayoutParams
12
+ import androidx.camera.core.CameraSelector
11
13
  import androidx.camera.core.ImageProxy
12
14
  import androidx.camera.view.PreviewView
13
15
  import com.getcapacitor.PluginCall
@@ -15,7 +17,9 @@ import com.google.mlkit.vision.barcode.common.Barcode
15
17
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
16
18
  import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
17
19
  import com.michaelwolz.capacitorcameraview.model.WebBoundingRect
20
+ import kotlinx.coroutines.CancellableContinuation
18
21
  import java.io.ByteArrayOutputStream
22
+ import kotlin.coroutines.resume
19
23
 
20
24
  /**
21
25
  * Memory-efficient Base64 encoding utilities.
@@ -31,7 +35,12 @@ object StreamingBase64Encoder {
31
35
 
32
36
  /**
33
37
  * Encodes a bitmap to Base64 with memory optimization.
34
- * Reuses ByteArrayOutputStream to reduce allocations.
38
+ * Reuses a pooled [ByteArrayOutputStream] and streams the compressed bytes straight
39
+ * through a [Base64OutputStream] into it, instead of collecting the raw (un-encoded)
40
+ * bytes first and then Base64-encoding them as a separate pass. This avoids ever
41
+ * holding a full-size raw buffer and a full-size Base64 buffer at the same time,
42
+ * roughly halving peak memory versus a two-step compress-then-`Base64.encodeToString`
43
+ * approach.
35
44
  *
36
45
  * @param bitmap The bitmap to encode
37
46
  * @param quality JPEG compression quality (0-100)
@@ -46,10 +55,16 @@ object StreamingBase64Encoder {
46
55
  val outputStream = outputStreamPool.get()!!
47
56
  outputStream.reset() // Clear previous data
48
57
 
49
- bitmap.compress(format, quality, outputStream)
50
- val byteArray = outputStream.toByteArray()
58
+ // Base64OutputStream.close() finalizes any trailing padding and then closes the
59
+ // wrapped stream; closing a ByteArrayOutputStream is a documented no-op, so the
60
+ // pooled buffer and its contents remain valid (and reusable) afterwards.
61
+ Base64OutputStream(outputStream, Base64.NO_WRAP).use { base64Stream ->
62
+ bitmap.compress(format, quality, base64Stream)
63
+ }
51
64
 
52
- return Base64.encodeToString(byteArray, Base64.NO_WRAP)
65
+ // The Base64 alphabet is pure ASCII, so this is a lossless decode of the bytes
66
+ // already sitting in the pooled buffer.
67
+ return String(outputStream.toByteArray(), Charsets.US_ASCII)
53
68
  }
54
69
 
55
70
  /**
@@ -72,10 +87,10 @@ fun getBarcodeFormatString(format: Int): String {
72
87
  Barcode.FORMAT_DATA_MATRIX -> "dataMatrix"
73
88
  Barcode.FORMAT_EAN_8 -> "ean8"
74
89
  Barcode.FORMAT_EAN_13 -> "ean13"
75
- Barcode.FORMAT_ITF -> "itf"
90
+ Barcode.FORMAT_ITF -> "itf14"
76
91
  Barcode.FORMAT_PDF417 -> "pdf417"
77
92
  Barcode.FORMAT_UPC_A -> "upcA"
78
- Barcode.FORMAT_UPC_E -> "upcE"
93
+ Barcode.FORMAT_UPC_E -> "upce"
79
94
  else -> "unknown"
80
95
  }
81
96
  }
@@ -180,25 +195,43 @@ fun sessionConfigFromPluginCall(call: PluginCall): CameraSessionConfiguration {
180
195
  enableBarcodeDetection = call.getBoolean("enableBarcodeDetection") ?: false,
181
196
  barcodeTypes = barcodeTypes,
182
197
  position = call.getString("position") ?: "back",
183
- zoomFactor = call.getFloat("zoomFactor") ?: 1.0f
198
+ zoomFactor = call.getFloat("zoomFactor") ?: 1.0f,
199
+ aspectRatio = call.getString("aspectRatio"),
200
+ captureMaxDimension = call.getInt("captureMaxDimension"),
201
+ previewScaleMode = call.getString("previewScaleMode")
184
202
  )
185
203
  }
186
204
 
187
205
  /**
188
- * Calculates the image orientation based on the display rotation and sensor rotation degrees.
206
+ * Whether the given CameraX lens-facing value denotes a front-facing camera.
207
+ *
208
+ * Deriving facing from the bound camera's lens facing is the only reliable signal:
209
+ * comparing the current [CameraSelector] against [CameraSelector.DEFAULT_FRONT_CAMERA] is
210
+ * wrong for any custom selector built from a `deviceId`.
211
+ *
212
+ * @param lensFacing A `CameraSelector.LENS_FACING_*` value, or `null` if not yet known.
213
+ * @return `true` only when [lensFacing] is [CameraSelector.LENS_FACING_FRONT].
214
+ */
215
+ fun isLensFacingFront(lensFacing: Int?): Boolean {
216
+ return lensFacing == CameraSelector.LENS_FACING_FRONT
217
+ }
218
+
219
+ /**
220
+ * Calculates the clockwise rotation (in degrees) to apply to a still capture so it is
221
+ * upright for the given display orientation.
189
222
  *
190
- * This is because CameraController will set the image orientation based on the device's
191
- * motion sensor, which may not match the display rotation and in this case not what we actually
192
- * want.
223
+ * The base value is the frame's own [imageRotationDegrees] rather than a fixed
224
+ * sensor-derived value, so devices whose HAL returns pre-rotated buffers report `0` here
225
+ * and are not rotated a second time.
193
226
  *
194
- * @param displayRotation The current display rotation (0, 1, 2, or 3).
195
- * @param sensorRotationDegrees The rotation of the camera sensor in degrees (0, 90, 180, or 270).
196
- * @param isFrontFacing Whether the camera is front-facing or back-facing.
197
- * @return The calculated image orientation in degrees.
227
+ * @param imageRotationDegrees The captured frame's `ImageInfo.rotationDegrees` (0/90/180/270).
228
+ * @param displayRotation The current display rotation (`Surface.ROTATION_*`: 0, 1, 2, or 3).
229
+ * @param isFrontFacing Whether the active camera is front-facing.
230
+ * @return The calculated image orientation in degrees (0, 90, 180, or 270).
198
231
  */
199
- fun calculateImageRotationBasedOnDisplayRotation(
232
+ fun calculateImageRotation(
233
+ imageRotationDegrees: Int,
200
234
  displayRotation: Int,
201
- sensorRotationDegrees: Int,
202
235
  isFrontFacing: Boolean
203
236
  ): Int {
204
237
  val surfaceRotationDegrees = when (displayRotation) {
@@ -210,9 +243,9 @@ fun calculateImageRotationBasedOnDisplayRotation(
210
243
  }
211
244
 
212
245
  return if (isFrontFacing) {
213
- (sensorRotationDegrees + surfaceRotationDegrees) % 360
246
+ (imageRotationDegrees + surfaceRotationDegrees) % 360
214
247
  } else {
215
- (sensorRotationDegrees - surfaceRotationDegrees + 360) % 360
248
+ (imageRotationDegrees - surfaceRotationDegrees + 360) % 360
216
249
  }
217
250
  }
218
251
 
@@ -253,6 +286,27 @@ fun imageProxyToBase64(image: ImageProxy, quality: Int, rotationDegrees: Int): S
253
286
  }
254
287
  }
255
288
 
289
+ /**
290
+ * Resumes this continuation with [value], but only if it hasn't already been resumed or
291
+ * cancelled. The camera/capture callbacks that resume continuations in this plugin run on
292
+ * the CameraX/ML Kit executor or a main-thread [android.os.Handler.post] callback - never on
293
+ * the coroutine's own dispatcher - so a resume can race with the caller cancelling the
294
+ * coroutine (e.g. because the plugin is being torn down mid-capture).
295
+ *
296
+ * The atomic `tryResume`/`completeResume` pair is `@InternalCoroutinesApi`, so this checks
297
+ * [CancellableContinuation.isActive] first and swallows the `IllegalStateException` a plain
298
+ * `resume()` throws if cancellation still wins the race.
299
+ */
300
+ fun <T> CancellableContinuation<T>.resumeIfActive(value: T) {
301
+ if (!isActive) return
302
+ try {
303
+ resume(value)
304
+ } catch (e: IllegalStateException) {
305
+ // Lost the race with cancellation between the isActive check and resume() -
306
+ // nobody is awaiting the result anymore, so there's nothing to recover.
307
+ }
308
+ }
309
+
256
310
  /**
257
311
  * Parses a string representation of video recording quality into a [VideoRecordingQuality] enum.
258
312
  */