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.
- package/CapacitorCameraView.podspec +1 -1
- package/Package.swift +1 -1
- package/README.md +341 -49
- package/android/build.gradle +0 -1
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
- package/dist/docs.json +475 -30
- package/dist/esm/definitions.d.ts +478 -26
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/utils.d.ts +59 -15
- package/dist/esm/utils.js +79 -38
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +191 -13
- package/dist/esm/web.js +624 -141
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +711 -179
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +711 -179
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
- package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
- package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
- package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +195 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
- package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
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.
|
|
407
|
+
continuation.resumeIfActive(CameraResult.Error(CameraError.PreviewNotInitialized()))
|
|
227
408
|
return@suspendCancellableCoroutine
|
|
228
409
|
}
|
|
229
410
|
|
|
230
411
|
mainHandler.post {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
494
|
+
continuation.resumeIfActive(CameraResult.Success(result))
|
|
296
495
|
} catch (e: Exception) {
|
|
297
496
|
Log.e(TAG, "Error processing captured image", e)
|
|
298
|
-
continuation.
|
|
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.
|
|
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.
|
|
512
|
+
continuation.resumeIfActive(CameraResult.Error(e))
|
|
314
513
|
}
|
|
315
514
|
}
|
|
316
515
|
}
|
|
317
516
|
|
|
318
|
-
/**
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
*
|
|
334
|
-
*
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
603
|
+
val result = JSObject().apply {
|
|
604
|
+
val capacitorFilePath = FileUtils.getPortablePath(
|
|
605
|
+
context,
|
|
606
|
+
pluginDelegate.bridge.localUrl,
|
|
607
|
+
Uri.fromFile(tempFile)
|
|
608
|
+
)
|
|
348
609
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
655
|
+
suspend fun captureSampleFromPreviewAsync(
|
|
383
656
|
quality: Int,
|
|
384
|
-
saveToFile: Boolean = false
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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) ?:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
765
|
+
continuation.resumeIfActive(CameraResult.Error(CameraError.CameraNotInitialized()))
|
|
444
766
|
return null
|
|
445
767
|
}
|
|
446
768
|
|
|
447
769
|
if (activeRecording != null) {
|
|
448
|
-
continuation.
|
|
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.
|
|
809
|
+
continuation.resumeIfActive(CameraResult.Error(e))
|
|
478
810
|
null
|
|
479
811
|
}
|
|
480
812
|
}
|
|
481
813
|
|
|
482
|
-
continuation.
|
|
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 (
|
|
526
|
-
continuation.
|
|
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 (
|
|
538
|
-
continuation.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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 {
|
|
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: (
|
|
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
|
-
|
|
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
|
-
/**
|
|
783
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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 =
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
//
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1784
|
+
value = value,
|
|
1036
1785
|
rawBytes = barcode.rawBytes ?: ByteArray(0),
|
|
1037
1786
|
displayValue = barcode.displayValue ?: "",
|
|
1038
|
-
type =
|
|
1787
|
+
type = type,
|
|
1039
1788
|
boundingRect = webBoundingRect
|
|
1040
1789
|
)
|
|
1041
1790
|
|
|
1042
|
-
// Emit to Flow
|
|
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 */
|