@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.
- package/ElizaosCapacitorCamera.podspec +18 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +9 -0
- package/android/src/main/java/ai/eliza/plugins/camera/CameraPlugin.kt +1002 -0
- package/dist/esm/definitions.d.ts +191 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +58 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +541 -0
- package/dist/plugin.cjs.js +557 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +560 -0
- package/dist/plugin.js.map +1 -0
- package/electrobun/src/index.ts +13 -0
- package/electrobun/tsconfig.json +18 -0
- package/ios/Sources/CameraPlugin/CameraPlugin.swift +1225 -0
- package/package.json +81 -0
|
@@ -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
|
+
}
|