capacitor-camera-view 2.0.2 → 2.2.0-rc.1
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/README.md +215 -19
- package/android/build.gradle +9 -5
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +491 -116
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +181 -31
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraResult.kt +47 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +11 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/VideoRecordingQuality.kt +10 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +114 -5
- package/dist/docs.json +281 -8
- package/dist/esm/definitions.d.ts +128 -6
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +26 -4
- package/dist/esm/web.js +218 -18
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +219 -18
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +219 -18
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +125 -2
- package/ios/Sources/CameraViewPlugin/CameraEvents.swift +109 -0
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +28 -1
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +30 -41
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +38 -7
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +4 -3
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +302 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +246 -166
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +194 -96
- package/ios/Sources/CameraViewPlugin/TempFileManager.swift +215 -0
- package/ios/Sources/CameraViewPlugin/Utils.swift +102 -0
- package/package.json +17 -17
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package com.michaelwolz.capacitorcameraview
|
|
2
2
|
|
|
3
|
+
import android.Manifest
|
|
3
4
|
import android.content.Context
|
|
4
5
|
import android.content.Context.CAMERA_SERVICE
|
|
6
|
+
import android.content.pm.PackageManager
|
|
5
7
|
import android.graphics.Bitmap
|
|
6
8
|
import android.hardware.camera2.CameraCharacteristics
|
|
7
9
|
import android.hardware.camera2.CameraManager
|
|
@@ -23,8 +25,16 @@ import androidx.camera.core.TorchState
|
|
|
23
25
|
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
|
24
26
|
import androidx.camera.core.resolutionselector.ResolutionSelector
|
|
25
27
|
import androidx.camera.mlkit.vision.MlKitAnalyzer
|
|
28
|
+
import androidx.camera.video.FallbackStrategy
|
|
29
|
+
import androidx.camera.video.FileOutputOptions
|
|
30
|
+
import androidx.camera.video.Quality
|
|
31
|
+
import androidx.camera.video.QualitySelector
|
|
32
|
+
import androidx.camera.video.Recording
|
|
33
|
+
import androidx.camera.video.VideoRecordEvent
|
|
34
|
+
import androidx.camera.view.CameraController
|
|
26
35
|
import androidx.camera.view.LifecycleCameraController
|
|
27
36
|
import androidx.camera.view.PreviewView
|
|
37
|
+
import androidx.camera.view.video.AudioConfig
|
|
28
38
|
import androidx.core.content.ContextCompat
|
|
29
39
|
import androidx.lifecycle.LifecycleOwner
|
|
30
40
|
import com.getcapacitor.FileUtils
|
|
@@ -36,20 +46,49 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
|
|
|
36
46
|
import com.google.mlkit.vision.barcode.common.Barcode
|
|
37
47
|
import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
|
|
38
48
|
import com.michaelwolz.capacitorcameraview.model.CameraDevice
|
|
49
|
+
import com.michaelwolz.capacitorcameraview.model.CameraResult
|
|
39
50
|
import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
|
|
51
|
+
import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
|
|
40
52
|
import com.michaelwolz.capacitorcameraview.model.ZoomFactors
|
|
53
|
+
import kotlinx.coroutines.CancellableContinuation
|
|
54
|
+
import kotlinx.coroutines.CoroutineScope
|
|
55
|
+
import kotlinx.coroutines.Dispatchers
|
|
56
|
+
import kotlinx.coroutines.SupervisorJob
|
|
57
|
+
import kotlinx.coroutines.cancel
|
|
58
|
+
import kotlinx.coroutines.channels.BufferOverflow
|
|
59
|
+
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
60
|
+
import kotlinx.coroutines.flow.SharedFlow
|
|
61
|
+
import kotlinx.coroutines.flow.asSharedFlow
|
|
62
|
+
import kotlinx.coroutines.launch
|
|
63
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
64
|
+
import kotlinx.coroutines.withContext
|
|
41
65
|
import java.io.ByteArrayOutputStream
|
|
42
66
|
import java.io.File
|
|
43
67
|
import java.io.FileOutputStream
|
|
44
68
|
import java.util.concurrent.ExecutorService
|
|
45
69
|
import java.util.concurrent.Executors
|
|
70
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
71
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
72
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
73
|
+
import kotlin.coroutines.resume
|
|
46
74
|
|
|
47
75
|
/** Throttle time for barcode detection in milliseconds. */
|
|
48
|
-
const val BARCODE_DETECTION_THROTTLE_MS =
|
|
76
|
+
const val BARCODE_DETECTION_THROTTLE_MS = 100L
|
|
49
77
|
|
|
50
78
|
class CameraView(plugin: Plugin) {
|
|
51
|
-
//
|
|
52
|
-
private
|
|
79
|
+
// Coroutine scope for async operations
|
|
80
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
81
|
+
|
|
82
|
+
// Thread-safe camera controller reference
|
|
83
|
+
private val cameraControllerRef = AtomicReference<LifecycleCameraController?>(null)
|
|
84
|
+
|
|
85
|
+
// Camera components (using atomic reference for thread safety)
|
|
86
|
+
private var cameraController: LifecycleCameraController?
|
|
87
|
+
get() = cameraControllerRef.get()
|
|
88
|
+
set(value) {
|
|
89
|
+
cameraControllerRef.set(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
private val cameraExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
|
|
54
93
|
private var previewView: PreviewView? = null
|
|
55
94
|
|
|
@@ -57,6 +96,18 @@ class CameraView(plugin: Plugin) {
|
|
|
57
96
|
private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
|
58
97
|
private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
|
|
59
98
|
|
|
99
|
+
// Active video recording
|
|
100
|
+
private var activeRecording: Recording? = null
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Holds the pending stop-recording continuation result handler.
|
|
104
|
+
* Needed because CameraX delivers the final recording outcome asynchronously via Finalize.
|
|
105
|
+
*/
|
|
106
|
+
private var pendingStopCallback: ((CameraResult<JSObject>) -> Unit)? = null
|
|
107
|
+
|
|
108
|
+
// Track the output file for the current recording
|
|
109
|
+
private var currentRecordingFile: File? = null
|
|
110
|
+
|
|
60
111
|
// Plugin context
|
|
61
112
|
private var lifecycleOwner: LifecycleOwner? = null
|
|
62
113
|
private var pluginDelegate: Plugin = plugin
|
|
@@ -65,56 +116,86 @@ class CameraView(plugin: Plugin) {
|
|
|
65
116
|
|
|
66
117
|
private val mainHandler by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
|
|
67
118
|
|
|
68
|
-
|
|
119
|
+
// Thread-safe barcode throttle timestamp
|
|
120
|
+
private val lastBarcodeDetectionTime = AtomicLong(0L)
|
|
121
|
+
|
|
122
|
+
// Flow for reactive barcode events
|
|
123
|
+
private val _barcodeEvents = MutableSharedFlow<BarcodeDetectionResult>(
|
|
124
|
+
replay = 0,
|
|
125
|
+
extraBufferCapacity = 1,
|
|
126
|
+
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
|
127
|
+
)
|
|
128
|
+
val barcodeEvents: SharedFlow<BarcodeDetectionResult> = _barcodeEvents.asSharedFlow()
|
|
69
129
|
|
|
70
130
|
/** Starts a camera session with the provided configuration. */
|
|
71
|
-
fun
|
|
72
|
-
|
|
73
|
-
context as? LifecycleOwner
|
|
74
|
-
?:
|
|
75
|
-
callback(CameraError.LifecycleOwnerMissing())
|
|
76
|
-
return
|
|
77
|
-
}
|
|
131
|
+
suspend fun startSessionAsync(config: CameraSessionConfiguration): CameraResult<Unit> =
|
|
132
|
+
withContext(Dispatchers.Main) {
|
|
133
|
+
val lifecycleOwner = context as? LifecycleOwner
|
|
134
|
+
?: return@withContext CameraResult.Error(CameraError.LifecycleOwnerMissing())
|
|
78
135
|
|
|
79
|
-
|
|
80
|
-
this.lifecycleOwner = lifecycleOwner
|
|
136
|
+
this@CameraView.lifecycleOwner = lifecycleOwner
|
|
81
137
|
|
|
82
|
-
mainHandler.post {
|
|
83
138
|
try {
|
|
84
139
|
initializeCamera(context, lifecycleOwner, config)
|
|
85
|
-
|
|
140
|
+
CameraResult.Success(Unit)
|
|
86
141
|
} catch (e: Exception) {
|
|
87
142
|
Log.e(TAG, "Error in camera setup", e)
|
|
88
|
-
|
|
143
|
+
CameraResult.Error(e)
|
|
89
144
|
}
|
|
90
145
|
}
|
|
146
|
+
|
|
147
|
+
/** Starts a camera session with the provided configuration (callback version for backward compatibility). */
|
|
148
|
+
fun startSession(config: CameraSessionConfiguration, callback: (Exception?) -> Unit) {
|
|
149
|
+
scope.launch {
|
|
150
|
+
startSessionAsync(config).fold(
|
|
151
|
+
onSuccess = { callback(null) },
|
|
152
|
+
onError = { callback(it) }
|
|
153
|
+
)
|
|
154
|
+
}
|
|
91
155
|
}
|
|
92
156
|
|
|
93
|
-
/** Stop the camera session and release resources */
|
|
94
|
-
fun
|
|
95
|
-
|
|
157
|
+
/** Stop the camera session and release resources. */
|
|
158
|
+
suspend fun stopSessionAsync(): CameraResult<Unit> = withContext(Dispatchers.Main) {
|
|
159
|
+
try {
|
|
160
|
+
// Stop any active recording before unbinding
|
|
161
|
+
activeRecording?.stop()
|
|
162
|
+
activeRecording = null
|
|
163
|
+
pendingStopCallback?.invoke(
|
|
164
|
+
CameraResult.Error(Exception("Recording was interrupted because the camera session stopped"))
|
|
165
|
+
)
|
|
166
|
+
pendingStopCallback = null
|
|
167
|
+
currentRecordingFile = null
|
|
168
|
+
|
|
96
169
|
cameraController?.unbind()
|
|
97
170
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
previewView = null
|
|
106
|
-
}
|
|
171
|
+
previewView?.let { view ->
|
|
172
|
+
try {
|
|
173
|
+
(webView.parent as? ViewGroup)?.removeView(view)
|
|
174
|
+
} catch (e: Exception) {
|
|
175
|
+
Log.e(TAG, "Error removing preview view", e)
|
|
176
|
+
} finally {
|
|
177
|
+
previewView = null
|
|
107
178
|
}
|
|
179
|
+
}
|
|
108
180
|
|
|
109
|
-
|
|
110
|
-
|
|
181
|
+
webView.setLayerType(WebView.LAYER_TYPE_NONE, null)
|
|
182
|
+
webView.setBackgroundColor(android.graphics.Color.WHITE)
|
|
111
183
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
184
|
+
Log.d(TAG, "Camera session stopped successfully")
|
|
185
|
+
CameraResult.Success(Unit)
|
|
186
|
+
} catch (e: Exception) {
|
|
187
|
+
Log.e(TAG, "Error stopping camera session", e)
|
|
188
|
+
CameraResult.Error(e)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Stop the camera session and release resources (callback version for backward compatibility). */
|
|
193
|
+
fun stopSession(callback: ((Exception?) -> Unit)? = null) {
|
|
194
|
+
scope.launch {
|
|
195
|
+
stopSessionAsync().fold(
|
|
196
|
+
onSuccess = { callback?.invoke(null) },
|
|
197
|
+
onError = { callback?.invoke(it) }
|
|
198
|
+
)
|
|
118
199
|
}
|
|
119
200
|
}
|
|
120
201
|
|
|
@@ -123,24 +204,24 @@ class CameraView(plugin: Plugin) {
|
|
|
123
204
|
return cameraController != null
|
|
124
205
|
}
|
|
125
206
|
|
|
126
|
-
/** Capture a photo with the current camera configuration */
|
|
127
|
-
fun
|
|
207
|
+
/** Capture a photo with the current camera configuration. */
|
|
208
|
+
suspend fun capturePhotoAsync(
|
|
128
209
|
quality: Int,
|
|
129
|
-
saveToFile: Boolean = false
|
|
130
|
-
|
|
131
|
-
) {
|
|
210
|
+
saveToFile: Boolean = false
|
|
211
|
+
): CameraResult<JSObject> = suspendCancellableCoroutine { continuation ->
|
|
132
212
|
val startTime = System.currentTimeMillis()
|
|
213
|
+
|
|
133
214
|
val controller = cameraController
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
215
|
+
if (controller == null) {
|
|
216
|
+
continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
|
|
217
|
+
return@suspendCancellableCoroutine
|
|
218
|
+
}
|
|
138
219
|
|
|
139
220
|
val preview = previewView
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
221
|
+
if (preview == null) {
|
|
222
|
+
continuation.resume(CameraResult.Error(CameraError.PreviewNotInitialized()))
|
|
223
|
+
return@suspendCancellableCoroutine
|
|
224
|
+
}
|
|
144
225
|
|
|
145
226
|
mainHandler.post {
|
|
146
227
|
val cameraInfo = controller.cameraInfo
|
|
@@ -177,12 +258,12 @@ class CameraView(plugin: Plugin) {
|
|
|
177
258
|
|
|
178
259
|
put("webPath", capacitorFilePath)
|
|
179
260
|
}
|
|
180
|
-
|
|
261
|
+
continuation.resume(CameraResult.Success(result))
|
|
181
262
|
}
|
|
182
263
|
|
|
183
264
|
override fun onError(exception: ImageCaptureException) {
|
|
184
265
|
Log.e(TAG, "Error saving image to file", exception)
|
|
185
|
-
|
|
266
|
+
continuation.resume(CameraResult.Error(exception))
|
|
186
267
|
}
|
|
187
268
|
}
|
|
188
269
|
)
|
|
@@ -196,109 +277,353 @@ class CameraView(plugin: Plugin) {
|
|
|
196
277
|
TAG,
|
|
197
278
|
"Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
|
|
198
279
|
)
|
|
199
|
-
|
|
280
|
+
try {
|
|
281
|
+
val base64String =
|
|
282
|
+
imageProxyToBase64(image, quality, imageRotationDegrees)
|
|
283
|
+
val result = JSObject().apply {
|
|
284
|
+
put("photo", base64String)
|
|
285
|
+
}
|
|
286
|
+
Log.d(
|
|
287
|
+
TAG,
|
|
288
|
+
"Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
|
|
289
|
+
)
|
|
290
|
+
continuation.resume(CameraResult.Success(result))
|
|
291
|
+
} catch (e: Exception) {
|
|
292
|
+
Log.e(TAG, "Error processing captured image", e)
|
|
293
|
+
continuation.resume(CameraResult.Error(e))
|
|
294
|
+
} finally {
|
|
295
|
+
image.close()
|
|
296
|
+
}
|
|
200
297
|
}
|
|
201
298
|
|
|
202
299
|
override fun onError(exception: ImageCaptureException) {
|
|
203
300
|
Log.e(TAG, "Error capturing image", exception)
|
|
204
|
-
|
|
301
|
+
continuation.resume(CameraResult.Error(exception))
|
|
205
302
|
}
|
|
206
303
|
}
|
|
207
304
|
)
|
|
208
305
|
}
|
|
209
306
|
} catch (e: Exception) {
|
|
210
307
|
Log.e(TAG, "Error setting up image capture", e)
|
|
211
|
-
|
|
308
|
+
continuation.resume(CameraResult.Error(e))
|
|
212
309
|
}
|
|
213
310
|
}
|
|
214
311
|
}
|
|
215
312
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
*/
|
|
219
|
-
fun handleCaptureSuccess(
|
|
220
|
-
image: ImageProxy,
|
|
313
|
+
/** Capture a photo with the current camera configuration (callback version for backward compatibility). */
|
|
314
|
+
fun capturePhoto(
|
|
221
315
|
quality: Int,
|
|
222
|
-
|
|
316
|
+
saveToFile: Boolean = false,
|
|
223
317
|
callback: (JSObject?, Exception?) -> Unit
|
|
224
318
|
) {
|
|
225
|
-
|
|
319
|
+
scope.launch {
|
|
320
|
+
capturePhotoAsync(quality, saveToFile).fold(
|
|
321
|
+
onSuccess = { callback(it, null) },
|
|
322
|
+
onError = { callback(null, it) }
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Capture a frame directly from the preview without using the full photo pipeline.
|
|
329
|
+
* Faster but has lower quality than full photo capture.
|
|
330
|
+
*/
|
|
331
|
+
suspend fun captureSampleFromPreviewAsync(
|
|
332
|
+
quality: Int,
|
|
333
|
+
saveToFile: Boolean = false
|
|
334
|
+
): CameraResult<JSObject> = withContext(Dispatchers.Main) {
|
|
335
|
+
val preview = previewView
|
|
336
|
+
?: return@withContext CameraResult.Error(CameraError.PreviewNotInitialized())
|
|
337
|
+
|
|
226
338
|
try {
|
|
227
|
-
val
|
|
228
|
-
|
|
229
|
-
|
|
339
|
+
val bitmap = preview.bitmap
|
|
340
|
+
?: return@withContext CameraResult.Error(Exception("Preview bitmap not available"))
|
|
341
|
+
|
|
342
|
+
val result = JSObject()
|
|
343
|
+
|
|
344
|
+
if (saveToFile) {
|
|
345
|
+
val tempFile =
|
|
346
|
+
File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
|
|
347
|
+
|
|
348
|
+
FileOutputStream(tempFile).use { outputStream ->
|
|
349
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
val capacitorFilePath = FileUtils.getPortablePath(
|
|
353
|
+
context,
|
|
354
|
+
pluginDelegate.bridge.localUrl,
|
|
355
|
+
Uri.fromFile(tempFile)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
result.put("webPath", capacitorFilePath)
|
|
359
|
+
} else {
|
|
360
|
+
// Convert bitmap to Base64 using pooled stream
|
|
361
|
+
val base64String = bitmapToBase64(bitmap, quality)
|
|
362
|
+
result.put("photo", base64String)
|
|
230
363
|
}
|
|
231
|
-
|
|
232
|
-
|
|
364
|
+
|
|
365
|
+
CameraResult.Success(result)
|
|
233
366
|
} catch (e: Exception) {
|
|
234
|
-
Log.e(TAG, "Error
|
|
235
|
-
|
|
236
|
-
} finally {
|
|
237
|
-
image.close()
|
|
367
|
+
Log.e(TAG, "Error capturing preview frame", e)
|
|
368
|
+
CameraResult.Error(e)
|
|
238
369
|
}
|
|
239
370
|
}
|
|
240
371
|
|
|
241
372
|
/**
|
|
242
|
-
* Capture a frame directly from the preview without using the full photo pipeline
|
|
243
|
-
*
|
|
373
|
+
* Capture a frame directly from the preview without using the full photo pipeline (callback version).
|
|
374
|
+
* Faster but has lower quality than full photo capture.
|
|
244
375
|
*/
|
|
245
376
|
fun captureSampleFromPreview(
|
|
246
377
|
quality: Int,
|
|
247
378
|
saveToFile: Boolean = false,
|
|
248
379
|
callback: (JSObject?, Exception?) -> Unit
|
|
249
380
|
) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
381
|
+
scope.launch {
|
|
382
|
+
captureSampleFromPreviewAsync(quality, saveToFile).fold(
|
|
383
|
+
onSuccess = { callback(it, null) },
|
|
384
|
+
onError = { callback(null, it) }
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
256
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Starts video recording to a temporary file.
|
|
391
|
+
*/
|
|
392
|
+
suspend fun startRecordingAsync(
|
|
393
|
+
enableAudio: Boolean,
|
|
394
|
+
videoQuality: VideoRecordingQuality,
|
|
395
|
+
): CameraResult<Unit> = suspendCancellableCoroutine { continuation ->
|
|
257
396
|
mainHandler.post {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
?: run {
|
|
262
|
-
callback(null, Exception("Preview bitmap not available"))
|
|
263
|
-
return@post
|
|
264
|
-
}
|
|
397
|
+
startRecordingOnMainThread(enableAudio, videoQuality, continuation)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
265
400
|
|
|
266
|
-
|
|
401
|
+
private fun startRecordingOnMainThread(
|
|
402
|
+
enableAudio: Boolean,
|
|
403
|
+
videoQuality: VideoRecordingQuality,
|
|
404
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
405
|
+
) {
|
|
406
|
+
val controller = validateRecordingPreconditions(continuation) ?: return
|
|
267
407
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
|
|
408
|
+
try {
|
|
409
|
+
controller.videoCaptureQualitySelector = videoQuality.toQualitySelector()
|
|
271
410
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
411
|
+
// Enable VIDEO_CAPTURE use case alongside IMAGE_CAPTURE
|
|
412
|
+
controller.setEnabledUseCases(
|
|
413
|
+
CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE
|
|
414
|
+
)
|
|
275
415
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
pluginDelegate.bridge.localUrl,
|
|
279
|
-
Uri.fromFile(tempFile)
|
|
280
|
-
)
|
|
416
|
+
val outputOptions = createRecordingOutputOptions()
|
|
417
|
+
val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
|
|
281
418
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
419
|
+
startCameraRecording(controller, outputOptions, audioConfig, continuation)
|
|
420
|
+
} catch (e: SecurityException) {
|
|
421
|
+
Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
|
|
422
|
+
// Restore normal use cases on permission error
|
|
423
|
+
cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
|
|
424
|
+
continuation.resume(CameraResult.Error(e))
|
|
425
|
+
} catch (e: Exception) {
|
|
426
|
+
Log.e(TAG, "Error starting recording", e)
|
|
427
|
+
// Restore normal use cases on error
|
|
428
|
+
cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
|
|
429
|
+
continuation.resume(CameraResult.Error(e))
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private fun validateRecordingPreconditions(
|
|
434
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
435
|
+
): LifecycleCameraController? {
|
|
436
|
+
val controller = cameraController
|
|
437
|
+
if (controller == null) {
|
|
438
|
+
continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
|
|
439
|
+
return null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (activeRecording != null) {
|
|
443
|
+
continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
|
|
444
|
+
return null
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return controller
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private fun createRecordingOutputOptions(): FileOutputOptions {
|
|
451
|
+
val tempFile = File.createTempFile(
|
|
452
|
+
"camera_recording_",
|
|
453
|
+
".mp4",
|
|
454
|
+
context.cacheDir
|
|
455
|
+
)
|
|
456
|
+
currentRecordingFile = tempFile
|
|
457
|
+
return FileOutputOptions.Builder(tempFile).build()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private fun resolveAudioConfig(
|
|
461
|
+
enableAudio: Boolean,
|
|
462
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
463
|
+
): AudioConfig? {
|
|
464
|
+
if (!enableAudio) {
|
|
465
|
+
return AudioConfig.AUDIO_DISABLED
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (hasMicrophonePermission()) {
|
|
469
|
+
return try {
|
|
470
|
+
AudioConfig.create(true)
|
|
471
|
+
} catch (e: SecurityException) {
|
|
472
|
+
continuation.resume(CameraResult.Error(e))
|
|
473
|
+
null
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
continuation.resume(
|
|
478
|
+
CameraResult.Error(
|
|
479
|
+
SecurityException("Microphone permission is required for audio recording")
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private fun hasMicrophonePermission(): Boolean {
|
|
486
|
+
return ContextCompat.checkSelfPermission(
|
|
487
|
+
context,
|
|
488
|
+
Manifest.permission.RECORD_AUDIO
|
|
489
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private fun startCameraRecording(
|
|
493
|
+
controller: LifecycleCameraController,
|
|
494
|
+
outputOptions: FileOutputOptions,
|
|
495
|
+
audioConfig: AudioConfig,
|
|
496
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
497
|
+
) {
|
|
498
|
+
val startResumed = AtomicBoolean(false)
|
|
499
|
+
activeRecording = controller.startRecording(
|
|
500
|
+
outputOptions,
|
|
501
|
+
audioConfig,
|
|
502
|
+
cameraExecutor
|
|
503
|
+
) { event ->
|
|
504
|
+
when (event) {
|
|
505
|
+
is VideoRecordEvent.Start -> handleRecordingStartEvent(startResumed, continuation)
|
|
506
|
+
is VideoRecordEvent.Finalize -> {
|
|
507
|
+
handleRecordingFinalizeEvent(event, startResumed, continuation)
|
|
292
508
|
}
|
|
293
509
|
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
510
|
+
else -> Unit
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private fun handleRecordingStartEvent(
|
|
516
|
+
startResumed: AtomicBoolean,
|
|
517
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
518
|
+
) {
|
|
519
|
+
Log.d(TAG, "Video recording started")
|
|
520
|
+
if (continuation.isActive && startResumed.compareAndSet(false, true)) {
|
|
521
|
+
continuation.resume(CameraResult.Success(Unit))
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private fun handleRecordingFinalizeEvent(
|
|
526
|
+
event: VideoRecordEvent.Finalize,
|
|
527
|
+
startResumed: AtomicBoolean,
|
|
528
|
+
continuation: CancellableContinuation<CameraResult<Unit>>
|
|
529
|
+
) {
|
|
530
|
+
// If recording finalized before Start was emitted, resume the
|
|
531
|
+
// startRecording continuation with an error
|
|
532
|
+
if (continuation.isActive && startResumed.compareAndSet(false, true)) {
|
|
533
|
+
continuation.resume(
|
|
534
|
+
CameraResult.Error(
|
|
535
|
+
Exception("Recording failed to start: error code ${event.error}")
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
finalizeRecordingAndNotifyStopCallback(event)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private fun finalizeRecordingAndNotifyStopCallback(event: VideoRecordEvent.Finalize) {
|
|
544
|
+
mainHandler.post {
|
|
545
|
+
// CameraX requires use case changes on the main thread.
|
|
546
|
+
cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
|
|
547
|
+
|
|
548
|
+
val callback = pendingStopCallback
|
|
549
|
+
pendingStopCallback = null
|
|
550
|
+
// Always clean up recording state
|
|
551
|
+
activeRecording = null
|
|
552
|
+
|
|
553
|
+
if (event.hasError()) {
|
|
554
|
+
Log.e(TAG, "Recording error: ${event.error}")
|
|
555
|
+
currentRecordingFile = null
|
|
556
|
+
callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
|
|
557
|
+
return@post
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
val file = currentRecordingFile
|
|
561
|
+
currentRecordingFile = null
|
|
562
|
+
if (file == null) {
|
|
563
|
+
callback?.invoke(CameraResult.Error(Exception("Recording file not found")))
|
|
564
|
+
return@post
|
|
298
565
|
}
|
|
566
|
+
|
|
567
|
+
val capacitorFilePath = FileUtils.getPortablePath(
|
|
568
|
+
context,
|
|
569
|
+
pluginDelegate.bridge.localUrl,
|
|
570
|
+
Uri.fromFile(file)
|
|
571
|
+
)
|
|
572
|
+
val result = JSObject().apply {
|
|
573
|
+
put("webPath", capacitorFilePath)
|
|
574
|
+
}
|
|
575
|
+
callback?.invoke(CameraResult.Success(result))
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private fun VideoRecordingQuality.toQualitySelector(): QualitySelector {
|
|
580
|
+
return when (this) {
|
|
581
|
+
VideoRecordingQuality.LOWEST -> QualitySelector.from(Quality.LOWEST)
|
|
582
|
+
VideoRecordingQuality.SD -> QualitySelector.from(
|
|
583
|
+
Quality.SD,
|
|
584
|
+
FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
VideoRecordingQuality.HD -> QualitySelector.from(
|
|
588
|
+
Quality.HD,
|
|
589
|
+
FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
VideoRecordingQuality.FHD -> QualitySelector.from(
|
|
593
|
+
Quality.FHD,
|
|
594
|
+
FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
VideoRecordingQuality.UHD -> QualitySelector.from(
|
|
598
|
+
Quality.UHD,
|
|
599
|
+
FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
VideoRecordingQuality.HIGHEST -> QualitySelector.from(Quality.HIGHEST)
|
|
299
603
|
}
|
|
300
604
|
}
|
|
301
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Stops the current video recording and returns the file path.
|
|
608
|
+
*/
|
|
609
|
+
suspend fun stopRecordingAsync(): CameraResult<JSObject> =
|
|
610
|
+
suspendCancellableCoroutine { continuation ->
|
|
611
|
+
mainHandler.post {
|
|
612
|
+
val recording = activeRecording
|
|
613
|
+
if (recording == null) {
|
|
614
|
+
continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
|
|
615
|
+
return@post
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
pendingStopCallback = { result ->
|
|
619
|
+
continuation.resume(result)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
activeRecording = null
|
|
623
|
+
recording.stop()
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
302
627
|
/** Flip between front and back cameras */
|
|
303
628
|
fun flipCamera(callback: (Exception?) -> Unit) {
|
|
304
629
|
currentCameraSelector = when (currentCameraSelector) {
|
|
@@ -481,8 +806,17 @@ class CameraView(plugin: Plugin) {
|
|
|
481
806
|
|
|
482
807
|
/** Clean up resources when the plugin is being destroyed */
|
|
483
808
|
fun cleanup() {
|
|
809
|
+
// Cancel all coroutines first
|
|
810
|
+
scope.cancel()
|
|
811
|
+
|
|
484
812
|
mainHandler.post {
|
|
485
813
|
try {
|
|
814
|
+
// Stop any active recording before cleanup
|
|
815
|
+
activeRecording?.stop()
|
|
816
|
+
activeRecording = null
|
|
817
|
+
pendingStopCallback = null
|
|
818
|
+
currentRecordingFile = null
|
|
819
|
+
|
|
486
820
|
// Stop camera session
|
|
487
821
|
cameraController?.unbind()
|
|
488
822
|
cameraController = null
|
|
@@ -512,6 +846,14 @@ class CameraView(plugin: Plugin) {
|
|
|
512
846
|
}
|
|
513
847
|
}
|
|
514
848
|
|
|
849
|
+
/** Converts a bitmap to Base64 with memory-efficient pooled ByteArrayOutputStream. */
|
|
850
|
+
private fun bitmapToBase64(bitmap: Bitmap, quality: Int): String {
|
|
851
|
+
val outputStream = ByteArrayOutputStream(256 * 1024) // 256KB initial capacity
|
|
852
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
|
853
|
+
val byteArray = outputStream.toByteArray()
|
|
854
|
+
return Base64.encodeToString(byteArray, Base64.NO_WRAP)
|
|
855
|
+
}
|
|
856
|
+
|
|
515
857
|
private fun setupPreviewView(context: Context) {
|
|
516
858
|
// Make WebView transparent
|
|
517
859
|
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
|
@@ -575,7 +917,7 @@ class CameraView(plugin: Plugin) {
|
|
|
575
917
|
|
|
576
918
|
// Setup barcode scanning if needed
|
|
577
919
|
if (config.enableBarcodeDetection) {
|
|
578
|
-
setupBarcodeScanner(controller)
|
|
920
|
+
setupBarcodeScanner(controller, config.barcodeTypes)
|
|
579
921
|
}
|
|
580
922
|
|
|
581
923
|
// Bind to lifecycle
|
|
@@ -585,13 +927,33 @@ class CameraView(plugin: Plugin) {
|
|
|
585
927
|
this.setZoomFactor(config.zoomFactor, null)
|
|
586
928
|
}
|
|
587
929
|
|
|
588
|
-
|
|
930
|
+
/**
|
|
931
|
+
* Sets up the barcode scanner with the specified formats.
|
|
932
|
+
*
|
|
933
|
+
* @param controller The camera controller to attach the scanner to.
|
|
934
|
+
* @param barcodeTypes Optional list of specific barcode format codes to detect.
|
|
935
|
+
* If null, all supported formats are detected (backwards compatible).
|
|
936
|
+
*/
|
|
937
|
+
private fun setupBarcodeScanner(
|
|
938
|
+
controller: LifecycleCameraController,
|
|
939
|
+
barcodeTypes: List<Int>? = null
|
|
940
|
+
) {
|
|
589
941
|
val previewView = this.previewView ?: return
|
|
590
942
|
|
|
591
|
-
|
|
943
|
+
// Build scanner options with specified formats or all formats
|
|
944
|
+
val options = if (barcodeTypes != null && barcodeTypes.isNotEmpty()) {
|
|
945
|
+
// Use specific formats - setBarcodeFormats takes first format + vararg rest
|
|
946
|
+
val firstFormat = barcodeTypes.first()
|
|
947
|
+
val restFormats = barcodeTypes.drop(1).toIntArray()
|
|
948
|
+
BarcodeScannerOptions.Builder()
|
|
949
|
+
.setBarcodeFormats(firstFormat, *restFormats)
|
|
950
|
+
.build()
|
|
951
|
+
} else {
|
|
952
|
+
// Default to all formats for backwards compatibility
|
|
592
953
|
BarcodeScannerOptions.Builder()
|
|
593
954
|
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
|
|
594
955
|
.build()
|
|
956
|
+
}
|
|
595
957
|
|
|
596
958
|
val barcodeScanner = BarcodeScanning.getClient(options)
|
|
597
959
|
val mainExecutor = ContextCompat.getMainExecutor(previewView.context)
|
|
@@ -621,10 +983,18 @@ class CameraView(plugin: Plugin) {
|
|
|
621
983
|
topOffset: Int
|
|
622
984
|
) {
|
|
623
985
|
val now = System.currentTimeMillis()
|
|
624
|
-
|
|
986
|
+
val lastTime = lastBarcodeDetectionTime.get()
|
|
987
|
+
|
|
988
|
+
// Thread-safe throttle check using atomic compare-and-set
|
|
989
|
+
if (now - lastTime < BARCODE_DETECTION_THROTTLE_MS) {
|
|
625
990
|
return // Skip this frame
|
|
626
991
|
}
|
|
627
992
|
|
|
993
|
+
// Atomically update the timestamp - if another thread beat us, skip
|
|
994
|
+
if (!lastBarcodeDetectionTime.compareAndSet(lastTime, now)) {
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
|
|
628
998
|
val barcodes = result?.getValue(barcodeScanner) ?: return
|
|
629
999
|
if (barcodes.isEmpty()) return
|
|
630
1000
|
|
|
@@ -642,8 +1012,13 @@ class CameraView(plugin: Plugin) {
|
|
|
642
1012
|
boundingRect = webBoundingRect
|
|
643
1013
|
)
|
|
644
1014
|
|
|
1015
|
+
// Emit to Flow for reactive subscribers
|
|
1016
|
+
scope.launch {
|
|
1017
|
+
_barcodeEvents.emit(barcodeResult)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Also notify via callback for backward compatibility
|
|
645
1021
|
notifyBarcodeDetected(barcodeResult)
|
|
646
|
-
lastBarcodeDetectionTime = now
|
|
647
1022
|
}
|
|
648
1023
|
|
|
649
1024
|
private fun notifyBarcodeDetected(result: BarcodeDetectionResult) {
|