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
@@ -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,33 +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("path", Uri.fromFile(tempFile).toString())
264
- put("webPath", capacitorFilePath)
265
- }
266
- continuation.resume(CameraResult.Success(result))
267
- }
268
-
269
- override fun onError(exception: ImageCaptureException) {
270
- Log.e(TAG, "Error saving image to file", exception)
271
- 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
272
455
  }
273
- }
274
- )
456
+ takePictureToFile(
457
+ controller,
458
+ outputFileOptions,
459
+ tempFile,
460
+ quality,
461
+ recompressAtQuality = !interopApplied,
462
+ startTime,
463
+ continuation
464
+ )
465
+ }, ContextCompat.getMainExecutor(context))
466
+ }
275
467
  } else {
276
468
  // Base64 capture using ImageProxy
277
469
  controller.takePicture(
@@ -283,6 +475,13 @@ class CameraView(plugin: Plugin) {
283
475
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
284
476
  )
285
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
+ )
286
485
  val base64String =
287
486
  imageProxyToBase64(image, quality, imageRotationDegrees)
288
487
  val result = JSObject().apply {
@@ -292,10 +491,10 @@ class CameraView(plugin: Plugin) {
292
491
  TAG,
293
492
  "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
294
493
  )
295
- continuation.resume(CameraResult.Success(result))
494
+ continuation.resumeIfActive(CameraResult.Success(result))
296
495
  } catch (e: Exception) {
297
496
  Log.e(TAG, "Error processing captured image", e)
298
- continuation.resume(CameraResult.Error(e))
497
+ continuation.resumeIfActive(CameraResult.Error(e))
299
498
  } finally {
300
499
  image.close()
301
500
  }
@@ -303,92 +502,207 @@ class CameraView(plugin: Plugin) {
303
502
 
304
503
  override fun onError(exception: ImageCaptureException) {
305
504
  Log.e(TAG, "Error capturing image", exception)
306
- continuation.resume(CameraResult.Error(exception))
505
+ continuation.resumeIfActive(CameraResult.Error(exception))
307
506
  }
308
507
  }
309
508
  )
310
509
  }
311
510
  } catch (e: Exception) {
312
511
  Log.e(TAG, "Error setting up image capture", e)
313
- continuation.resume(CameraResult.Error(e))
512
+ continuation.resumeIfActive(CameraResult.Error(e))
314
513
  }
315
514
  }
316
515
  }
317
516
 
318
- /** Capture a photo with the current camera configuration (callback version for backward compatibility). */
319
- fun capturePhoto(
320
- quality: Int,
321
- saveToFile: Boolean = false,
322
- callback: (JSObject?, Exception?) -> Unit
323
- ) {
324
- scope.launch {
325
- capturePhotoAsync(quality, saveToFile).fold(
326
- onSuccess = { callback(it, null) },
327
- onError = { callback(null, it) }
328
- )
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
329
544
  }
330
545
  }
331
546
 
332
547
  /**
333
- * Capture a frame directly from the preview without using the full photo pipeline.
334
- * 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.
335
553
  */
336
- suspend fun captureSampleFromPreviewAsync(
337
- quality: Int,
338
- saveToFile: Boolean = false
339
- ): CameraResult<JSObject> = withContext(Dispatchers.Main) {
340
- val preview = previewView
341
- ?: 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()
342
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
+ ) {
343
588
  try {
344
- val bitmap = preview.bitmap
345
- ?: 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
+ }
346
602
 
347
- val result = JSObject()
603
+ val result = JSObject().apply {
604
+ val capacitorFilePath = FileUtils.getPortablePath(
605
+ context,
606
+ pluginDelegate.bridge.localUrl,
607
+ Uri.fromFile(tempFile)
608
+ )
348
609
 
349
- if (saveToFile) {
350
- val tempFile =
351
- 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
+ }
352
619
 
353
- FileOutputStream(tempFile).use { outputStream ->
354
- 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
+ }
355
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
+ }
356
633
 
357
- val capacitorFilePath = FileUtils.getPortablePath(
358
- context,
359
- pluginDelegate.bridge.localUrl,
360
- Uri.fromFile(tempFile)
361
- )
362
-
363
- result.put("path", Uri.fromFile(tempFile).toString())
364
- result.put("webPath", capacitorFilePath)
365
- } else {
366
- // Convert bitmap to Base64 using pooled stream
367
- val base64String = bitmapToBase64(bitmap, quality)
368
- 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)
369
645
  }
