@elizaos/capacitor-camera 1.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.
@@ -0,0 +1,1002 @@
1
+ package ai.eliza.plugins.camera
2
+
3
+ import android.Manifest
4
+ import android.content.ContentValues
5
+ import android.graphics.Bitmap
6
+ import android.graphics.BitmapFactory
7
+ import android.graphics.Matrix
8
+ import android.hardware.camera2.CameraCharacteristics
9
+ import android.hardware.camera2.CameraManager
10
+ import android.net.Uri
11
+ import android.os.Build
12
+ import android.os.Environment
13
+ import android.os.Handler
14
+ import android.os.Looper
15
+ import android.provider.MediaStore
16
+ import android.util.Base64
17
+ import android.util.Size
18
+ import android.view.ViewGroup
19
+ import androidx.camera.core.*
20
+ import androidx.camera.core.resolutionselector.ResolutionSelector
21
+ import androidx.camera.core.resolutionselector.ResolutionStrategy
22
+ import androidx.camera.lifecycle.ProcessCameraProvider
23
+ import androidx.camera.video.*
24
+ import androidx.core.content.ContextCompat
25
+ import androidx.exifinterface.media.ExifInterface
26
+ import androidx.lifecycle.LifecycleOwner
27
+ import com.getcapacitor.JSArray
28
+ import com.getcapacitor.JSObject
29
+ import com.getcapacitor.Plugin
30
+ import com.getcapacitor.PluginCall
31
+ import com.getcapacitor.PluginMethod
32
+ import com.getcapacitor.annotation.CapacitorPlugin
33
+ import com.getcapacitor.annotation.Permission
34
+ import com.getcapacitor.annotation.PermissionCallback
35
+ import kotlinx.coroutines.*
36
+ import java.io.ByteArrayOutputStream
37
+ import java.io.File
38
+ import java.text.SimpleDateFormat
39
+ import java.util.*
40
+ import java.util.concurrent.ExecutorService
41
+ import java.util.concurrent.Executors
42
+ import kotlin.coroutines.resume
43
+
44
+ @CapacitorPlugin(
45
+ name = "ElizaCamera",
46
+ permissions = [
47
+ Permission(alias = "camera", strings = [Manifest.permission.CAMERA]),
48
+ Permission(alias = "microphone", strings = [Manifest.permission.RECORD_AUDIO]),
49
+ Permission(alias = "storage", strings = [Manifest.permission.WRITE_EXTERNAL_STORAGE])
50
+ ]
51
+ )
52
+ class CameraPlugin : Plugin() {
53
+
54
+ private var cameraProvider: ProcessCameraProvider? = null
55
+ private var preview: Preview? = null
56
+ private var imageCapture: ImageCapture? = null
57
+ private var videoCapture: VideoCapture<Recorder>? = null
58
+ private var camera: Camera? = null
59
+ private var previewView: androidx.camera.view.PreviewView? = null
60
+ private var cameraExecutor: ExecutorService? = null
61
+ private var currentRecording: Recording? = null
62
+ private var isRecording = false
63
+ private var recordingStartTime = 0L
64
+ private var recordingTimer: Handler? = null
65
+ private var recordingRunnable: Runnable? = null
66
+ private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
67
+ private var currentDirection = "back"
68
+ private var pendingCall: PluginCall? = null
69
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
70
+
71
+ // Frame event timer for emitting periodic frame events during preview.
72
+ private var frameTimer: Handler? = null
73
+ private var frameRunnable: Runnable? = null
74
+ private var frameCount = 0L
75
+
76
+ // Current recording output file (for returning path on stop).
77
+ private var currentRecordingFile: File? = null
78
+ private var currentRecordingSaveToGallery = false
79
+
80
+ // Track current preview resolution for reference.
81
+ private var currentPreviewWidth = 1920
82
+ private var currentPreviewHeight = 1080
83
+
84
+ private var currentSettings = mutableMapOf<String, Any>(
85
+ "flash" to "off",
86
+ "zoom" to 1.0f,
87
+ "focusMode" to "continuous",
88
+ "exposureMode" to "continuous",
89
+ "exposureCompensation" to 0f,
90
+ "whiteBalance" to "auto"
91
+ )
92
+
93
+ // ---- Device Enumeration ----
94
+
95
+ @PluginMethod
96
+ fun getDevices(call: PluginCall) {
97
+ try {
98
+ val cameraManager =
99
+ context.getSystemService(android.content.Context.CAMERA_SERVICE) as CameraManager
100
+ val devices = JSArray()
101
+
102
+ for (cameraId in cameraManager.cameraIdList) {
103
+ val characteristics = cameraManager.getCameraCharacteristics(cameraId)
104
+ val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
105
+
106
+ val direction = when (facing) {
107
+ CameraCharacteristics.LENS_FACING_FRONT -> "front"
108
+ CameraCharacteristics.LENS_FACING_BACK -> "back"
109
+ else -> "external"
110
+ }
111
+
112
+ val hasFlash =
113
+ characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false
114
+ val maxZoom =
115
+ characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM)
116
+ ?: 1f
117
+
118
+ val streamConfigMap =
119
+ characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
120
+ val outputSizes =
121
+ streamConfigMap?.getOutputSizes(android.graphics.ImageFormat.JPEG) ?: arrayOf()
122
+
123
+ val resolutions = JSArray()
124
+ outputSizes.take(10).forEach { size ->
125
+ resolutions.put(JSObject().apply {
126
+ put("width", size.width)
127
+ put("height", size.height)
128
+ })
129
+ }
130
+
131
+ val fpsRanges =
132
+ characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
133
+ val frameRates = JSArray()
134
+ val rateSet = mutableSetOf<Int>()
135
+ fpsRanges?.forEach { range -> rateSet.add(range.upper) }
136
+ rateSet.sortedDescending().forEach { frameRates.put(it) }
137
+
138
+ devices.put(JSObject().apply {
139
+ put("deviceId", cameraId)
140
+ put("label", "Camera $cameraId ($direction)")
141
+ put("direction", direction)
142
+ put("hasFlash", hasFlash)
143
+ put("hasZoom", true)
144
+ put("maxZoom", maxZoom.toDouble())
145
+ put("supportedResolutions", resolutions)
146
+ put("supportedFrameRates", frameRates)
147
+ })
148
+ }
149
+
150
+ call.resolve(JSObject().apply {
151
+ put("devices", devices)
152
+ })
153
+ } catch (e: Exception) {
154
+ call.reject("Failed to enumerate cameras: ${e.message}")
155
+ }
156
+ }
157
+
158
+ // ---- Preview Lifecycle ----
159
+
160
+ @PluginMethod
161
+ fun startPreview(call: PluginCall) {
162
+ if (!hasRequiredPermissions()) {
163
+ pendingCall = call
164
+ requestPermissionForAlias("camera", call, "handleCameraPermissionResult")
165
+ return
166
+ }
167
+ startPreviewInternal(call)
168
+ }
169
+
170
+ @PermissionCallback
171
+ private fun handleCameraPermissionResult(call: PluginCall) {
172
+ if (getPermissionState("camera") == com.getcapacitor.PermissionState.GRANTED) {
173
+ startPreviewInternal(call)
174
+ } else {
175
+ call.reject("Camera permission denied")
176
+ }
177
+ }
178
+
179
+ override fun hasRequiredPermissions(): Boolean {
180
+ return getPermissionState("camera") == com.getcapacitor.PermissionState.GRANTED
181
+ }
182
+
183
+ private fun startPreviewInternal(call: PluginCall) {
184
+ val direction = call.getString("direction") ?: "back"
185
+ val resObj = call.getObject("resolution")
186
+ val width = resObj?.getInteger("width") ?: 1920
187
+ val height = resObj?.getInteger("height") ?: 1080
188
+ val mirror = call.getBoolean("mirror") ?: (direction == "front")
189
+
190
+ currentPreviewWidth = width
191
+ currentPreviewHeight = height
192
+
193
+ activity.runOnUiThread {
194
+ stopPreviewInternal()
195
+
196
+ cameraExecutor = Executors.newSingleThreadExecutor()
197
+
198
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
199
+
200
+ cameraProviderFuture.addListener({
201
+ try {
202
+ cameraProvider = cameraProviderFuture.get()
203
+
204
+ previewView = androidx.camera.view.PreviewView(context).apply {
205
+ layoutParams = ViewGroup.LayoutParams(
206
+ ViewGroup.LayoutParams.MATCH_PARENT,
207
+ ViewGroup.LayoutParams.MATCH_PARENT
208
+ )
209
+ scaleType = androidx.camera.view.PreviewView.ScaleType.FILL_CENTER
210
+ }
211
+
212
+ if (mirror) {
213
+ previewView?.scaleX = -1f
214
+ }
215
+
216
+ // Insert preview behind the WebView.
217
+ val webView = bridge.webView
218
+ val parent = webView?.parent as? ViewGroup
219
+ parent?.let { viewGroup ->
220
+ viewGroup.addView(previewView, 0)
221
+ webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
222
+ }
223
+
224
+ val resolutionSelector = ResolutionSelector.Builder()
225
+ .setResolutionStrategy(
226
+ ResolutionStrategy(
227
+ Size(width, height),
228
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
229
+ )
230
+ )
231
+ .build()
232
+
233
+ currentDirection = direction
234
+ currentCameraSelector = if (direction == "front") {
235
+ CameraSelector.DEFAULT_FRONT_CAMERA
236
+ } else {
237
+ CameraSelector.DEFAULT_BACK_CAMERA
238
+ }
239
+
240
+ preview = Preview.Builder()
241
+ .setResolutionSelector(resolutionSelector)
242
+ .build()
243
+ .also {
244
+ it.setSurfaceProvider(previewView?.surfaceProvider)
245
+ }
246
+
247
+ // Build ImageCapture with flash mode from current settings.
248
+ imageCapture = ImageCapture.Builder()
249
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
250
+ .setResolutionSelector(resolutionSelector)
251
+ .setFlashMode(flashModeFromSetting(currentSettings["flash"] as? String ?: "off"))
252
+ .build()
253
+
254
+ val recorder = Recorder.Builder()
255
+ .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
256
+ .build()
257
+ videoCapture = VideoCapture.withOutput(recorder)
258
+
259
+ cameraProvider?.unbindAll()
260
+
261
+ camera = cameraProvider?.bindToLifecycle(
262
+ activity as LifecycleOwner,
263
+ currentCameraSelector,
264
+ preview,
265
+ imageCapture,
266
+ videoCapture
267
+ )
268
+
269
+ // Apply stored torch setting.
270
+ applyTorch(currentSettings["flash"] as? String == "torch")
271
+
272
+ // Start frame event emission.
273
+ startFrameEvents()
274
+
275
+ call.resolve(JSObject().apply {
276
+ put("width", width)
277
+ put("height", height)
278
+ put("deviceId", if (direction == "front") "front" else "back")
279
+ })
280
+ } catch (e: Exception) {
281
+ notifyListeners("error", JSObject().apply {
282
+ put("code", "PREVIEW_ERROR")
283
+ put("message", "Failed to start preview: ${e.message}")
284
+ })
285
+ call.reject("Failed to start preview: ${e.message}")
286
+ }
287
+ }, ContextCompat.getMainExecutor(context))
288
+ }
289
+ }
290
+
291
+ @PluginMethod
292
+ fun stopPreview(call: PluginCall) {
293
+ activity.runOnUiThread {
294
+ stopPreviewInternal()
295
+ call.resolve()
296
+ }
297
+ }
298
+
299
+ private fun stopPreviewInternal() {
300
+ stopFrameEvents()
301
+
302
+ if (isRecording) {
303
+ currentRecording?.stop()
304
+ isRecording = false
305
+ }
306
+
307
+ recordingTimer?.removeCallbacks(recordingRunnable ?: Runnable {})
308
+ recordingTimer = null
309
+ recordingRunnable = null
310
+
311
+ cameraProvider?.unbindAll()
312
+ cameraProvider = null
313
+
314
+ previewView?.let { view ->
315
+ (view.parent as? ViewGroup)?.removeView(view)
316
+ }
317
+ previewView = null
318
+
319
+ cameraExecutor?.shutdown()
320
+ cameraExecutor = null
321
+
322
+ preview = null
323
+ imageCapture = null
324
+ videoCapture = null
325
+ camera = null
326
+ }
327
+
328
+ // ---- Switch Camera ----
329
+
330
+ @PluginMethod
331
+ fun switchCamera(call: PluginCall) {
332
+ if (cameraProvider == null) {
333
+ call.reject("Preview not started")
334
+ return
335
+ }
336
+
337
+ val direction = call.getString("direction")
338
+ ?: if (currentCameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) "front" else "back"
339
+ val mirror = direction == "front"
340
+
341
+ activity.runOnUiThread {
342
+ currentDirection = direction
343
+ currentCameraSelector = if (direction == "front") {
344
+ CameraSelector.DEFAULT_FRONT_CAMERA
345
+ } else {
346
+ CameraSelector.DEFAULT_BACK_CAMERA
347
+ }
348
+
349
+ previewView?.scaleX = if (mirror) -1f else 1f
350
+
351
+ cameraProvider?.unbindAll()
352
+
353
+ try {
354
+ camera = cameraProvider?.bindToLifecycle(
355
+ activity as LifecycleOwner,
356
+ currentCameraSelector,
357
+ preview,
358
+ imageCapture,
359
+ videoCapture
360
+ )
361
+
362
+ // Re-apply settings after rebinding.
363
+ applyTorch(currentSettings["flash"] as? String == "torch")
364
+ applyZoom((currentSettings["zoom"] as? Number)?.toFloat() ?: 1.0f)
365
+
366
+ call.resolve(JSObject().apply {
367
+ put("width", currentPreviewWidth)
368
+ put("height", currentPreviewHeight)
369
+ put("deviceId", direction)
370
+ })
371
+ } catch (e: Exception) {
372
+ notifyListeners("error", JSObject().apply {
373
+ put("code", "SWITCH_CAMERA_ERROR")
374
+ put("message", "Failed to switch camera: ${e.message}")
375
+ })
376
+ call.reject("Failed to switch camera: ${e.message}")
377
+ }
378
+ }
379
+ }
380
+
381
+ // ---- Photo Capture ----
382
+
383
+ @PluginMethod
384
+ fun capturePhoto(call: PluginCall) {
385
+ val imgCapture = this.imageCapture ?: run {
386
+ call.reject("Camera not ready")
387
+ return
388
+ }
389
+
390
+ val quality = call.getFloat("quality") ?: 90f
391
+ val format = call.getString("format") ?: "jpeg"
392
+ val saveToGallery = call.getBoolean("saveToGallery") ?: false
393
+ val targetWidth = call.getInt("width")
394
+ val targetHeight = call.getInt("height")
395
+ val includeExif = call.getBoolean("exifOrientation") ?: false
396
+
397
+ // Apply flash mode for this capture.
398
+ val flashSetting = currentSettings["flash"] as? String ?: "off"
399
+ imgCapture.flashMode = flashModeFromSetting(flashSetting)
400
+
401
+ // Use file-based capture for EXIF support (matches classic CameraCaptureManager pattern).
402
+ val tempFile = File.createTempFile("eliza-snap-", ".jpg", context.cacheDir)
403
+ val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
404
+
405
+ imgCapture.takePicture(
406
+ outputOptions,
407
+ cameraExecutor ?: Executors.newSingleThreadExecutor(),
408
+ object : ImageCapture.OnImageSavedCallback {
409
+ override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
410
+ try {
411
+ // Extract EXIF orientation before decoding.
412
+ val exif = ExifInterface(tempFile.absolutePath)
413
+ val orientation = exif.getAttributeInt(
414
+ ExifInterface.TAG_ORIENTATION,
415
+ ExifInterface.ORIENTATION_NORMAL
416
+ )
417
+
418
+ val bytes = tempFile.readBytes()
419
+ var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
420
+ ?: throw IllegalStateException("Failed to decode captured image")
421
+
422
+ // Rotate based on EXIF orientation (like classic implementation).
423
+ bitmap = rotateBitmapByExif(bitmap, orientation)
424
+
425
+ // Scale if target dimensions specified.
426
+ if (targetWidth != null && targetHeight != null) {
427
+ bitmap = Bitmap.createScaledBitmap(
428
+ bitmap, targetWidth, targetHeight, true
429
+ )
430
+ }
431
+
432
+ val outputStream = ByteArrayOutputStream()
433
+ val compressFormat = when (format) {
434
+ "png" -> Bitmap.CompressFormat.PNG
435
+ "webp" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
436
+ Bitmap.CompressFormat.WEBP_LOSSY
437
+ } else {
438
+ @Suppress("DEPRECATION")
439
+ Bitmap.CompressFormat.WEBP
440
+ }
441
+ else -> Bitmap.CompressFormat.JPEG
442
+ }
443
+ bitmap.compress(compressFormat, quality.toInt(), outputStream)
444
+
445
+ val outputBytes = outputStream.toByteArray()
446
+ val base64 = Base64.encodeToString(outputBytes, Base64.NO_WRAP)
447
+
448
+ if (saveToGallery) {
449
+ saveImageToGallery(outputBytes, format)
450
+ }
451
+
452
+ // Build EXIF metadata if requested.
453
+ val exifData = if (includeExif) extractExifData(exif) else null
454
+
455
+ val finalWidth = bitmap.width
456
+ val finalHeight = bitmap.height
457
+ bitmap.recycle()
458
+
459
+ activity.runOnUiThread {
460
+ call.resolve(JSObject().apply {
461
+ put("base64", base64)
462
+ put("format", format)
463
+ put("width", finalWidth)
464
+ put("height", finalHeight)
465
+ exifData?.let { put("exif", it) }
466
+ })
467
+ }
468
+ } catch (e: Exception) {
469
+ call.reject("Photo processing failed: ${e.message}")
470
+ } finally {
471
+ tempFile.delete()
472
+ }
473
+ }
474
+
475
+ override fun onError(exception: ImageCaptureException) {
476
+ tempFile.delete()
477
+ notifyListeners("error", JSObject().apply {
478
+ put("code", "CAPTURE_ERROR")
479
+ put("message", "Photo capture failed: ${exception.message}")
480
+ })
481
+ call.reject("Photo capture failed: ${exception.message}")
482
+ }
483
+ }
484
+ )
485
+ }
486
+
487
+ /** Rotate bitmap using EXIF orientation (ported from classic CameraCaptureManager). */
488
+ private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
489
+ val matrix = Matrix()
490
+ when (orientation) {
491
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
492
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
493
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
494
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
495
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
496
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
497
+ matrix.postRotate(90f)
498
+ matrix.postScale(-1f, 1f)
499
+ }
500
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
501
+ matrix.postRotate(-90f)
502
+ matrix.postScale(-1f, 1f)
503
+ }
504
+ else -> return bitmap
505
+ }
506
+ val rotated =
507
+ Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
508
+ if (rotated !== bitmap) {
509
+ bitmap.recycle()
510
+ }
511
+ return rotated
512
+ }
513
+
514
+ /** Extract common EXIF tags as a JSObject. */
515
+ private fun extractExifData(exif: ExifInterface): JSObject {
516
+ return JSObject().apply {
517
+ exif.getAttribute(ExifInterface.TAG_MAKE)?.let { put("Make", it) }
518
+ exif.getAttribute(ExifInterface.TAG_MODEL)?.let { put("Model", it) }
519
+ exif.getAttribute(ExifInterface.TAG_ORIENTATION)?.let { put("Orientation", it) }
520
+ exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { put("DateTime", it) }
521
+ exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)?.let { put("ExposureTime", it) }
522
+ exif.getAttribute(ExifInterface.TAG_F_NUMBER)?.let { put("FNumber", it) }
523
+ exif.getAttribute(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY)?.let {
524
+ put("ISO", it)
525
+ }
526
+ exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH)?.let { put("FocalLength", it) }
527
+ exif.getAttribute(ExifInterface.TAG_WHITE_BALANCE)?.let { put("WhiteBalance", it) }
528
+ exif.getAttribute(ExifInterface.TAG_FLASH)?.let { put("Flash", it) }
529
+ exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.let { put("ImageWidth", it) }
530
+ exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.let { put("ImageLength", it) }
531
+ exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let { put("GPSLatitude", it) }
532
+ exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let { put("GPSLongitude", it) }
533
+ }
534
+ }
535
+
536
+ private fun saveImageToGallery(bytes: ByteArray, format: String) {
537
+ val fileName =
538
+ "IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$format"
539
+
540
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
541
+ val contentValues = ContentValues().apply {
542
+ put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
543
+ put(MediaStore.Images.Media.MIME_TYPE, "image/$format")
544
+ put(
545
+ MediaStore.Images.Media.RELATIVE_PATH,
546
+ Environment.DIRECTORY_PICTURES
547
+ )
548
+ }
549
+ val uri = context.contentResolver.insert(
550
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues
551
+ )
552
+ uri?.let {
553
+ context.contentResolver.openOutputStream(it)?.use { outputStream ->
554
+ outputStream.write(bytes)
555
+ }
556
+ }
557
+ } else {
558
+ @Suppress("DEPRECATION")
559
+ val picturesDir =
560
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
561
+ val file = File(picturesDir, fileName)
562
+ file.writeBytes(bytes)
563
+ }
564
+ }
565
+
566
+ // ---- Video Recording ----
567
+
568
+ @PluginMethod
569
+ fun startRecording(call: PluginCall) {
570
+ if (isRecording) {
571
+ call.reject("Already recording")
572
+ return
573
+ }
574
+
575
+ this.videoCapture ?: run {
576
+ call.reject("Camera not ready")
577
+ return
578
+ }
579
+
580
+ val saveToGallery = call.getBoolean("saveToGallery") ?: false
581
+ val includeAudio = call.getBoolean("audio") ?: true
582
+ val maxDuration = call.getDouble("maxDuration")
583
+
584
+ if (includeAudio && getPermissionState("microphone") != com.getcapacitor.PermissionState.GRANTED) {
585
+ pendingCall = call
586
+ requestPermissionForAlias("microphone", call, "handleMicPermissionForRecording")
587
+ return
588
+ }
589
+
590
+ startRecordingInternal(call, saveToGallery, includeAudio, maxDuration)
591
+ }
592
+
593
+ @PermissionCallback
594
+ private fun handleMicPermissionForRecording(call: PluginCall) {
595
+ val saveToGallery = call.getBoolean("saveToGallery") ?: false
596
+ val includeAudio =
597
+ getPermissionState("microphone") == com.getcapacitor.PermissionState.GRANTED
598
+ val maxDuration = call.getDouble("maxDuration")
599
+ startRecordingInternal(call, saveToGallery, includeAudio, maxDuration)
600
+ }
601
+
602
+ @android.annotation.SuppressLint("MissingPermission")
603
+ private fun startRecordingInternal(
604
+ call: PluginCall,
605
+ saveToGallery: Boolean,
606
+ includeAudio: Boolean,
607
+ maxDuration: Double?
608
+ ) {
609
+ val videoCapture = this.videoCapture ?: return
610
+
611
+ val fileName =
612
+ "VID_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.mp4"
613
+
614
+ currentRecordingSaveToGallery = saveToGallery
615
+
616
+ val pendingRecording = if (saveToGallery && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
617
+ val contentValues = ContentValues().apply {
618
+ put(MediaStore.Video.Media.DISPLAY_NAME, fileName)
619
+ put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
620
+ put(
621
+ MediaStore.Video.Media.RELATIVE_PATH,
622
+ Environment.DIRECTORY_MOVIES
623
+ )
624
+ }
625
+ currentRecordingFile = null
626
+ val options = MediaStoreOutputOptions.Builder(
627
+ context.contentResolver,
628
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI
629
+ ).setContentValues(contentValues).build()
630
+ videoCapture.output.prepareRecording(context, options)
631
+ } else {
632
+ val file = File(context.cacheDir, fileName)
633
+ currentRecordingFile = file
634
+ val options = FileOutputOptions.Builder(file).build()
635
+ videoCapture.output.prepareRecording(context, options)
636
+ }
637
+
638
+ if (includeAudio) {
639
+ pendingRecording.withAudioEnabled()
640
+ }
641
+
642
+ isRecording = true
643
+ recordingStartTime = System.currentTimeMillis()
644
+
645
+ currentRecording =
646
+ pendingRecording.start(ContextCompat.getMainExecutor(context)) { recordEvent: VideoRecordEvent ->
647
+ when (recordEvent) {
648
+ is VideoRecordEvent.Start -> {
649
+ notifyListeners("recordingState", JSObject().apply {
650
+ put("isRecording", true)
651
+ put("duration", 0)
652
+ put("fileSize", 0)
653
+ })
654
+ }
655
+ is VideoRecordEvent.Status -> {
656
+ // CameraX emits periodic status events with stats.
657
+ val stats = recordEvent.recordingStats
658
+ notifyListeners("recordingState", JSObject().apply {
659
+ put("isRecording", true)
660
+ put(
661
+ "duration",
662
+ (System.currentTimeMillis() - recordingStartTime) / 1000.0
663
+ )
664
+ put("fileSize", stats.numBytesRecorded)
665
+ })
666
+ }
667
+ is VideoRecordEvent.Finalize -> {
668
+ isRecording = false
669
+ if (recordEvent.hasError()) {
670
+ notifyListeners("error", JSObject().apply {
671
+ put("code", "RECORDING_ERROR")
672
+ put(
673
+ "message",
674
+ "Recording failed: ${recordEvent.cause?.message}"
675
+ )
676
+ })
677
+ }
678
+ notifyListeners("recordingState", JSObject().apply {
679
+ put("isRecording", false)
680
+ put(
681
+ "duration",
682
+ (System.currentTimeMillis() - recordingStartTime) / 1000.0
683
+ )
684
+ put("fileSize", recordEvent.recordingStats.numBytesRecorded)
685
+ })
686
+ }
687
+ }
688
+ }
689
+
690
+ // Periodic duration timer as fallback for events.
691
+ recordingTimer = Handler(Looper.getMainLooper())
692
+ recordingRunnable = object : Runnable {
693
+ override fun run() {
694
+ if (isRecording) {
695
+ val duration =
696
+ (System.currentTimeMillis() - recordingStartTime) / 1000.0
697
+
698
+ if (maxDuration != null && duration >= maxDuration) {
699
+ scope.launch { stopRecordingInternal() }
700
+ } else {
701
+ recordingTimer?.postDelayed(this, 500)
702
+ }
703
+ }
704
+ }
705
+ }
706
+ recordingTimer?.postDelayed(recordingRunnable!!, 500)
707
+
708
+ call.resolve()
709
+ }
710
+
711
+ @PluginMethod
712
+ fun stopRecording(call: PluginCall) {
713
+ if (!isRecording) {
714
+ call.reject("Not recording")
715
+ return
716
+ }
717
+
718
+ scope.launch {
719
+ val result = stopRecordingInternal()
720
+ if (result != null) {
721
+ call.resolve(result)
722
+ } else {
723
+ call.reject("Failed to stop recording")
724
+ }
725
+ }
726
+ }
727
+
728
+ private suspend fun stopRecordingInternal(): JSObject? = withContext(Dispatchers.Main) {
729
+ recordingTimer?.removeCallbacks(recordingRunnable ?: return@withContext null)
730
+ recordingTimer = null
731
+ recordingRunnable = null
732
+
733
+ val duration = (System.currentTimeMillis() - recordingStartTime) / 1000.0
734
+
735
+ return@withContext suspendCancellableCoroutine { continuation ->
736
+ currentRecording?.stop()
737
+ currentRecording = null
738
+ isRecording = false
739
+
740
+ val filePath = currentRecordingFile?.absolutePath ?: ""
741
+ val fileSize = currentRecordingFile?.length() ?: 0L
742
+
743
+ continuation.resume(JSObject().apply {
744
+ put("path", filePath)
745
+ put("duration", duration)
746
+ put("width", currentPreviewWidth)
747
+ put("height", currentPreviewHeight)
748
+ put("fileSize", fileSize)
749
+ put("mimeType", "video/mp4")
750
+ })
751
+ }
752
+ }
753
+
754
+ @PluginMethod
755
+ fun getRecordingState(call: PluginCall) {
756
+ val duration =
757
+ if (isRecording) (System.currentTimeMillis() - recordingStartTime) / 1000.0 else 0.0
758
+
759
+ call.resolve(JSObject().apply {
760
+ put("isRecording", isRecording)
761
+ put("duration", duration)
762
+ put("fileSize", currentRecordingFile?.length() ?: 0)
763
+ })
764
+ }
765
+
766
+ // ---- Settings ----
767
+
768
+ @PluginMethod
769
+ fun getSettings(call: PluginCall) {
770
+ call.resolve(JSObject().apply {
771
+ put("settings", JSObject().apply {
772
+ currentSettings.forEach { (key, value) ->
773
+ when (value) {
774
+ is Float -> put(key, value.toDouble())
775
+ is Double -> put(key, value)
776
+ is Int -> put(key, value)
777
+ is String -> put(key, value)
778
+ is Boolean -> put(key, value)
779
+ else -> put(key, value.toString())
780
+ }
781
+ }
782
+ })
783
+ })
784
+ }
785
+
786
+ @PluginMethod
787
+ fun setSettings(call: PluginCall) {
788
+ val settings = call.getObject("settings") ?: run {
789
+ call.reject("Missing settings")
790
+ return
791
+ }
792
+
793
+ settings.keys().forEach { key ->
794
+ currentSettings[key] = settings.get(key)
795
+ }
796
+
797
+ // Apply flash/torch setting.
798
+ if (settings.has("flash")) {
799
+ val flashMode = settings.getString("flash") ?: "off"
800
+ currentSettings["flash"] = flashMode
801
+
802
+ // Torch mode is handled via camera control, flash via ImageCapture.
803
+ if (flashMode == "torch") {
804
+ applyTorch(true)
805
+ } else {
806
+ applyTorch(false)
807
+ imageCapture?.flashMode = flashModeFromSetting(flashMode)
808
+ }
809
+ }
810
+
811
+ // Apply zoom.
812
+ if (settings.has("zoom")) {
813
+ val zoom = settings.getDouble("zoom").toFloat()
814
+ applyZoom(zoom)
815
+ currentSettings["zoom"] = zoom
816
+ }
817
+
818
+ // Apply exposure compensation.
819
+ if (settings.has("exposureCompensation")) {
820
+ val ev = settings.getDouble("exposureCompensation").toFloat()
821
+ applyExposureCompensation(ev)
822
+ currentSettings["exposureCompensation"] = ev
823
+ }
824
+
825
+ call.resolve()
826
+ }
827
+
828
+ // ---- Zoom ----
829
+
830
+ @PluginMethod
831
+ fun setZoom(call: PluginCall) {
832
+ val zoom = call.getFloat("zoom") ?: run {
833
+ call.reject("Missing zoom parameter")
834
+ return
835
+ }
836
+ applyZoom(zoom)
837
+ currentSettings["zoom"] = zoom
838
+ call.resolve()
839
+ }
840
+
841
+ private fun applyZoom(zoom: Float) {
842
+ // CameraX setLinearZoom expects 0..1 range. Map 1..maxZoom to 0..1.
843
+ val zoomState = camera?.cameraInfo?.zoomState?.value
844
+ val maxZoom = zoomState?.maxZoomRatio ?: 10f
845
+ val minZoom = zoomState?.minZoomRatio ?: 1f
846
+ val linearZoom = ((zoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f)
847
+ camera?.cameraControl?.setLinearZoom(linearZoom)
848
+ }
849
+
850
+ // ---- Focus ----
851
+
852
+ @PluginMethod
853
+ fun setFocusPoint(call: PluginCall) {
854
+ val x = call.getFloat("x") ?: run {
855
+ call.reject("Missing x coordinate")
856
+ return
857
+ }
858
+ val y = call.getFloat("y") ?: run {
859
+ call.reject("Missing y coordinate")
860
+ return
861
+ }
862
+
863
+ previewView?.let { view ->
864
+ val factory = view.meteringPointFactory
865
+ val point = factory.createPoint(x * view.width, y * view.height)
866
+ val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
867
+ .setAutoCancelDuration(3, java.util.concurrent.TimeUnit.SECONDS)
868
+ .build()
869
+ camera?.cameraControl?.startFocusAndMetering(action)
870
+ }
871
+
872
+ currentSettings["focusMode"] = "manual"
873
+ call.resolve()
874
+ }
875
+
876
+ // ---- Exposure ----
877
+
878
+ @PluginMethod
879
+ fun setExposurePoint(call: PluginCall) {
880
+ val x = call.getFloat("x") ?: run {
881
+ call.reject("Missing x coordinate")
882
+ return
883
+ }
884
+ val y = call.getFloat("y") ?: run {
885
+ call.reject("Missing y coordinate")
886
+ return
887
+ }
888
+
889
+ previewView?.let { view ->
890
+ val factory = view.meteringPointFactory
891
+ val point = factory.createPoint(x * view.width, y * view.height)
892
+ val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AE)
893
+ .setAutoCancelDuration(3, java.util.concurrent.TimeUnit.SECONDS)
894
+ .build()
895
+ camera?.cameraControl?.startFocusAndMetering(action)
896
+ }
897
+
898
+ currentSettings["exposureMode"] = "manual"
899
+ call.resolve()
900
+ }
901
+
902
+ private fun applyExposureCompensation(ev: Float) {
903
+ // CameraX exposure compensation uses an index. Map EV to the nearest index.
904
+ val cameraInfo = camera?.cameraInfo ?: return
905
+ val range = cameraInfo.exposureState.exposureCompensationRange
906
+ val step = cameraInfo.exposureState.exposureCompensationStep.toFloat()
907
+ if (step <= 0f) return
908
+ val index = (ev / step).toInt().coerceIn(range.lower, range.upper)
909
+ camera?.cameraControl?.setExposureCompensationIndex(index)
910
+ }
911
+
912
+ // ---- Flash / Torch ----
913
+
914
+ private fun flashModeFromSetting(setting: String): Int {
915
+ return when (setting) {
916
+ "auto" -> ImageCapture.FLASH_MODE_AUTO
917
+ "on" -> ImageCapture.FLASH_MODE_ON
918
+ "torch" -> ImageCapture.FLASH_MODE_OFF // Torch is handled separately.
919
+ else -> ImageCapture.FLASH_MODE_OFF
920
+ }
921
+ }
922
+
923
+ private fun applyTorch(enabled: Boolean) {
924
+ camera?.cameraControl?.enableTorch(enabled)
925
+ }
926
+
927
+ // ---- Frame Events ----
928
+
929
+ private fun startFrameEvents() {
930
+ stopFrameEvents()
931
+ frameCount = 0
932
+ frameTimer = Handler(Looper.getMainLooper())
933
+ frameRunnable = object : Runnable {
934
+ override fun run() {
935
+ if (camera != null) {
936
+ frameCount++
937
+ notifyListeners("frame", JSObject().apply {
938
+ put("timestamp", System.currentTimeMillis())
939
+ put("width", currentPreviewWidth)
940
+ put("height", currentPreviewHeight)
941
+ })
942
+ // Emit at ~2 Hz to avoid flooding the bridge.
943
+ frameTimer?.postDelayed(this, 500)
944
+ }
945
+ }
946
+ }
947
+ frameTimer?.postDelayed(frameRunnable!!, 500)
948
+ }
949
+
950
+ private fun stopFrameEvents() {
951
+ frameRunnable?.let { frameTimer?.removeCallbacks(it) }
952
+ frameTimer = null
953
+ frameRunnable = null
954
+ }
955
+
956
+ // ---- Permissions ----
957
+
958
+ @PluginMethod
959
+ override fun checkPermissions(call: PluginCall) {
960
+ val cameraStatus = getPermissionState("camera")
961
+ val micStatus = getPermissionState("microphone")
962
+
963
+ call.resolve(JSObject().apply {
964
+ put("camera", permissionString(cameraStatus))
965
+ put("microphone", permissionString(micStatus))
966
+ put("photos", "granted")
967
+ })
968
+ }
969
+
970
+ @PluginMethod
971
+ override fun requestPermissions(call: PluginCall) {
972
+ requestAllPermissions(call, "handleAllPermissionsResult")
973
+ }
974
+
975
+ @PermissionCallback
976
+ private fun handleAllPermissionsResult(call: PluginCall) {
977
+ val cameraStatus = getPermissionState("camera")
978
+ val micStatus = getPermissionState("microphone")
979
+
980
+ call.resolve(JSObject().apply {
981
+ put("camera", permissionString(cameraStatus))
982
+ put("microphone", permissionString(micStatus))
983
+ put("photos", "granted")
984
+ })
985
+ }
986
+
987
+ private fun permissionString(status: com.getcapacitor.PermissionState?): String {
988
+ return when (status) {
989
+ com.getcapacitor.PermissionState.GRANTED -> "granted"
990
+ com.getcapacitor.PermissionState.DENIED -> "denied"
991
+ else -> "prompt"
992
+ }
993
+ }
994
+
995
+ // ---- Lifecycle ----
996
+
997
+ override fun handleOnDestroy() {
998
+ super.handleOnDestroy()
999
+ stopPreviewInternal()
1000
+ scope.cancel()
1001
+ }
1002
+ }