capacitor-camera-view 2.3.1 → 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 -48
  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 +946 -205
  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 +487 -30
  16. package/dist/esm/definitions.d.ts +494 -27
  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 +626 -142
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +713 -180
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +713 -180
  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 +111 -69
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
@@ -5,25 +5,36 @@ import android.content.Context
5
5
  import android.content.Context.CAMERA_SERVICE
6
6
  import android.content.pm.PackageManager
7
7
  import android.graphics.Bitmap
8
+ import android.graphics.BitmapFactory
9
+ import android.graphics.drawable.ColorDrawable
10
+ import android.graphics.drawable.Drawable
8
11
  import android.hardware.camera2.CameraCharacteristics
9
12
  import android.hardware.camera2.CameraManager
13
+ import android.hardware.camera2.CaptureRequest
10
14
  import android.net.Uri
11
- import android.util.Base64
15
+ import android.os.Build
16
+ import android.os.SystemClock
12
17
  import android.util.Log
18
+ import android.util.Size
13
19
  import android.view.Surface
14
20
  import android.view.ViewGroup
15
21
  import android.webkit.WebView
16
22
  import androidx.annotation.OptIn
23
+ import androidx.camera.camera2.interop.Camera2CameraControl
17
24
  import androidx.camera.camera2.interop.Camera2CameraInfo
25
+ import androidx.camera.camera2.interop.CaptureRequestOptions
18
26
  import androidx.camera.camera2.interop.ExperimentalCamera2Interop
19
27
  import androidx.camera.core.CameraSelector
28
+ import androidx.camera.core.FocusMeteringAction
20
29
  import androidx.camera.core.ImageAnalysis
21
30
  import androidx.camera.core.ImageCapture
22
31
  import androidx.camera.core.ImageCaptureException
23
32
  import androidx.camera.core.ImageProxy
24
33
  import androidx.camera.core.TorchState
34
+ import androidx.camera.core.ZoomState
25
35
  import androidx.camera.core.resolutionselector.AspectRatioStrategy
26
36
  import androidx.camera.core.resolutionselector.ResolutionSelector
37
+ import androidx.camera.core.resolutionselector.ResolutionStrategy
27
38
  import androidx.camera.mlkit.vision.MlKitAnalyzer
28
39
  import androidx.camera.video.FallbackStrategy
29
40
  import androidx.camera.video.FileOutputOptions
@@ -37,9 +48,11 @@ import androidx.camera.view.PreviewView
37
48
  import androidx.camera.view.video.AudioConfig
38
49
  import androidx.core.content.ContextCompat
39
50
  import androidx.lifecycle.LifecycleOwner
51
+ import androidx.lifecycle.Observer
40
52
  import com.getcapacitor.FileUtils
41
53
  import com.getcapacitor.JSObject
42
54
  import com.getcapacitor.Plugin
55
+ import com.google.common.util.concurrent.ListenableFuture
43
56
  import com.google.mlkit.vision.barcode.BarcodeScanner
44
57
  import com.google.mlkit.vision.barcode.BarcodeScannerOptions
45
58
  import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -48,12 +61,14 @@ import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
48
61
  import com.michaelwolz.capacitorcameraview.model.CameraDevice
49
62
  import com.michaelwolz.capacitorcameraview.model.CameraResult
50
63
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
64
+ import com.michaelwolz.capacitorcameraview.model.TorchModeState
51
65
  import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
52
66
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
53
67
  import kotlinx.coroutines.CancellableContinuation
54
68
  import kotlinx.coroutines.CoroutineScope
55
69
  import kotlinx.coroutines.Dispatchers
56
70
  import kotlinx.coroutines.SupervisorJob
71
+ import kotlinx.coroutines.asCoroutineDispatcher
57
72
  import kotlinx.coroutines.cancel
58
73
  import kotlinx.coroutines.channels.BufferOverflow
59
74
  import kotlinx.coroutines.flow.MutableSharedFlow
@@ -62,7 +77,6 @@ import kotlinx.coroutines.flow.asSharedFlow
62
77
  import kotlinx.coroutines.launch
63
78
  import kotlinx.coroutines.suspendCancellableCoroutine
64
79
  import kotlinx.coroutines.withContext
65
- import java.io.ByteArrayOutputStream
66
80
  import java.io.File
67
81
  import java.io.FileOutputStream
68
82
  import java.util.concurrent.ExecutorService
@@ -70,11 +84,47 @@ import java.util.concurrent.Executors
70
84
  import java.util.concurrent.atomic.AtomicBoolean
71
85
  import java.util.concurrent.atomic.AtomicLong
72
86
  import java.util.concurrent.atomic.AtomicReference
73
- import kotlin.coroutines.resume
87
+ import kotlin.math.atan
88
+ import kotlin.math.roundToInt
74
89
 
75
90
  /** Throttle time for barcode detection in milliseconds. */
76
91
  const val BARCODE_DETECTION_THROTTLE_MS = 100L
77
92
 