370
-
371
- CameraResult.Success(result)
372
- } catch (e: Exception) {
373
- Log.e(TAG, "Error capturing preview frame", e)
374
- CameraResult.Error(e)
646
+ } finally {
647
+ bitmap.recycle()
375
648
  }
376
649
  }
377
650
 
378
651
  /**
379
- * 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.
380
653
  * Faster but has lower quality than full photo capture.
381
654
  */
382
- fun captureSampleFromPreview(
655
+ suspend fun captureSampleFromPreviewAsync(
383
656
  quality: Int,
384
- saveToFile: Boolean = false,
385
- callback: (JSObject?, Exception?) -> Unit
386
- ) {
387
- scope.launch {
388
- captureSampleFromPreviewAsync(quality, saveToFile).fold(
389
- onSuccess = { callback(it, null) },
390
- onError = { callback(null, it) }
391
- )
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
+ }
392
706
  }
393
707
  }
394
708
 
@@ -421,17 +735,25 @@ class CameraView(plugin: Plugin) {
421
735
  )
422
736
 
423
737
  val outputOptions = createRecordingOutputOptions()
424
- 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
+ }
425
745
 
426
746
  startCameraRecording(controller, outputOptions, audioConfig, continuation)
427
747
  } catch (e: SecurityException) {
428
748
  Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
429
749
  restoreUseCasesAfterRecording()
430
- continuation.resume(CameraResult.Error(e))
750
+ discardPendingRecordingFile()
751
+ continuation.resumeIfActive(CameraResult.Error(e))
431
752
  } catch (e: Exception) {
432
753
  Log.e(TAG, "Error starting recording", e)
433
754
  restoreUseCasesAfterRecording()
434
- continuation.resume(CameraResult.Error(e))
755
+ discardPendingRecordingFile()
756
+ continuation.resumeIfActive(CameraResult.Error(e))
435
757
  }
436
758
  }
437
759
 
