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