93
+ /**
94
+ * Suppression window in milliseconds during which a repeat of the same barcode
95
+ * (identical value + type) is not re-emitted. A genuinely new code still emits
96
+ * immediately. Kept consistent with the iOS and web implementations.
97
+ */
98
+ const val BARCODE_SUPPRESSION_WINDOW_MS = 500L
99
+
100
+ /**
101
+ * Once the per-key dedupe map grows past this size, expired entries are pruned
102
+ * so a long session scanning many different codes stays bounded. Kept
103
+ * consistent with the iOS and web implementations.
104
+ */
105
+ const val BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD = 64
106
+
107
+ /**
108
+ * Age threshold, in milliseconds, past which a leftover `camera_capture_*`/
109
+ * `camera_recording_*` file in the cache directory is considered stale and swept on the
110
+ * next session start. Kept consistent with the iOS implementation's `TempFileManager`.
111
+ */
112
+ const val STALE_TEMP_FILE_THRESHOLD_MS = 30 * 60 * 1000L
113
+
114
+ /**
115
+ * Horizontal field-of-view threshold (degrees) at or above which a lens is classified as
116
+ * `"ultraWide"` in [CameraView.classifyLensDeviceType]. Common ultra-wide modules sit around
117
+ * 100-120°; standard wide modules are typically well under 94°.
118
+ */
119
+ const val ULTRA_WIDE_FOV_THRESHOLD_DEGREES = 94.0
120
+
121
+ /**
122
+ * Horizontal field-of-view threshold (degrees) at or below which a lens is classified as
123
+ * `"telephoto"` in [CameraView.classifyLensDeviceType]. Common telephoto modules are well
124
+ * under 60°; standard wide modules are typically well above it.
125
+ */
126
+ const val TELEPHOTO_FOV_THRESHOLD_DEGREES = 60.0
127
+
78
128
  class CameraView(plugin: Plugin) {
79
129
  // Coroutine scope for async operations
80
130
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -92,10 +142,34 @@ class CameraView(plugin: Plugin) {
92
142
  private val cameraExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
93
143
  private var previewView: PreviewView? = null
94
144
 
145
+ // The ML Kit barcode scanner currently attached to the image analysis
146
+ // analyzer, if barcode detection is enabled. Tracked here so it can be
147
+ // closed (and the analyzer cleared) on session stop/cleanup - the
148
+ // MlKitAnalyzer that wraps it does not own or close it for us.
149
+ private var barcodeScanner: BarcodeScanner? = null
150
+
95
151
  // Camera state
96
152
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
97
153
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
98
154
 
155
+ /**
156
+ * Normalized (0.0-1.0) torch intensity last successfully applied via [setTorchMode],
157
+ * reported back by [getTorchMode]. CameraX/Camera2 interop expose no getter for the
158
+ * currently active `FLASH_STRENGTH_LEVEL` capture-request option, so the level this
159
+ * plugin itself applied is tracked here instead. Reset to 0.0 whenever the torch is off.
160
+ */
161
+ private var currentTorchLevel: Float = 0.0f
162
+
163
+ /**
164
+ * The torch-strength Camera2 interop capture-request option currently applied on the
165
+ * bound camera, or `null` when the torch is off or strength isn't controllable.
166
+ *
167
+ * Persisted so [clearJpegQualityCaptureOption] - which clears ALL interop options -
168
+ * can re-apply it instead of silently resetting an active torch to default strength.
169
+ */
170
+ @OptIn(ExperimentalCamera2Interop::class)
171
+ private var torchStrengthCaptureRequestOptions: CaptureRequestOptions? = null
172
+
99
173
  // Active video recording
100
174
  private var activeRecording: Recording? = null
101
175
 
@@ -117,11 +191,30 @@ class CameraView(plugin: Plugin) {
117
191
  private var webView: WebView = plugin.bridge.webView
118
192
  private var context: Context = webView.context
119
193
 
194
+ // The WebView's appearance before the first camera session made it
195
+ // transparent. Captured once and never overwritten, so teardown restores
196
+ // the app's real appearance instead of hard-coding white/LAYER_TYPE_NONE.
197
+ //
198
+ // A ColorDrawable must be captured as its color int, not as a Drawable
199
+ // reference: setBackgroundColor() mutates an existing ColorDrawable in
200
+ // place, so a reference would alias the drawable we turn transparent.
201
+ private var originalWebViewBackgroundColor: Int? = null
202
+ private var originalWebViewBackground: Drawable? = null
203
+ private var originalWebViewLayerType: Int? = null
204
+ private var hasCapturedOriginalWebViewAppearance = false
205
+
120
206
  private val mainHandler by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
121
207
 
122
208
  // Thread-safe barcode throttle timestamp
123
209
  private val lastBarcodeDetectionTime = AtomicLong(0L)
124
210
 
211
+ // Dedupe state: timestamps of recently emitted barcodes keyed by value +
212
+ // type. A per-key map (rather than a single "last" slot) is required so
213
+ // multiple codes in frame can't alternate and defeat the suppression
214
+ // window. Accessed only on the main executor (MlKitAnalyzer callback) and
215
+ // the main thread (session teardown), so no locking is needed.
216
+ private val recentBarcodeEmitTimes = mutableMapOf<String, Long>()
217
+
125
218
  // Flow for reactive barcode events
126
219
  private val _barcodeEvents = MutableSharedFlow<BarcodeDetectionResult>(
127
220
  replay = 0,
@@ -133,57 +226,45 @@ class CameraView(plugin: Plugin) {
133
226
  /** Starts a camera session with the provided configuration. */
134
227
  suspend fun startSessionAsync(config: CameraSessionConfiguration): CameraResult<Unit> =
135
228
  withContext(Dispatchers.Main) {
229
+ if (isRunning()) {
230
+ return@withContext CameraResult.Error(CameraError.SessionAlreadyRunning())
231
+ }
232
+
136
233
  val lifecycleOwner = context as? LifecycleOwner
137
234
  ?: return@withContext CameraResult.Error(CameraError.LifecycleOwnerMissing())
138
235
 
139
236
  this@CameraView.lifecycleOwner = lifecycleOwner
140
237
 
238
+ // Sweep any stale capture/recording temp files left behind by a previous
239
+ // session that crashed or was killed before it could clean up after itself.
240
+ // Session start is the one guaranteed point where none of our own
241
+ // captures/recordings can be in flight yet, so it's safe to delete anything
242
+ // old here. Run off the main thread so this never delays session start.
243
+ scope.launch(cameraExecutor.asCoroutineDispatcher()) {
244
+ sweepStaleTempFiles()
245
+ }
246
+
141
247
  try {
142
248
  initializeCamera(context, lifecycleOwner, config)
143
249
  CameraResult.Success(Unit)
144
250
  } catch (e: Exception) {
145
251
  Log.e(TAG, "Error in camera setup", e)
252
+ // Tear down any partially initialized state so a failed start
253
+ // doesn't leave the session guard tripped or a preview attached
254
+ releaseSessionResources()
146
255
  CameraResult.Error(e)
147
256
  }
148
257
  }
149
258
 
150
- /** Starts a camera session with the provided configuration (callback version for backward compatibility). */
151
- fun startSession(config: CameraSessionConfiguration, callback: (Exception?) -> Unit) {
152
- scope.launch {
153
- startSessionAsync(config).fold(
154
- onSuccess = { callback(null) },
155
- onError = { callback(it) }
156
- )
157
- }
158
- }
159
-
160
259
  /** Stop the camera session and release resources. */
161
260
  suspend fun stopSessionAsync(): CameraResult<Unit> = withContext(Dispatchers.Main) {
162
261
  try {
163
262
  // Stop any active recording before unbinding
164
263
  activeRecording?.stop()
165
264
  activeRecording = null
166
- pendingStopCallback?.invoke(
167
- CameraResult.Error(Exception("Recording was interrupted because the camera session stopped"))
168
- )
169
- pendingStopCallback = null
170
- currentRecordingFile = null
265
+ failPendingRecording("Recording was interrupted because the camera session stopped")
171
266
 
172
- cameraController?.unbind()
173
- cameraController = null
174
-
175
- previewView?.let { view ->
176
- try {
177
- (webView.parent as? ViewGroup)?.removeView(view)
178
- } catch (e: Exception) {
179
- Log.e(TAG, "Error removing preview view", e)
180
- } finally {
181
- previewView = null
182
- }
183
- }
184
-
185
- webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
186
- webView.setBackgroundColor(android.graphics.Color.WHITE)
267
+ releaseSessionResources()
187
268
 
188
269
  Log.d(TAG, "Camera session stopped successfully")
189
270
  CameraResult.Success(Unit)
@@ -193,13 +274,113 @@ class CameraView(plugin: Plugin) {
193
274
  }
194
275
  }
195
276
 
196
- /** Stop the camera session and release resources (callback version for backward compatibility). */
197
- fun stopSession(callback: ((Exception?) -> Unit)? = null) {
198
- scope.launch {
199
- stopSessionAsync().fold(
200
- onSuccess = { callback?.invoke(null) },
201
- onError = { callback?.invoke(it) }
202
- )
277
+ /**
278
+ * Releases the camera controller and preview view and restores the WebView.
279
+ *
280
+ * Must be called on the main thread. Used both for a regular [stopSessionAsync]
281
+ * and to tear down partially initialized state when [startSessionAsync] fails.
282
+ */
283
+ private fun releaseSessionResources() {
284
+ closeBarcodeScanner()
285
+
286
+ cameraController?.unbind()
287
+ cameraController = null
288
+
289
+ // Reset barcode dedupe state so a restarted session emits immediately
290
+ recentBarcodeEmitTimes.clear()
291
+
292
+ previewView?.let { view ->
293
+ try {
294
+ (webView.parent as? ViewGroup)?.removeView(view)
295
+ } catch (e: Exception) {
296
+ Log.e(TAG, "Error removing preview view", e)
297
+ } finally {
298
+ previewView = null
299
+ }
300
+ }
301
+
302
+ restoreWebViewAppearance()
303
+ }
304
+
305
+ /**
306
+ * Restores the WebView's background and layer type to what they were before
307
+ * [setupPreviewView] first made it transparent. No-op if a session was never
308
+ * started, since nothing was ever changed.
309
+ */
310
+ private fun restoreWebViewAppearance() {
311
+ if (!hasCapturedOriginalWebViewAppearance) return
312
+
313
+ webView.setLayerType(originalWebViewLayerType ?: WebView.LAYER_TYPE_NONE, null)
314
+
315
+ val originalColor = originalWebViewBackgroundColor
316
+ if (originalColor != null) {
317
+ webView.setBackgroundColor(originalColor)
318
+ } else {
319
+ webView.background = originalWebViewBackground
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Clears the image-analysis analyzer from the camera controller (if any) and closes
325
+ * the ML Kit barcode scanner, releasing its native resources. The [MlKitAnalyzer]
326
+ * wrapping the scanner does not own or close it, so this must be done explicitly
327
+ * whenever a session with barcode detection enabled is torn down.
328
+ */
329
+ private fun closeBarcodeScanner() {
330
+ if (barcodeScanner == null) return
331
+
332
+ cameraController?.clearImageAnalysisAnalyzer()
333
+ barcodeScanner?.close()
334
+ barcodeScanner = null
335
+ }
336
+
337
+ /**
338
+ * Fails any pending stop-recording callback with [message] and deletes the
339
+ * partially-written recording file (if any), so an interrupted recording neither
340
+ * leaves its caller awaiting forever nor leaks an orphaned temp file.
341
+ *
342
+ * Used both by [stopSessionAsync] (session stopped mid-recording) and [cleanup]
343
+ * (plugin torn down mid-recording).
344
+ */
345
+ private fun failPendingRecording(message: String) {
346
+ pendingStopCallback?.invoke(CameraResult.Error(Exception(message)))
347
+ pendingStopCallback = null
348
+
349
+ currentRecordingFile?.let { deleteFileQuietly(it) }
350
+ currentRecordingFile = null
351
+ }
352
+
353
+ /** Deletes [file] if it exists, logging (but never throwing) on failure. */
354
+ private fun deleteFileQuietly(file: File) {
355
+ try {
356
+ if (file.exists() && !file.delete()) {
357
+ Log.w(TAG, "Failed to delete temp file: ${file.name}")
358
+ }
359
+ } catch (e: Exception) {
360
+ Log.w(TAG, "Error deleting temp file: ${file.name}", e)
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Deletes stale `camera_capture_*`/`camera_recording_*` files left in [Context.cacheDir]
366
+ * by a previous session that crashed or was killed before it could clean up after
367
+ * itself. Only files older than [STALE_TEMP_FILE_THRESHOLD_MS] are removed, so a file
368
+ * from a capture/recording that is genuinely still in flight is never touched.
369
+ *
370
+ * Performs file I/O and must not be called on the main thread.
371
+ */
372
+ private fun sweepStaleTempFiles() {
373
+ try {
374
+ val cutoff = System.currentTimeMillis() - STALE_TEMP_FILE_THRESHOLD_MS
375
+ context.cacheDir?.listFiles()?.forEach { file ->
376
+ val isCameraTempFile = file.isFile &&
377
+ (file.name.startsWith("camera_capture_") || file.name.startsWith("camera_recording_"))
378
+ if (isCameraTempFile && file.lastModified() < cutoff) {
379
+ deleteFileQuietly(file)
380
+ }
381
+ }
382
+ } catch (e: Exception) {
383
+ Log.w(TAG, "Error sweeping stale temp files", e)
203
384
  }
204
385
  }
205
386
 
@@ -217,26 +398,22 @@ class CameraView(plugin: Plugin) {
217
398
 
218
399
  val controller = cameraController
219
400
  if (controller == null) {
220
- continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
401
+ continuation.resumeIfActive(CameraResult.Error(CameraError.CameraNotInitialized()))
221
402
  return@suspendCancellableCoroutine
222
403
  }
223
404
 
224
405
  val preview = previewView
225
406
  if (preview == null) {
226
- continuation.resume(CameraResult.Error(CameraError.PreviewNotInitialized()))
407
+ continuation.resumeIfActive(CameraResult.Error(CameraError.PreviewNotInitialized()))
227
408
  return@suspendCancellableCoroutine
228
409
  }
229
410
 
230
411
  mainHandler.post {
231
- val cameraInfo = controller.cameraInfo
232
- val isFrontFacing = controller.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA
233
- val sensorRotationDegrees = cameraInfo?.sensorRotationDegrees ?: 0
412
+ // Derive facing from the bound camera's lens facing rather than comparing the
413
+ // selector against DEFAULT_FRONT_CAMERA, which is always false for a selector
414
+ // built from a deviceId even when it resolves to the front camera.
415
+ val isFrontFacing = isLensFacingFront(controller.cameraInfo?.lensFacing)
234
416
  val displayRotationDegrees = preview.display?.rotation ?: Surface.ROTATION_0
235
- val imageRotationDegrees = calculateImageRotationBasedOnDisplayRotation(
236
- displayRotationDegrees,
237
- sensorRotationDegrees,
238
- isFrontFacing
239
- )
240
417
 
241
418
  try {
242
419
  if (saveToFile) {
@@ -245,32 +422,48 @@ class CameraView(plugin: Plugin) {
245
422
  File.createTempFile("camera_capture_photo", ".jpg", context.cacheDir)
246
423
  val outputFileOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
247
424
 
248
- controller.takePicture(
249
- outputFileOptions,
250
- cameraExecutor,
251
- object : ImageCapture.OnImageSavedCallback {
252
- override fun onImageSaved(output: ImageCapture.OutputFileResults) {
253
- val processingTime = System.currentTimeMillis() - startTime
254
- Log.d(TAG, "Image saved directly to file in ${processingTime}ms")
255
-
256
- val result = JSObject().apply {
257
- val capacitorFilePath = FileUtils.getPortablePath(
258
- context,
259
- pluginDelegate.bridge.localUrl,
260
- Uri.fromFile(tempFile)
261
- )
262
-
263
- put("webPath", capacitorFilePath)
264
- }
265
- continuation.resume(CameraResult.Success(result))
266
- }
267
-
268
- override fun onError(exception: ImageCaptureException) {
269
- Log.e(TAG, "Error saving image to file", exception)
270
- continuation.resume(CameraResult.Error(exception))
425
+ // CameraController has no JPEG-quality setter, so quality is applied via
426
+ // Camera2 interop on the capture request. Applying it is asynchronous, so
427
+ // the capture is chained on the returned future. If interop isn't usable,
428
+ // fall back to re-compressing the saved file.
429
+ val qualityOptionFuture = tryApplyJpegQualityCaptureOption(controller, quality)
430
+
431
+ if (qualityOptionFuture == null) {
432
+ // Interop unavailable - capture now, re-compress the file afterwards.
433
+ takePictureToFile(
434
+ controller,
435
+ outputFileOptions,
436
+ tempFile,
437
+ quality,
438
+ recompressAtQuality = true,
439
+ startTime,
440
+ continuation
441
+ )
442
+ } else {
443
+ qualityOptionFuture.addListener({
444
+ val interopApplied = try {
445
+ qualityOptionFuture.get()
446
+ true
447
+ } catch (e: Exception) {
448
+ Log.w(
449
+ TAG,
450
+ "JPEG quality capture option was not applied; " +
451
+ "falling back to re-compression",
452
+ e
453
+ )
454
+ false
271
455
  }
272
- }
273
- )
456
+ takePictureToFile(
457
+ controller,
458
+ outputFileOptions,
459
+ tempFile,
460
+ quality,
461
+ recompressAtQuality = !interopApplied,
462
+ startTime,
463
+ continuation
464
+ )
465
+ }, ContextCompat.getMainExecutor(context))
466
+ }
274
467
  } else {
275
468
  // Base64 capture using ImageProxy
276
469
  controller.takePicture(
@@ -282,6 +475,13 @@ class CameraView(plugin: Plugin) {
282
475
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
283
476
  )
284
477
  try {
478
+ // Base the rotation on the frame's own rotationDegrees so
479
+ // pre-rotated buffers are not rotated a second time.
480
+ val imageRotationDegrees = calculateImageRotation(
481
+ image.imageInfo.rotationDegrees,
482
+ displayRotationDegrees,
483
+ isFrontFacing
484
+ )
285
485
  val base64String =
286
486
  imageProxyToBase64(image, quality, imageRotationDegrees)
287
487
  val result = JSObject().apply {
@@ -291,10 +491,10 @@ class CameraView(plugin: Plugin) {
291
491
  TAG,
292
492
  "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
293
493
  )
294
- continuation.resume(CameraResult.Success(result))
494
+ continuation.resumeIfActive(CameraResult.Success(result))
295
495
  } catch (e: Exception) {
296
496
  Log.e(TAG, "Error processing captured image", e)
297
- continuation.resume(CameraResult.Error(e))
497
+ continuation.resumeIfActive(CameraResult.Error(e))
298
498
  } finally {
299
499
  image.close()
300
500
  }
@@ -302,91 +502,207 @@ class CameraView(plugin: Plugin) {
302
502
 
303
503
  override fun onError(exception: ImageCaptureException) {
304
504
  Log.e(TAG, "Error capturing image", exception)
305
- continuation.resume(CameraResult.Error(exception))
505
+ continuation.resumeIfActive(CameraResult.Error(exception))
306
506
  }
307
507
  }
308
508
  )
309
509
  }
310
510
  } catch (e: Exception) {
311
511
  Log.e(TAG, "Error setting up image capture", e)
312
- continuation.resume(CameraResult.Error(e))
512
+ continuation.resumeIfActive(CameraResult.Error(e))
313
513
  }
314
514
  }
315
515
  }
316
516
 
317
- /** Capture a photo with the current camera configuration (callback version for backward compatibility). */
318
- fun capturePhoto(
319
- quality: Int,
320
- saveToFile: Boolean = false,
321
- callback: (JSObject?, Exception?) -> Unit
322
- ) {
323
- scope.launch {
324
- capturePhotoAsync(quality, saveToFile).fold(
325
- onSuccess = { callback(it, null) },
326
- onError = { callback(null, it) }
327
- )
517
+ /**
518
+ * Attempts to apply the requested JPEG quality to the still-capture pipeline via the
519
+ * Camera2 `JPEG_QUALITY` capture-request option. Returns the future that completes once
520
+ * the option has been submitted to the capture session, or `null` if interop isn't
521
+ * usable at all (e.g. the camera control has no Camera2 implementation).
522
+ *
523
+ * Options set this way are submitted with every capture request for this camera, so
524
+ * callers must clear them again via [clearJpegQualityCaptureOption] once the capture
525
+ * completes, and must await the returned future before triggering the capture.
526
+ *
527
+ * Uses `addCaptureRequestOptions()` rather than `setCaptureRequestOptions()` so an
528
+ * active [torchStrengthCaptureRequestOptions] is preserved.
529
+ */
530
+ @OptIn(ExperimentalCamera2Interop::class)
531
+ private fun tryApplyJpegQualityCaptureOption(
532
+ controller: LifecycleCameraController,
533
+ quality: Int
534
+ ): ListenableFuture<Void>? {
535
+ return try {
536
+ val cameraControl = controller.cameraControl ?: return null
537
+ val options = CaptureRequestOptions.Builder()
538
+ .setCaptureRequestOption(CaptureRequest.JPEG_QUALITY, quality.coerceIn(1, 100).toByte())
539
+ .build()
540
+ Camera2CameraControl.from(cameraControl).addCaptureRequestOptions(options)
541
+ } catch (e: Exception) {
542
+ Log.w(TAG, "Camera2 interop unavailable for JPEG quality; falling back to re-compression", e)
543
+ null
328
544
  }
329
545
  }
330
546
 
331
547
  /**
332
- * Capture a frame directly from the preview without using the full photo pipeline.
333
- * Faster but has lower quality than full photo capture.
548
+ * Clears any JPEG-quality Camera2 interop option previously set by
549
+ * [tryApplyJpegQualityCaptureOption].
550
+ *
551
+ * `clearCaptureRequestOptions()` clears ALL interop options on this camera, including an
552
+ * active [torchStrengthCaptureRequestOptions], so that one is re-applied right after.
334
553
  */
335
- suspend fun captureSampleFromPreviewAsync(
336
- quality: Int,
337
- saveToFile: Boolean = false
338
- ): CameraResult<JSObject> = withContext(Dispatchers.Main) {
339
- val preview = previewView
340
- ?: return@withContext CameraResult.Error(CameraError.PreviewNotInitialized())
554
+ @OptIn(ExperimentalCamera2Interop::class)
555
+ private fun clearJpegQualityCaptureOption(controller: LifecycleCameraController) {
556
+ try {
557
+ val cameraControl = controller.cameraControl ?: return
558
+ val camera2CameraControl = Camera2CameraControl.from(cameraControl)
559
+ camera2CameraControl.clearCaptureRequestOptions()
341
560
 
561
+ torchStrengthCaptureRequestOptions?.let { options ->
562
+ camera2CameraControl.addCaptureRequestOptions(options)
563
+ }
564
+ } catch (e: Exception) {
565
+ Log.w(TAG, "Failed to clear JPEG quality Camera2 interop option", e)
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Takes a picture into [tempFile] and resumes [continuation] with the file paths.
571
+ * Must be called on the main thread.
572
+ *
573
+ * If [recompressAtQuality] is true (the Camera2 interop JPEG-quality option could not
574
+ * be applied), the saved file is re-compressed in place at [quality] before resuming,
575
+ * so the requested quality is honored either way. Exactly one resume is guaranteed:
576
+ * either via the capture callbacks or via the synchronous catch below (in which case
577
+ * no callback will fire, since `takePicture` failed before being registered).
578
+ */
579
+ private fun takePictureToFile(
580
+ controller: LifecycleCameraController,
581
+ outputFileOptions: ImageCapture.OutputFileOptions,
582
+ tempFile: File,
583
+ quality: Int,
584
+ recompressAtQuality: Boolean,
585
+ startTime: Long,
586
+ continuation: CancellableContinuation<CameraResult<JSObject>>
587
+ ) {
342
588
  try {
343
- val bitmap = preview.bitmap
344
- ?: return@withContext CameraResult.Error(Exception("Preview bitmap not available"))
589
+ controller.takePicture(
590
+ outputFileOptions,
591
+ cameraExecutor,
592
+ object : ImageCapture.OnImageSavedCallback {
593
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
594
+ clearJpegQualityCaptureOption(controller)
595
+ val processingTime = System.currentTimeMillis() - startTime
596
+ Log.d(TAG, "Image saved directly to file in ${processingTime}ms")
597
+
598
+ try {
599
+ if (recompressAtQuality) {
600
+ recompressFileInPlace(tempFile, quality)
601
+ }
345
602
 
346
- val result = JSObject()
603
+ val result = JSObject().apply {
604
+ val capacitorFilePath = FileUtils.getPortablePath(
605
+ context,
606
+ pluginDelegate.bridge.localUrl,
607
+ Uri.fromFile(tempFile)
608
+ )
347
609
 
348
- if (saveToFile) {
349
- val tempFile =
350
- File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
610
+ put("path", Uri.fromFile(tempFile).toString())
611
+ put("webPath", capacitorFilePath)
612
+ }
613
+ continuation.resumeIfActive(CameraResult.Success(result))
614
+ } catch (e: Exception) {
615
+ Log.e(TAG, "Error re-compressing captured file", e)
616
+ continuation.resumeIfActive(CameraResult.Error(e))
617
+ }
618
+ }
351
619
 
352
- FileOutputStream(tempFile).use { outputStream ->
353
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
620
+ override fun onError(exception: ImageCaptureException) {
621
+ clearJpegQualityCaptureOption(controller)
622
+ Log.e(TAG, "Error saving image to file", exception)
623
+ continuation.resumeIfActive(CameraResult.Error(exception))
624
+ }
354
625
  }
626
+ )
627
+ } catch (e: Exception) {
628
+ clearJpegQualityCaptureOption(controller)
629
+ Log.e(TAG, "Error setting up image capture", e)
630
+ continuation.resumeIfActive(CameraResult.Error(e))
631
+ }
632
+ }
355
633
 
356
- val capacitorFilePath = FileUtils.getPortablePath(
357
- context,
358
- pluginDelegate.bridge.localUrl,
359
- Uri.fromFile(tempFile)
360
- )
361
-
362
- result.put("webPath", capacitorFilePath)
363
- } else {
364
- // Convert bitmap to Base64 using pooled stream
365
- val base64String = bitmapToBase64(bitmap, quality)
366
- result.put("photo", base64String)
634
+ /**
635
+ * Re-compresses a JPEG file in place at the given quality. Used as a fallback for the
636
+ * file-based capture path when Camera2 interop isn't available to honor `quality` at
637
+ * capture time.
638
+ */
639
+ private fun recompressFileInPlace(file: File, quality: Int) {
640
+ val bitmap = BitmapFactory.decodeFile(file.absolutePath)
641
+ ?: throw IllegalStateException("Failed to decode captured file for re-compression")
642
+ try {
643
+ FileOutputStream(file).use { outputStream ->
644
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
367
645
  }
368
-
369
- CameraResult.Success(result)
370
- } catch (e: Exception) {
371
- Log.e(TAG, "Error capturing preview frame", e)
372
- CameraResult.Error(e)
646
+ } finally {
647
+ bitmap.recycle()
373
648
  }
374
649
  }
375
650
 
376
651
  /**
377
- * Capture a frame directly from the preview without using the full photo pipeline (callback version).
652
+ * Capture a frame directly from the preview without using the full photo pipeline.
378
653
  * Faster but has lower quality than full photo capture.
379
654
  */
380
- fun captureSampleFromPreview(
655
+ suspend fun captureSampleFromPreviewAsync(
381
656
  quality: Int,
382
- saveToFile: Boolean = false,
383
- callback: (JSObject?, Exception?) -> Unit
384
- ) {
385
- scope.launch {
386
- captureSampleFromPreviewAsync(quality, saveToFile).fold(
387
- onSuccess = { callback(it, null) },
388
- onError = { callback(null, it) }
389
- )
657
+ saveToFile: Boolean = false
658
+ ): CameraResult<JSObject> {
659
+ val bitmapResult: CameraResult<Bitmap> = withContext(Dispatchers.Main) {
660
+ val preview = previewView
661
+ ?: return@withContext CameraResult.Error(CameraError.PreviewNotInitialized())
662
+ preview.bitmap
663
+ ?.let { CameraResult.Success(it) }
664
+ ?: CameraResult.Error(Exception("Preview bitmap not available"))
665
+ }
666
+
667
+ val bitmap = when (bitmapResult) {
668
+ is CameraResult.Error -> return bitmapResult
669
+ is CameraResult.Success -> bitmapResult.value
670
+ }
671
+
672
+ // Compression, encoding and file IO are CPU/IO-bound and must not block the main thread.
673
+ return withContext(cameraExecutor.asCoroutineDispatcher()) {
674
+ try {
675
+ val result = JSObject()
676
+
677
+ if (saveToFile) {
678
+ val tempFile =
679
+ File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
680
+
681
+ FileOutputStream(tempFile).use { outputStream ->
682
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
683
+ }
684
+
685
+ val capacitorFilePath = FileUtils.getPortablePath(
686
+ context,
687
+ pluginDelegate.bridge.localUrl,
688
+ Uri.fromFile(tempFile)
689
+ )
690
+
691
+ result.put("path", Uri.fromFile(tempFile).toString())
692
+ result.put("webPath", capacitorFilePath)
693
+ } else {
694
+ // Convert bitmap to Base64 using the shared pooled streaming encoder
695
+ // (also used by imageProxyToBase64) rather than a separate,
696
+ // non-pooled duplicate.
697
+ val base64String = StreamingBase64Encoder.encodeToBase64(bitmap, quality)
698
+ result.put("photo", base64String)
699
+ }
700
+
701
+ CameraResult.Success(result)
702
+ } catch (e: Exception) {
703
+ Log.e(TAG, "Error capturing preview frame", e)
704
+ CameraResult.Error(e)
705
+ }
390
706
  }
391
707
  }
392
708
 
@@ -419,17 +735,25 @@ class CameraView(plugin: Plugin) {
419
735
  )
420
736
 
421
737
  val outputOptions = createRecordingOutputOptions()
422
- val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
738
+ val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: run {
739
+ // Recording never actually started - the just-created output file is
740
+ // empty and unused, so it must be removed here rather than left for
741
+ // the next session's stale-file sweep to find.
742
+ discardPendingRecordingFile()
743
+ return
744
+ }
423
745
 
424
746
  startCameraRecording(controller, outputOptions, audioConfig, continuation)
425
747
  } catch (e: SecurityException) {
426
748
  Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
427
749
  restoreUseCasesAfterRecording()
428
- continuation.resume(CameraResult.Error(e))
750
+ discardPendingRecordingFile()
751
+ continuation.resumeIfActive(CameraResult.Error(e))
429
752
  } catch (e: Exception) {
430
753
  Log.e(TAG, "Error starting recording", e)
431
754
  restoreUseCasesAfterRecording()
432
- continuation.resume(CameraResult.Error(e))
755
+ discardPendingRecordingFile()
756
+ continuation.resumeIfActive(CameraResult.Error(e))
433
757
  }
434
758
  }
435
759
 
@@ -438,12 +762,12 @@ class CameraView(plugin: Plugin) {
438
762
  ): LifecycleCameraController? {
439
763
  val controller = cameraController
440
764
  if (controller == null) {
441
- continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
765
+ continuation.resumeIfActive(CameraResult.Error(CameraError.CameraNotInitialized()))
442
766
  return null
443
767
  }
444
768
 
445
769
  if (activeRecording != null) {
446
- continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
770
+ continuation.resumeIfActive(CameraResult.Error(CameraError.RecordingAlreadyInProgress()))
447
771
  return null
448
772
  }
449
773
 
@@ -460,6 +784,16 @@ class CameraView(plugin: Plugin) {
460
784
  return FileOutputOptions.Builder(tempFile).build()
461
785
  }
462
786
 
787
+ /**
788
+ * Deletes the just-created (but not yet recorded-into) output file tracked by
789
+ * [currentRecordingFile] and clears the reference. Used when recording fails to start
790
+ * after [createRecordingOutputOptions] has already created the file.
791
+ */
792
+ private fun discardPendingRecordingFile() {
793
+ currentRecordingFile?.let { deleteFileQuietly(it) }
794
+ currentRecordingFile = null
795
+ }
796
+
463
797
  private fun resolveAudioConfig(
464
798
  enableAudio: Boolean,
465
799
  continuation: CancellableContinuation<CameraResult<Unit>>
@@ -472,12 +806,12 @@ class CameraView(plugin: Plugin) {
472
806
  return try {
473
807
  AudioConfig.create(true)
474
808
  } catch (e: SecurityException) {
475
- continuation.resume(CameraResult.Error(e))
809
+ continuation.resumeIfActive(CameraResult.Error(e))
476
810
  null
477
811
  }
478
812
  }
479
813
 
480
- continuation.resume(
814
+ continuation.resumeIfActive(
481
815
  CameraResult.Error(
482
816
  SecurityException("Microphone permission is required for audio recording")
483
817
  )
@@ -520,8 +854,8 @@ class CameraView(plugin: Plugin) {
520
854
  continuation: CancellableContinuation<CameraResult<Unit>>
521
855
  ) {
522
856
  Log.d(TAG, "Video recording started")
523
- if (continuation.isActive && startResumed.compareAndSet(false, true)) {
524
- continuation.resume(CameraResult.Success(Unit))
857
+ if (startResumed.compareAndSet(false, true)) {
858
+ continuation.resumeIfActive(CameraResult.Success(Unit))
525
859
  }
526
860
  }
527
861
 
@@ -532,8 +866,8 @@ class CameraView(plugin: Plugin) {
532
866
  ) {
533
867
  // If recording finalized before Start was emitted, resume the
534
868
  // startRecording continuation with an error
535
- if (continuation.isActive && startResumed.compareAndSet(false, true)) {
536
- continuation.resume(
869
+ if (startResumed.compareAndSet(false, true)) {
870
+ continuation.resumeIfActive(
537
871
  CameraResult.Error(
538
872
  Exception("Recording failed to start: error code ${event.error}")
539
873
  )
@@ -555,6 +889,10 @@ class CameraView(plugin: Plugin) {
555
889
 
556
890
  if (event.hasError()) {
557
891
  Log.e(TAG, "Recording error: ${event.error}")
892
+ // The recording failed partway through, so the file on disk is
893
+ // incomplete/corrupt - delete it rather than leaving it for the next
894
+ // session's stale-file sweep to find.
895
+ currentRecordingFile?.let { deleteFileQuietly(it) }
558
896
  currentRecordingFile = null
559
897
  callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
560
898
  return@post
@@ -573,6 +911,7 @@ class CameraView(plugin: Plugin) {
573
911
  Uri.fromFile(file)
574
912
  )
575
913
  val result = JSObject().apply {
914
+ put("path", Uri.fromFile(file).toString())
576
915
  put("webPath", capacitorFilePath)
577
916
  }
578
917
  callback?.invoke(CameraResult.Success(result))
@@ -634,12 +973,17 @@ class CameraView(plugin: Plugin) {
634
973
  mainHandler.post {
635
974
  val recording = activeRecording
636
975
  if (recording == null) {
637
- continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
976
+ continuation.resumeIfActive(CameraResult.Error(Exception("No recording is in progress")))
638
977
  return@post
639
978
  }
640
979
 
980
+ // Delivered later from Finalize (finalizeRecordingAndNotifyStopCallback) or,
981
+ // if the session/plugin tears down first, from failPendingRecording. Guard
982
+ // with resumeIfActive: if this coroutine was itself cancelled in the
983
+ // meantime, the recording still needs to run to completion (it isn't
984
+ // stoppable from here), but nobody is left to observe the outcome.
641
985
  pendingStopCallback = { result ->
642
- continuation.resume(result)
986
+ continuation.resumeIfActive(result)
643
987
  }
644
988
 
645
989
  activeRecording = null
@@ -647,13 +991,17 @@ class CameraView(plugin: Plugin) {
647
991
  }
648
992
  }
649
993
 
650
- /** Flip between front and back cameras */
994
+ /**
995
+ * Flip between front and back cameras.
996
+ *
997
+ * Rejects while a recording is active: reassigning the controller's `cameraSelector`
998
+ * unbinds and rebinds every use case (including the active `VideoCapture`), which
999
+ * interrupts the in-progress recording. The check runs on [mainHandler], the only
1000
+ * thread [activeRecording] is mutated on, so it can't race a concurrent recording.
1001
+ */
651
1002
  fun flipCamera(callback: (Exception?) -> Unit) {
652
- currentCameraSelector = when (currentCameraSelector) {
653
- CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
654
- else -> CameraSelector.DEFAULT_FRONT_CAMERA
655
- }
656
-
1003
+ // Validate controller state before mutating any stored selector state, so a failed
1004
+ // flip (no active session) leaves currentCameraSelector untouched for the next start.
657
1005
  val controller = cameraController
658
1006
  ?: run {
659
1007
  callback(CameraError.CameraNotInitialized())
@@ -661,7 +1009,25 @@ class CameraView(plugin: Plugin) {
661
1009
  }
662
1010
 
663
1011
  mainHandler.post {
664
- controller.cameraSelector = currentCameraSelector
1012
+ if (activeRecording != null) {
1013
+ callback(CameraError.RecordingAlreadyInProgress())
1014
+ return@post
1015
+ }
1016
+
1017
+ // Derive the current facing from the bound camera rather than the stored selector.
1018
+ // A deviceId-based selector never equals DEFAULT_FRONT/BACK_CAMERA, so toggling on
1019
+ // the selector would flip any deviceId camera to front regardless of which way it
1020
+ // physically points. Flipping abandons the deviceId in favour of the default
1021
+ // camera of the physically opposite facing.
1022
+ val currentlyFront = isLensFacingFront(controller.cameraInfo?.lensFacing)
1023
+ val newSelector = if (currentlyFront) {
1024
+ CameraSelector.DEFAULT_BACK_CAMERA
1025
+ } else {
1026
+ CameraSelector.DEFAULT_FRONT_CAMERA
1027
+ }
1028
+
1029
+ controller.cameraSelector = newSelector
1030
+ currentCameraSelector = newSelector
665
1031
  callback(null)
666
1032
  }
667
1033
  }
@@ -706,6 +1072,88 @@ class CameraView(plugin: Plugin) {
706
1072
  }
707
1073
  }
708
1074
 
1075
+ /**
1076
+ * Focus and meter the camera at a point given in CSS/viewport pixels
1077
+ * (tap-to-focus).
1078
+ *
1079
+ * The coordinates are in the same space the plugin emits for barcode
1080
+ * `boundingRect`, so that mapping is inverted here to build a metering point
1081
+ * in the PreviewView's coordinate system.
1082
+ *
1083
+ * A combined AF + AE [FocusMeteringAction] is used with CameraX's default
1084
+ * auto-cancel, so continuous auto-focus resumes automatically. Fixed-focus
1085
+ * cameras degrade to exposure-only metering; if neither is supported the
1086
+ * callback receives [CameraError.FocusNotSupported].
1087
+ */
1088
+ fun setFocusPoint(x: Float, y: Float, callback: ((Exception?) -> Unit)? = null) {
1089
+ mainHandler.post {
1090
+ val controller = cameraController
1091
+ ?: run {
1092
+ callback?.invoke(CameraError.CameraNotInitialized())
1093
+ return@post
1094
+ }
1095
+
1096
+ val preview = previewView
1097
+ ?: run {
1098
+ callback?.invoke(CameraError.PreviewNotInitialized())
1099
+ return@post
1100
+ }
1101
+
1102
+ val cameraControl = controller.cameraControl
1103
+ ?: run {
1104
+ callback?.invoke(CameraError.CameraNotInitialized())
1105
+ return@post
1106
+ }
1107
+
1108
+ val cameraInfo = controller.cameraInfo
1109
+ ?: run {
1110
+ callback?.invoke(CameraError.CameraNotInitialized())
1111
+ return@post
1112
+ }
1113
+
1114
+ // Invert the barcode boundingRect mapping: CSS px -> PreviewView px.
1115
+ val density = preview.context.resources.displayMetrics.density
1116
+ val topOffset = calculateTopOffset(webView)
1117
+ val previewX = x * density
1118
+ val previewY = y * density + topOffset
1119
+
1120
+ val meteringPoint = preview.meteringPointFactory.createPoint(previewX, previewY)
1121
+
1122
+ // Prefer combined focus + exposure metering; degrade to
1123
+ // exposure-only on fixed-focus cameras.
1124
+ var action = FocusMeteringAction.Builder(
1125
+ meteringPoint,
1126
+ FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
1127
+ ).build()
1128
+
1129
+ if (!cameraInfo.isFocusMeteringSupported(action)) {
1130
+ action = FocusMeteringAction.Builder(
1131
+ meteringPoint,
1132
+ FocusMeteringAction.FLAG_AE
1133
+ ).build()
1134
+
1135
+ if (!cameraInfo.isFocusMeteringSupported(action)) {
1136
+ callback?.invoke(CameraError.FocusNotSupported())
1137
+ return@post
1138
+ }
1139
+ }
1140
+
1141
+ val focusFuture = cameraControl.startFocusAndMetering(action)
1142
+ focusFuture.addListener(
1143
+ {
1144
+ try {
1145
+ focusFuture.get()
1146
+ callback?.invoke(null)
1147
+ } catch (e: Exception) {
1148
+ Log.e(TAG, "Failed to set focus point", e)
1149
+ callback?.invoke(Exception(e.message))
1150
+ }
1151
+ },
1152
+ ContextCompat.getMainExecutor(context)
1153
+ )
1154
+ }
1155
+ }
1156
+
709
1157
  /** Get the current flash mode */
710
1158
  fun getFlashMode(): String {
711
1159
  return when (currentFlashMode) {
@@ -734,11 +1182,18 @@ class CameraView(plugin: Plugin) {
734
1182
  }
735
1183
  }
736
1184
 
737
- /** Set the flash mode */
738
- fun setFlashMode(mode: String) {
739
- val controller =
740
- this.cameraController
741
- ?: run { throw Exception("Camera controller not initialized") }
1185
+ /**
1186
+ * Set the flash mode. [callback] is invoked (with `null` on success) only after the mode
1187
+ * has actually been applied to the controller on the main handler, rather than
1188
+ * immediately after posting the change - and with the typed [CameraError.CameraNotInitialized]
1189
+ * rather than a raw `Exception` when there is no active session.
1190
+ */
1191
+ fun setFlashMode(mode: String, callback: (Exception?) -> Unit) {
1192
+ val controller = this.cameraController
1193
+ ?: run {
1194
+ callback(CameraError.CameraNotInitialized())
1195
+ return
1196
+ }
742
1197
 
743
1198
  currentFlashMode =
744
1199
  when (mode) {
@@ -747,7 +1202,10 @@ class CameraView(plugin: Plugin) {
747
1202
  else -> ImageCapture.FLASH_MODE_OFF
748
1203
  }
749
1204
 
750
- mainHandler.post { controller.imageCaptureFlashMode = currentFlashMode }
1205
+ mainHandler.post {
1206
+ controller.imageCaptureFlashMode = currentFlashMode
1207
+ callback(null)
1208
+ }
751
1209
  }
752
1210
 
753
1211
  /** Check if torch is available */
@@ -763,21 +1221,35 @@ class CameraView(plugin: Plugin) {
763
1221
  }
764
1222
  }
765
1223
 
766
- /** Get the current torch mode */
767
- fun getTorchMode(callback: (Boolean) -> Unit) {
1224
+ /** Get the current torch mode and intensity level. */
1225
+ fun getTorchMode(callback: (TorchModeState) -> Unit) {
768
1226
  mainHandler.post {
769
1227
  val cameraInfo = cameraController?.cameraInfo
770
1228
  ?: run {
771
- callback(false)
1229
+ callback(TorchModeState(enabled = false, level = 0.0f))
772
1230
  return@post
773
1231
  }
774
1232
 
775
- callback(cameraInfo.torchState.value == TorchState.ON)
1233
+ val enabled = cameraInfo.torchState.value == TorchState.ON
1234
+ callback(TorchModeState(enabled = enabled, level = if (enabled) currentTorchLevel else 0.0f))
776
1235
  }
777
1236
  }
778
1237
 
779
- /** Set the torch mode */
780
- fun setTorchMode(enabled: Boolean, callback: ((Exception?) -> Unit)? = null) {
1238
+ /**
1239
+ * Sets the torch mode and, on API 33+ multi-level hardware, its intensity.
1240
+ *
1241
+ * [level] is normalized 0.0-1.0 and mapped onto the device's
1242
+ * [CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL] range via the Camera2 interop
1243
+ * `CaptureRequest.FLASH_STRENGTH_LEVEL` option, applied to the active CameraX session.
1244
+ * `CameraManager.turnOnTorchWithStrengthLevel` is intentionally not used: it throws
1245
+ * `CAMERA_IN_USE` while a CameraX session has the camera device open, which is always
1246
+ * the case here.
1247
+ *
1248
+ * Below API 33 or on single-level hardware [level] is ignored and the torch is simply
1249
+ * switched on/off via [androidx.camera.core.CameraControl.enableTorch].
1250
+ */
1251
+ @OptIn(ExperimentalCamera2Interop::class)
1252
+ fun setTorchMode(enabled: Boolean, level: Float? = null, callback: ((Exception?) -> Unit)? = null) {
781
1253
  mainHandler.post {
782
1254
  try {
783
1255
  val controller = cameraController
@@ -793,13 +1265,121 @@ class CameraView(plugin: Plugin) {
793
1265
  }
794
1266
 
795
1267
  controller.cameraControl?.enableTorch(enabled)
796
- callback?.invoke(null)
1268
+
1269
+ if (!enabled) {
1270
+ clearTorchStrengthCaptureOption(controller)
1271
+ currentTorchLevel = 0.0f
1272
+ callback?.invoke(null)
1273
+ return@post
1274
+ }
1275
+
1276
+ val requestedLevel = (level ?: 1.0f).coerceIn(0.0f, 1.0f)
1277
+ val maxStrengthLevel = getMaxTorchStrengthLevel(controller)
1278
+
1279
+ if (maxStrengthLevel == null) {
1280
+ // Below API 33, or single-level hardware: strength isn't controllable, so
1281
+ // the torch is simply on at the device's (only) strength - report 1.0.
1282
+ clearTorchStrengthCaptureOption(controller)
1283
+ currentTorchLevel = 1.0f
1284
+ callback?.invoke(null)
1285
+ return@post
1286
+ }
1287
+
1288
+ applyTorchStrengthLevel(controller, requestedLevel, maxStrengthLevel, callback)
797
1289
  } catch (e: Exception) {
798
1290
  callback?.invoke(e)
799
1291
  }
800
1292
  }
801
1293
  }
802
1294
 
1295
+ /**
1296
+ * Returns the maximum torch-strength level supported by [controller]'s bound camera
1297
+ * ([CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL]), or `null` if strength
1298
+ * control isn't available - either below API 33, where the characteristic doesn't exist
1299
+ * yet, or on flash units supporting only a single binary on/off level.
1300
+ */
1301
+ private fun getMaxTorchStrengthLevel(controller: LifecycleCameraController): Int? {
1302
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
1303
+
1304
+ return try {
1305
+ val cameraInfo = controller.cameraInfo ?: return null
1306
+ val cameraId = Camera2CameraInfo.from(cameraInfo).cameraId
1307
+ val cameraManager =
1308
+ context.getSystemService(CAMERA_SERVICE) as? CameraManager ?: return null
1309
+ val characteristics = cameraManager.getCameraCharacteristics(cameraId)
1310
+ characteristics.get(CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL)
1311
+ ?.takeIf { it > 1 }
1312
+ } catch (e: Exception) {
1313
+ Log.w(TAG, "Failed to read torch strength characteristics", e)
1314
+ null
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * Applies [requestedLevel] (normalized 0.0-1.0) as a `FLASH_STRENGTH_LEVEL` Camera2 interop
1320
+ * capture-request option, mapped onto `1..maxStrengthLevel` (levels are 1-indexed; "off" is
1321
+ * handled separately by the caller via `enableTorch(false)`, so 0.0 here still maps to the
1322
+ * dimmest available level rather than off).
1323
+ *
1324
+ * If applying the option fails the torch remains on at the device's default strength and
1325
+ * [callback] is still invoked with `null` rather than failing the whole call.
1326
+ */
1327
+ @OptIn(ExperimentalCamera2Interop::class)
1328
+ private fun applyTorchStrengthLevel(
1329
+ controller: LifecycleCameraController,
1330
+ requestedLevel: Float,
1331
+ maxStrengthLevel: Int,
1332
+ callback: ((Exception?) -> Unit)?
1333
+ ) {
1334
+ val cameraControl = controller.cameraControl
1335
+ if (cameraControl == null) {
1336
+ callback?.invoke(CameraError.CameraNotInitialized())
1337
+ return
1338
+ }
1339
+
1340
+ val strengthLevel = (requestedLevel * maxStrengthLevel).roundToInt().coerceIn(1, maxStrengthLevel)
1341
+ val options = CaptureRequestOptions.Builder()
1342
+ .setCaptureRequestOption(CaptureRequest.FLASH_STRENGTH_LEVEL, strengthLevel)
1343
+ .build()
1344
+
1345
+ val camera2CameraControl = Camera2CameraControl.from(cameraControl)
1346
+ val future = camera2CameraControl.addCaptureRequestOptions(options)
1347
+
1348
+ future.addListener({
1349
+ try {
1350
+ future.get()
1351
+ torchStrengthCaptureRequestOptions = options
1352
+ currentTorchLevel = strengthLevel.toFloat() / maxStrengthLevel
1353
+ } catch (e: Exception) {
1354
+ Log.w(
1355
+ TAG,
1356
+ "Failed to apply torch strength level; torch remains on at default strength",
1357
+ e
1358
+ )
1359
+ torchStrengthCaptureRequestOptions = null
1360
+ currentTorchLevel = 1.0f
1361
+ }
1362
+ callback?.invoke(null)
1363
+ }, ContextCompat.getMainExecutor(context))
1364
+ }
1365
+
1366
+ /**
1367
+ * Clears any torch-strength Camera2 interop option previously set by
1368
+ * [applyTorchStrengthLevel], and forgets it so [clearJpegQualityCaptureOption] no longer
1369
+ * re-applies it after a capture.
1370
+ */
1371
+ @OptIn(ExperimentalCamera2Interop::class)
1372
+ private fun clearTorchStrengthCaptureOption(controller: LifecycleCameraController) {
1373
+ if (torchStrengthCaptureRequestOptions == null) return
1374
+ torchStrengthCaptureRequestOptions = null
1375
+
1376
+ try {
1377
+ controller.cameraControl?.let { Camera2CameraControl.from(it).clearCaptureRequestOptions() }
1378
+ } catch (e: Exception) {
1379
+ Log.w(TAG, "Failed to clear torch strength Camera2 interop option", e)
1380
+ }
1381
+ }
1382
+
803
1383
  /** Get a list of available camera devices */
804
1384
  fun getAvailableDevices(): List<CameraDevice> {
805
1385
  try {
@@ -819,7 +1399,14 @@ class CameraView(plugin: Plugin) {
819
1399
  else -> "external"
820
1400
  }
821
1401
 
822
- CameraDevice(id = cameraId, name = cameraId, position = position)
1402
+ val deviceType = classifyLensDeviceType(characteristics)
1403
+
1404
+ CameraDevice(
1405
+ id = cameraId,
1406
+ name = buildDeviceName(position, deviceType),
1407
+ position = position,
1408
+ deviceType = deviceType
1409
+ )
823
1410
  }
824
1411
  } catch (e: Exception) {
825
1412
  Log.e(TAG, "Error getting camera devices", e)
@@ -827,6 +1414,52 @@ class CameraView(plugin: Plugin) {
827
1414
  }
828
1415
  }
829
1416
 
1417
+ /**
1418
+ * Classifies a camera's lens as `"wideAngle"`, `"ultraWide"`, or `"telephoto"` based on
1419
+ * its horizontal field of view, derived from [CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS]
1420
+ * and [CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE].
1421
+ *
1422
+ * Unlike iOS, Camera2 has no direct lens-type API per physical camera ID, and it doesn't
1423
+ * expose multi-camera composition either, so the `dual`/`triple`/`trueDepth` types are
1424
+ * never produced here. Returns `null` when focal length or sensor size isn't reported.
1425
+ */
1426
+ private fun classifyLensDeviceType(characteristics: CameraCharacteristics): String? {
1427
+ val focalLength =
1428
+ characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
1429
+ ?.firstOrNull()
1430
+ val sensorWidth =
1431
+ characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)?.width
1432
+
1433
+ if (focalLength == null || focalLength <= 0f || sensorWidth == null || sensorWidth <= 0f) {
1434
+ return null
1435
+ }
1436
+
1437
+ val horizontalFovDegrees =
1438
+ Math.toDegrees(2.0 * atan(sensorWidth / (2.0 * focalLength)))
1439
+
1440
+ return when {
1441
+ horizontalFovDegrees >= ULTRA_WIDE_FOV_THRESHOLD_DEGREES -> "ultraWide"
1442
+ horizontalFovDegrees <= TELEPHOTO_FOV_THRESHOLD_DEGREES -> "telephoto"
1443
+ else -> "wideAngle"
1444
+ }
1445
+ }
1446
+
1447
+ /** Builds a human-readable device name from its position and (optional) lens type. */
1448
+ private fun buildDeviceName(position: String, deviceType: String?): String {
1449
+ val positionLabel = when (position) {
1450
+ "front" -> "Front"
1451
+ "back" -> "Back"
1452
+ else -> "External"
1453
+ }
1454
+ val typeLabel = when (deviceType) {
1455
+ "ultraWide" -> "Ultra Wide Camera"
1456
+ "telephoto" -> "Telephoto Camera"
1457
+ "wideAngle" -> "Wide Camera"
1458
+ else -> "Camera"
1459
+ }
1460
+ return "$positionLabel $typeLabel"
1461
+ }
1462
+
830
1463
  /** Clean up resources when the plugin is being destroyed */
831
1464
  fun cleanup() {
832
1465
  // Cancel all coroutines first
@@ -834,11 +1467,15 @@ class CameraView(plugin: Plugin) {
834
1467
 
835
1468
  mainHandler.post {
836
1469
  try {
837
- // Stop any active recording before cleanup
1470
+ // Stop any active recording before cleanup. Resumes (rather than drops)
1471
+ // any pending stopRecording() caller and deletes the partially-written
1472
+ // recording file, mirroring stopSessionAsync's handling of the same case.
838
1473
  activeRecording?.stop()
839
1474
  activeRecording = null
840
- pendingStopCallback = null
841
- currentRecordingFile = null
1475
+ failPendingRecording("Recording was interrupted because camera resources were cleaned up")
1476
+
1477
+ // Clear the image-analysis analyzer and close the ML Kit scanner (if any)
1478
+ closeBarcodeScanner()
842
1479
 
843
1480
  // Stop camera session
844
1481
  cameraController?.unbind()
@@ -851,8 +1488,7 @@ class CameraView(plugin: Plugin) {
851
1488
  }
852
1489
 
853
1490
  // Reset WebView properties
854
- webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
855
- webView.setBackgroundColor(android.graphics.Color.WHITE)
1491
+ restoreWebViewAppearance()
856
1492
 
857
1493
  // Clear references
858
1494
  lifecycleOwner = null
@@ -869,19 +1505,44 @@ class CameraView(plugin: Plugin) {
869
1505
  }
870
1506
  }
871
1507
 
872
- /** Converts a bitmap to Base64 with memory-efficient pooled ByteArrayOutputStream. */
873
- private fun bitmapToBase64(bitmap: Bitmap, quality: Int): String {
874
- val outputStream = ByteArrayOutputStream(256 * 1024) // 256KB initial capacity
875
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
876
- val byteArray = outputStream.toByteArray()
877
- return Base64.encodeToString(byteArray, Base64.NO_WRAP)
878
- }
1508
+ private fun setupPreviewView(context: Context, previewScaleMode: String? = null) {
1509
+ // Capture the WebView's original background/layer type exactly once,
1510
+ // before it's ever made transparent, so restoreWebViewAppearance()
1511
+ // can restore the app's real appearance later instead of a
1512
+ // hard-coded value.
1513
+ if (!hasCapturedOriginalWebViewAppearance) {
1514
+ val background = webView.background
1515
+ if (background is ColorDrawable) {
1516
+ // Store the color, not the drawable: setBackgroundColor()
1517
+ // below mutates this ColorDrawable instance in place, so a
1518
+ // captured reference would already be transparent on restore.
1519
+ originalWebViewBackgroundColor = background.color
1520
+ originalWebViewBackground = null
1521
+ } else {
1522
+ originalWebViewBackgroundColor = null
1523
+ originalWebViewBackground = background
1524
+ }
1525
+ originalWebViewLayerType = webView.layerType
1526
+ hasCapturedOriginalWebViewAppearance = true
1527
+ }
879
1528
 
880
- private fun setupPreviewView(context: Context) {
881
1529
  // Make WebView transparent
882
1530
  webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
883
1531
  webView.setLayerType(WebView.LAYER_TYPE_HARDWARE, null)
884
1532
 
1533
+ // "fit" letterboxes the whole frame (FIT_CENTER); any other value keeps
1534
+ // the default cover behavior (FILL_CENTER). The PreviewView stays
1535
+ // fullscreen (MATCH_PARENT) in both cases, so the barcode
1536
+ // COORDINATE_SYSTEM_VIEW_REFERENCED coordinates and the metering-point
1537
+ // factory - both scaleType-aware - remain correct; only the fitted
1538
+ // region within the view changes.
1539
+ val previewScaleType =
1540
+ if (previewScaleMode == "fit") {
1541
+ PreviewView.ScaleType.FIT_CENTER
1542
+ } else {
1543
+ PreviewView.ScaleType.FILL_CENTER
1544
+ }
1545
+
885
1546
  previewView =
886
1547
  PreviewView(context).apply {
887
1548
  layoutParams =
@@ -889,7 +1550,7 @@ class CameraView(plugin: Plugin) {
889
1550
  ViewGroup.LayoutParams.MATCH_PARENT,
890
1551
  ViewGroup.LayoutParams.MATCH_PARENT
891
1552
  )
892
- scaleType = PreviewView.ScaleType.FILL_CENTER
1553
+ scaleType = previewScaleType
893
1554
  }
894
1555
 
895
1556
  (webView.parent as? ViewGroup)?.addView(previewView, 0)
@@ -902,7 +1563,7 @@ class CameraView(plugin: Plugin) {
902
1563
  config: CameraSessionConfiguration,
903
1564
  ) {
904
1565
  // Setup preview view
905
- setupPreviewView(context)
1566
+ setupPreviewView(context, config.previewScaleMode)
906
1567
 
907
1568
  currentCameraSelector = if (config.position == "front") {
908
1569
  CameraSelector.DEFAULT_FRONT_CAMERA
@@ -922,17 +1583,55 @@ class CameraView(plugin: Plugin) {
922
1583
  .build()
923
1584
  }
924
1585
 
1586
+ // Resolve the resolution selectors from the configured aspect ratio and
1587
+ // capture-resolution hint. With both options omitted this is exactly the
1588
+ // long-standing default: 16:9-with-automatic-fallback for capture and an
1589
+ // automatically chosen preview resolution.
1590
+ val aspectRatioStrategy = when (config.aspectRatio) {
1591
+ "4:3" -> AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
1592
+ "16:9" -> AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
1593
+ else -> null
1594
+ }
1595
+
1596
+ val imageCaptureSelectorBuilder = ResolutionSelector.Builder()
1597
+ .setAspectRatioStrategy(
1598
+ aspectRatioStrategy ?: AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
1599
+ )
1600
+
1601
+ config.captureMaxDimension?.let { maxDimension ->
1602
+ // The bound size is expressed in the sensor's landscape-oriented
1603
+ // coordinate space: the longer edge is the width and the shorter
1604
+ // edge derives from the configured aspect ratio (16:9 by default).
1605
+ // CLOSEST_LOWER_THEN_HIGHER picks the largest supported resolution
1606
+ // that does not exceed the bound, falling back to the closest
1607
+ // higher one - mirroring the iOS maxPhotoDimensions selection.
1608
+ val shorterEdge =
1609
+ if (config.aspectRatio == "4:3") maxDimension * 3 / 4
1610
+ else maxDimension * 9 / 16
1611
+ imageCaptureSelectorBuilder.setResolutionStrategy(
1612
+ ResolutionStrategy(
1613
+ Size(maxDimension, shorterEdge),
1614
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER
1615
+ )
1616
+ )
1617
+ }
1618
+
925
1619
  // Initialize camera controller
926
1620
  val controller =
927
1621
  LifecycleCameraController(context).apply {
928
1622
  cameraSelector = currentCameraSelector
929
1623
  imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
930
- imageCaptureResolutionSelector =
931
- ResolutionSelector.Builder()
932
- .setAspectRatioStrategy(
933
- AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
934
- )
935
- .build()
1624
+ imageCaptureResolutionSelector = imageCaptureSelectorBuilder.build()
1625
+
1626
+ // Drive the preview stream with the same aspect ratio so the
1627
+ // on-screen framing matches the captured photo. Left untouched
1628
+ // when no aspectRatio was configured (default behavior).
1629
+ aspectRatioStrategy?.let { strategy ->
1630
+ previewResolutionSelector =
1631
+ ResolutionSelector.Builder()
1632
+ .setAspectRatioStrategy(strategy)
1633
+ .build()
1634
+ }
936
1635
  }
937
1636
 
938
1637
  cameraController = controller
@@ -946,8 +1645,33 @@ class CameraView(plugin: Plugin) {
946
1645
  // Bind to lifecycle
947
1646
  controller.bindToLifecycle(lifecycleOwner)
948
1647
 
949
- // Set initial zoom factor
950
- this.setZoomFactor(config.zoomFactor, null)
1648
+ // Apply the initial zoom factor once the camera has actually opened.
1649
+ // Immediately after bindToLifecycle the controller's zoomState is still
1650
+ // null, so the supported range falls back to 1.0..1.0 and would reject
1651
+ // any non-default zoomFactor.
1652
+ val zoomState = controller.zoomState
1653
+ zoomState.observe(
1654
+ lifecycleOwner,
1655
+ object : Observer<ZoomState> {
1656
+ override fun onChanged(value: ZoomState) {
1657
+ zoomState.removeObserver(this)
1658
+
1659
+ // Guard against a stale observation from a controller whose
1660
+ // session was already replaced.
1661
+ if (cameraController !== controller) return
1662
+
1663
+ setZoomFactor(config.zoomFactor) { error ->
1664
+ if (error != null) {
1665
+ Log.e(
1666
+ TAG,
1667
+ "Failed to apply initial zoom factor ${config.zoomFactor}",
1668
+ error
1669
+ )
1670
+ }
1671
+ }
1672
+ }
1673
+ }
1674
+ )
951
1675
  }
952
1676
 
953
1677
  /**
@@ -979,6 +1703,7 @@ class CameraView(plugin: Plugin) {
979
1703
  }
980
1704
 
981
1705
  val barcodeScanner = BarcodeScanning.getClient(options)
1706
+ this.barcodeScanner = barcodeScanner
982
1707
  val mainExecutor = ContextCompat.getMainExecutor(previewView.context)
983
1708
 
984
1709
  // Calculate a possible top offset of the webView which is not applied to the previewView
@@ -987,8 +1712,13 @@ class CameraView(plugin: Plugin) {
987
1712
  // views on android
988
1713
  val topOffset = calculateTopOffset(webView)
989
1714
 
1715
+ // The analyzer itself (frame acquisition + ML Kit detection) runs on the camera
1716
+ // executor so it never competes with UI work on the main thread; only the final
1717
+ // result callback is delivered on mainExecutor, since processBarcodeResults reads
1718
+ // previewView (a UI-thread-only object) to map the bounding box to webview
1719
+ // coordinates.
990
1720
  controller.setImageAnalysisAnalyzer(
991
- mainExecutor,
1721
+ cameraExecutor,
992
1722
  MlKitAnalyzer(
993
1723
  listOf(barcodeScanner),
994
1724
  ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED,
@@ -1005,7 +1735,9 @@ class CameraView(plugin: Plugin) {
1005
1735
  previewView: PreviewView,
1006
1736
  topOffset: Int
1007
1737
  ) {
1008
- val now = System.currentTimeMillis()
1738
+ // Monotonic clock (ms since boot): a backward wall-clock jump must not
1739
+ // extend the throttle or suppression windows arbitrarily.
1740
+ val now = SystemClock.elapsedRealtime()
1009
1741
  val lastTime = lastBarcodeDetectionTime.get()
1010
1742
 
1011
1743
  // Thread-safe throttle check using atomic compare-and-set
@@ -1023,34 +1755,43 @@ class CameraView(plugin: Plugin) {
1023
1755
 
1024
1756
  val barcode = barcodes.firstOrNull() ?: return
1025
1757
 
1758
+ val value = barcode.rawValue ?: ""
1759
+ val type = getBarcodeFormatString(barcode.format)
1760
+
1761
+ // Suppress re-emission of the same code within the suppression window so
1762
+ // a static barcode in view produces a bounded event rate. Timestamps are
1763
+ // tracked per key so multiple codes in frame can't defeat the window by
1764
+ // alternating.
1765
+ val barcodeKey = "$type\u0000$value"
1766
+ val lastEmit = recentBarcodeEmitTimes[barcodeKey]
1767
+ if (lastEmit != null && now - lastEmit < BARCODE_SUPPRESSION_WINDOW_MS) {
1768
+ return
1769
+ }
1770
+
1771
+ // Prune expired entries once the map grows, keeping it bounded during
1772
+ // long sessions that scan many different codes.
1773
+ if (recentBarcodeEmitTimes.size > BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD) {
1774
+ recentBarcodeEmitTimes.entries.removeAll { now - it.value >= BARCODE_SUPPRESSION_WINDOW_MS }
1775
+ }
1776
+ recentBarcodeEmitTimes[barcodeKey] = now
1777
+
1026
1778
  // Adjust bounding box to webView coordinates
1027
1779
  val webBoundingRect =
1028
1780
  boundingBoxToWebBoundingRect(previewView, barcode.boundingBox, topOffset)
1029
1781
 
1030
1782
  val barcodeResult =
1031
1783
  BarcodeDetectionResult(
1032
- value = barcode.rawValue ?: "",
1784
+ value = value,
1033
1785
  rawBytes = barcode.rawBytes ?: ByteArray(0),
1034
1786
  displayValue = barcode.displayValue ?: "",
1035
- type = getBarcodeFormatString(barcode.format),
1787
+ type = type,
1036
1788
  boundingRect = webBoundingRect
1037
1789
  )
1038
1790
 
1039
- // Emit to Flow for reactive subscribers
1791
+ // Emit to Flow; the plugin collects this and notifies JS listeners
1040
1792
  scope.launch {
1041
1793
  _barcodeEvents.emit(barcodeResult)
1042
1794
  }
1043
-
1044
- // Also notify via callback for backward compatibility
1045
- notifyBarcodeDetected(barcodeResult)
1046
- }
1047
-
1048
- private fun notifyBarcodeDetected(result: BarcodeDetectionResult) {
1049
- pluginDelegate.let { plugin ->
1050
- if (plugin is CameraViewPlugin) {
1051
- plugin.notifyBarcodeDetected(result)
1052
- }
1053
- }
1054
1795
  }
1055
1796
 
1056
1797
  /** Get the current zoom factors */