@@ -440,12 +762,12 @@ class CameraView(plugin: Plugin) {
440
762
  ): LifecycleCameraController? {
441
763
  val controller = cameraController
442
764
  if (controller == null) {
443
- continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
765
+ continuation.resumeIfActive(CameraResult.Error(CameraError.CameraNotInitialized()))
444
766
  return null
445
767
  }
446
768
 
447
769
  if (activeRecording != null) {
448
- continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
770
+ continuation.resumeIfActive(CameraResult.Error(CameraError.RecordingAlreadyInProgress()))
449
771
  return null
450
772
  }
451
773
 
@@ -462,6 +784,16 @@ class CameraView(plugin: Plugin) {
462
784
  return FileOutputOptions.Builder(tempFile).build()
463
785
  }
464
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
+
465
797
  private fun resolveAudioConfig(
466
798
  enableAudio: Boolean,
467
799
  continuation: CancellableContinuation<CameraResult<Unit>>
@@ -474,12 +806,12 @@ class CameraView(plugin: Plugin) {
474
806
  return try {
475
807
  AudioConfig.create(true)
476
808
  } catch (e: SecurityException) {
477
- continuation.resume(CameraResult.Error(e))
809
+ continuation.resumeIfActive(CameraResult.Error(e))
478
810
  null
479
811
  }
480
812
  }
481
813
 
482
- continuation.resume(
814
+ continuation.resumeIfActive(
483
815
  CameraResult.Error(
484
816
  SecurityException("Microphone permission is required for audio recording")
485
817
  )
@@ -522,8 +854,8 @@ class CameraView(plugin: Plugin) {
522
854
  continuation: CancellableContinuation<CameraResult<Unit>>
523
855
  ) {
524
856
  Log.d(TAG, "Video recording started")
525
- if (continuation.isActive && startResumed.compareAndSet(false, true)) {
526
- continuation.resume(CameraResult.Success(Unit))
857
+ if (startResumed.compareAndSet(false, true)) {
858
+ continuation.resumeIfActive(CameraResult.Success(Unit))
527
859
  }
528
860
  }
529
861
 
@@ -534,8 +866,8 @@ class CameraView(plugin: Plugin) {
534
866
  ) {
535
867
  // If recording finalized before Start was emitted, resume the
536
868
  // startRecording continuation with an error
537
- if (continuation.isActive && startResumed.compareAndSet(false, true)) {
538
- continuation.resume(
869
+ if (startResumed.compareAndSet(false, true)) {
870
+ continuation.resumeIfActive(
539
871
  CameraResult.Error(
540
872
  Exception("Recording failed to start: error code ${event.error}")
541
873
  )
@@ -557,6 +889,10 @@ class CameraView(plugin: Plugin) {
557
889
 
558
890
  if (event.hasError()) {
559
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) }
560
896
  currentRecordingFile = null
561
897
  callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
562
898
  return@post
@@ -637,12 +973,17 @@ class CameraView(plugin: Plugin) {
637
973
  mainHandler.post {
638
974
  val recording = activeRecording
639
975
  if (recording == null) {
640
- continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
976
+ continuation.resumeIfActive(CameraResult.Error(Exception("No recording is in progress")))
641
977
  return@post
642
978
  }
643
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.
644
985
  pendingStopCallback = { result ->
645
- continuation.resume(result)
986
+ continuation.resumeIfActive(result)
646
987
  }
647
988
 
648
989
  activeRecording = null
@@ -650,13 +991,17 @@ class CameraView(plugin: Plugin) {
650
991
  }
651
992
  }
652
993
 
653
- /** 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
+ */
654
1002
  fun flipCamera(callback: (Exception?) -> Unit) {
655
- currentCameraSelector = when (currentCameraSelector) {
656
- CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
657
- else -> CameraSelector.DEFAULT_FRONT_CAMERA
658
- }
659
-
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.
660
1005
  val controller = cameraController
661
1006
  ?: run {
662
1007
  callback(CameraError.CameraNotInitialized())
@@ -664,7 +1009,25 @@ class CameraView(plugin: Plugin) {
664
1009
  }
665
1010
 
666
1011
  mainHandler.post {
667
- 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
668
1031
  callback(null)
669
1032
  }
670
1033
  }
@@ -709,6 +1072,88 @@ class CameraView(plugin: Plugin) {
709
1072
  }
710
1073
  }
711
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
+
712
1157
  /** Get the current flash mode */
713
1158
  fun getFlashMode(): String {
714
1159
  return when (currentFlashMode) {
@@ -737,11 +1182,18 @@ class CameraView(plugin: Plugin) {
737
1182
  }
738
1183
  }
739
1184
 
740
- /** Set the flash mode */
741
- fun setFlashMode(mode: String) {
742
- val controller =
743
- this.cameraController
744
- ?: 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
+ }
745
1197
 
746
1198
  currentFlashMode =
747
1199
  when (mode) {
@@ -750,7 +1202,10 @@ class CameraView(plugin: Plugin) {
750
1202
  else -> ImageCapture.FLASH_MODE_OFF
751
1203
  }
752
1204
 
753
- mainHandler.post { controller.imageCaptureFlashMode = currentFlashMode }
1205
+ mainHandler.post {
1206
+ controller.imageCaptureFlashMode = currentFlashMode
1207
+ callback(null)
1208
+ }
754
1209
  }
755
1210
 
756
1211
  /** Check if torch is available */
@@ -766,21 +1221,35 @@ class CameraView(plugin: Plugin) {
766
1221
  }
767
1222
  }
768
1223
 
769
- /** Get the current torch mode */
770
- fun getTorchMode(callback: (Boolean) -> Unit) {
1224
+ /** Get the current torch mode and intensity level. */
1225
+ fun getTorchMode(callback: (TorchModeState) -> Unit) {
771
1226
  mainHandler.post {
772
1227
  val cameraInfo = cameraController?.cameraInfo
773
1228
  ?: run {
774
- callback(false)
1229
+ callback(TorchModeState(enabled = false, level = 0.0f))
775
1230
  return@post
776
1231
  }
777
1232
 
778
- 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))
779
1235
  }
780
1236
  }
781
1237
 
782
- /** Set the torch mode */
783
- 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) {
784
1253
  mainHandler.post {
785
1254
  try {
786
1255
  val controller = cameraController
@@ -796,13 +1265,121 @@ class CameraView(plugin: Plugin) {
796
1265
  }
797
1266
 
798
1267
  controller.cameraControl?.enableTorch(enabled)
799
- 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)
800
1289
  } catch (e: Exception) {
801
1290
  callback?.invoke(e)
802
1291
  }
803
1292
  }
804
1293
  }
805
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
+
806
1383
  /** Get a list of available camera devices */
807
1384
  fun getAvailableDevices(): List<CameraDevice> {
808
1385
  try {
@@ -822,7 +1399,14 @@ class CameraView(plugin: Plugin) {
822
1399
  else -> "external"
823
1400
  }
824
1401
 
825
- 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
+ )
826
1410
  }
827
1411
  } catch (e: Exception) {
828
1412
  Log.e(TAG, "Error getting camera devices", e)
@@ -830,6 +1414,52 @@ class CameraView(plugin: Plugin) {
830
1414
  }
831
1415
  }
832
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
+
833
1463
  /** Clean up resources when the plugin is being destroyed */
834
1464
  fun cleanup() {
835
1465
  // Cancel all coroutines first
@@ -837,11 +1467,15 @@ class CameraView(plugin: Plugin) {
837
1467
 
838
1468
  mainHandler.post {
839
1469
  try {
840
- // 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.
841
1473
  activeRecording?.stop()
842
1474
  activeRecording = null
843
- pendingStopCallback = null
844
- 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()
845
1479
 
846
1480
  // Stop camera session
847
1481
  cameraController?.unbind()
@@ -854,8 +1488,7 @@ class CameraView(plugin: Plugin) {
854
1488
  }
855
1489
 
856
1490
  // Reset WebView properties
857
- webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
858
- webView.setBackgroundColor(android.graphics.Color.WHITE)
1491
+ restoreWebViewAppearance()
859
1492
 
860
1493
  // Clear references
861
1494
  lifecycleOwner = null
@@ -872,19 +1505,44 @@ class CameraView(plugin: Plugin) {
872
1505
  }
873
1506
  }
874
1507
 
875
- /** Converts a bitmap to Base64 with memory-efficient pooled ByteArrayOutputStream. */
876
- private fun bitmapToBase64(bitmap: Bitmap, quality: Int): String {
877
- val outputStream = ByteArrayOutputStream(256 * 1024) // 256KB initial capacity
878
- bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
879
- val byteArray = outputStream.toByteArray()
880
- return Base64.encodeToString(byteArray, Base64.NO_WRAP)
881
- }
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
+ }
882
1528
 
883
- private fun setupPreviewView(context: Context) {
884
1529
  // Make WebView transparent
885
1530
  webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
886
1531
  webView.setLayerType(WebView.LAYER_TYPE_HARDWARE, null)
887
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
+
888
1546
  previewView =
889
1547
  PreviewView(context).apply {
890
1548
  layoutParams =
@@ -892,7 +1550,7 @@ class CameraView(plugin: Plugin) {
892
1550
  ViewGroup.LayoutParams.MATCH_PARENT,
893
1551
  ViewGroup.LayoutParams.MATCH_PARENT
894
1552
  )
895
- scaleType = PreviewView.ScaleType.FILL_CENTER
1553
+ scaleType = previewScaleType
896
1554
  }
897
1555
 
898
1556
  (webView.parent as? ViewGroup)?.addView(previewView, 0)
@@ -905,7 +1563,7 @@ class CameraView(plugin: Plugin) {
905
1563
  config: CameraSessionConfiguration,
906
1564
  ) {
907
1565
  // Setup preview view
908
- setupPreviewView(context)
1566
+ setupPreviewView(context, config.previewScaleMode)
909
1567
 
910
1568
  currentCameraSelector = if (config.position == "front") {
911
1569
  CameraSelector.DEFAULT_FRONT_CAMERA
@@ -925,17 +1583,55 @@ class CameraView(plugin: Plugin) {
925
1583
  .build()
926
1584
  }
927
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
+
928
1619
  // Initialize camera controller
929
1620
  val controller =
930
1621
  LifecycleCameraController(context).apply {
931
1622
  cameraSelector = currentCameraSelector
932
1623
  imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
933
- imageCaptureResolutionSelector =
934
- ResolutionSelector.Builder()
935
- .setAspectRatioStrategy(
936
- AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY
937
- )
938
- .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
+ }
939
1635
  }
940
1636
 
941
1637
  cameraController = controller
@@ -949,8 +1645,33 @@ class CameraView(plugin: Plugin) {
949
1645
  // Bind to lifecycle
950
1646
  controller.bindToLifecycle(lifecycleOwner)
951
1647
 
952
- // Set initial zoom factor
953
- 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
+ )
954
1675
  }
955
1676
 
956
1677
  /**
@@ -982,6 +1703,7 @@ class CameraView(plugin: Plugin) {
982
1703
  }
983
1704
 
984
1705
  val barcodeScanner = BarcodeScanning.getClient(options)
1706
+ this.barcodeScanner = barcodeScanner
985
1707
  val mainExecutor = ContextCompat.getMainExecutor(previewView.context)
986
1708
 
987
1709
  // Calculate a possible top offset of the webView which is not applied to the previewView
@@ -990,8 +1712,13 @@ class CameraView(plugin: Plugin) {
990
1712
  // views on android
991
1713
  val topOffset = calculateTopOffset(webView)
992
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.
993
1720
  controller.setImageAnalysisAnalyzer(
994
- mainExecutor,
1721
+ cameraExecutor,
995
1722
  MlKitAnalyzer(
996
1723
  listOf(barcodeScanner),
997
1724
  ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED,
@@ -1008,7 +1735,9 @@ class CameraView(plugin: Plugin) {
1008
1735
  previewView: PreviewView,
1009
1736
  topOffset: Int
1010
1737
  ) {
1011
- 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()
1012
1741
  val lastTime = lastBarcodeDetectionTime.get()
1013
1742
 
1014
1743
  // Thread-safe throttle check using atomic compare-and-set
@@ -1026,34 +1755,43 @@ class CameraView(plugin: Plugin) {
1026
1755
 
1027
1756
  val barcode = barcodes.firstOrNull() ?: return
1028
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
+
1029
1778
  // Adjust bounding box to webView coordinates
1030
1779
  val webBoundingRect =
1031
1780
  boundingBoxToWebBoundingRect(previewView, barcode.boundingBox, topOffset)
1032
1781
 
1033
1782
  val barcodeResult =
1034
1783
  BarcodeDetectionResult(
1035
- value = barcode.rawValue ?: "",
1784
+ value = value,
1036
1785
  rawBytes = barcode.rawBytes ?: ByteArray(0),
1037
1786
  displayValue = barcode.displayValue ?: "",
1038
- type = getBarcodeFormatString(barcode.format),
1787
+ type = type,
1039
1788
  boundingRect = webBoundingRect
1040
1789
  )
1041
1790
 
1042
- // Emit to Flow for reactive subscribers
1791
+ // Emit to Flow; the plugin collects this and notifies JS listeners
1043
1792
  scope.launch {
1044
1793
  _barcodeEvents.emit(barcodeResult)
1045
1794
  }
1046
-
1047
- // Also notify via callback for backward compatibility
1048
- notifyBarcodeDetected(barcodeResult)
1049
- }
1050
-
1051
- private fun notifyBarcodeDetected(result: BarcodeDetectionResult) {
1052
- pluginDelegate.let { plugin ->
1053
- if (plugin is CameraViewPlugin) {
1054
- plugin.notifyBarcodeDetected(result)
1055
- }
1056
- }
1057
1795
  }
1058
1796
 
1059
1797
  /** Get the current zoom factors */