@capgo/camera-preview 7.3.11 → 7.4.0-alpha.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.
Files changed (47) hide show
  1. package/CapgoCameraPreview.podspec +16 -13
  2. package/README.md +492 -73
  3. package/android/build.gradle +11 -0
  4. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  5. package/android/src/main/AndroidManifest.xml +5 -3
  6. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +968 -505
  7. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +3017 -0
  8. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +119 -0
  9. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +63 -0
  10. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +79 -0
  11. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +167 -0
  12. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +40 -0
  13. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +35 -0
  14. package/dist/docs.json +1041 -161
  15. package/dist/esm/definitions.d.ts +484 -84
  16. package/dist/esm/definitions.js +10 -1
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/web.d.ts +78 -3
  19. package/dist/esm/web.js +813 -68
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +819 -68
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +819 -68
  24. package/dist/plugin.js.map +1 -1
  25. package/ios/Sources/CapgoCameraPreviewPlugin/CameraController.swift +1658 -0
  26. package/ios/Sources/CapgoCameraPreviewPlugin/GridOverlayView.swift +65 -0
  27. package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +1548 -0
  28. package/ios/Tests/CameraPreviewPluginTests/CameraPreviewPluginTests.swift +15 -0
  29. package/package.json +2 -2
  30. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraActivity.java +0 -1279
  31. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomSurfaceView.java +0 -29
  32. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomTextureView.java +0 -39
  33. package/android/src/main/java/com/ahm/capacitor/camera/preview/Preview.java +0 -461
  34. package/android/src/main/java/com/ahm/capacitor/camera/preview/TapGestureDetector.java +0 -24
  35. package/ios/Plugin/CameraController.swift +0 -809
  36. package/ios/Plugin/Info.plist +0 -24
  37. package/ios/Plugin/Plugin.h +0 -10
  38. package/ios/Plugin/Plugin.m +0 -18
  39. package/ios/Plugin/Plugin.swift +0 -511
  40. package/ios/Plugin.xcodeproj/project.pbxproj +0 -593
  41. package/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  42. package/ios/Plugin.xcworkspace/contents.xcworkspacedata +0 -10
  43. package/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  44. package/ios/PluginTests/Info.plist +0 -22
  45. package/ios/PluginTests/PluginTests.swift +0 -83
  46. package/ios/Podfile +0 -13
  47. package/ios/Podfile.lock +0 -23
@@ -0,0 +1,1548 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import Photos
4
+ import Capacitor
5
+ import CoreImage
6
+ import CoreLocation
7
+ import MobileCoreServices
8
+
9
+ extension UIWindow {
10
+ static var isLandscape: Bool {
11
+ if #available(iOS 13.0, *) {
12
+ return UIApplication.shared.windows
13
+ .first?
14
+ .windowScene?
15
+ .interfaceOrientation
16
+ .isLandscape ?? false
17
+ } else {
18
+ return UIApplication.shared.statusBarOrientation.isLandscape
19
+ }
20
+ }
21
+ static var isPortrait: Bool {
22
+ if #available(iOS 13.0, *) {
23
+ return UIApplication.shared.windows
24
+ .first?
25
+ .windowScene?
26
+ .interfaceOrientation
27
+ .isPortrait ?? false
28
+ } else {
29
+ return UIApplication.shared.statusBarOrientation.isPortrait
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Please read the Capacitor iOS Plugin Development Guide
36
+ * here: https://capacitor.ionicframework.com/docs/plugins/ios
37
+ */
38
+ @objc(CameraPreview)
39
+ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
40
+ public let identifier = "CameraPreviewPlugin"
41
+ public let jsName = "CameraPreview"
42
+ public let pluginMethods: [CAPPluginMethod] = [
43
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "flip", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "capture", returnType: CAPPluginReturnPromise),
47
+ CAPPluginMethod(name: "captureSample", returnType: CAPPluginReturnPromise),
48
+ CAPPluginMethod(name: "getSupportedFlashModes", returnType: CAPPluginReturnPromise),
49
+ CAPPluginMethod(name: "getHorizontalFov", returnType: CAPPluginReturnPromise),
50
+ CAPPluginMethod(name: "setFlashMode", returnType: CAPPluginReturnPromise),
51
+ CAPPluginMethod(name: "startRecordVideo", returnType: CAPPluginReturnPromise),
52
+ CAPPluginMethod(name: "stopRecordVideo", returnType: CAPPluginReturnPromise),
53
+ CAPPluginMethod(name: "getTempFilePath", returnType: CAPPluginReturnPromise),
54
+ CAPPluginMethod(name: "getSupportedPictureSizes", returnType: CAPPluginReturnPromise),
55
+ CAPPluginMethod(name: "isRunning", returnType: CAPPluginReturnPromise),
56
+ CAPPluginMethod(name: "getAvailableDevices", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "getZoom", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "setZoom", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "getFlashMode", returnType: CAPPluginReturnPromise),
60
+ CAPPluginMethod(name: "setDeviceId", returnType: CAPPluginReturnPromise),
61
+ CAPPluginMethod(name: "getDeviceId", returnType: CAPPluginReturnPromise),
62
+ CAPPluginMethod(name: "setAspectRatio", returnType: CAPPluginReturnPromise),
63
+ CAPPluginMethod(name: "getAspectRatio", returnType: CAPPluginReturnPromise),
64
+ CAPPluginMethod(name: "setGridMode", returnType: CAPPluginReturnPromise),
65
+ CAPPluginMethod(name: "getGridMode", returnType: CAPPluginReturnPromise),
66
+ CAPPluginMethod(name: "getPreviewSize", returnType: CAPPluginReturnPromise),
67
+ CAPPluginMethod(name: "setPreviewSize", returnType: CAPPluginReturnPromise),
68
+ CAPPluginMethod(name: "setFocus", returnType: CAPPluginReturnPromise)
69
+ ]
70
+ // Camera state tracking
71
+ private var isInitializing: Bool = false
72
+ private var isInitialized: Bool = false
73
+ private var backgroundSession: AVCaptureSession?
74
+
75
+ var previewView: UIView!
76
+ var cameraPosition = String()
77
+ let cameraController = CameraController()
78
+ var posX: CGFloat?
79
+ var posY: CGFloat?
80
+ var width: CGFloat?
81
+ var height: CGFloat?
82
+ var paddingBottom: CGFloat?
83
+ var rotateWhenOrientationChanged: Bool?
84
+ var toBack: Bool?
85
+ var storeToFile: Bool?
86
+ var enableZoom: Bool?
87
+ var disableAudio: Bool = false
88
+ var locationManager: CLLocationManager?
89
+ var currentLocation: CLLocation?
90
+ private var aspectRatio: String?
91
+ private var gridMode: String = "none"
92
+ private var positioning: String = "center"
93
+ private var permissionCallID: String?
94
+ private var waitingForLocation: Bool = false
95
+
96
+ // MARK: - Transparency Methods
97
+
98
+ private func makeWebViewTransparent() {
99
+ guard let webView = self.webView else { return }
100
+
101
+ DispatchQueue.main.async {
102
+ // Define a recursive function to traverse the view hierarchy
103
+ func makeSubviewsTransparent(_ view: UIView) {
104
+ // Set the background color to clear
105
+ view.backgroundColor = .clear
106
+
107
+ // Recurse for all subviews
108
+ for subview in view.subviews {
109
+ makeSubviewsTransparent(subview)
110
+ }
111
+ }
112
+
113
+ // Set the main webView to be transparent
114
+ webView.isOpaque = false
115
+ webView.backgroundColor = .clear
116
+
117
+ // Recursively make all subviews transparent
118
+ makeSubviewsTransparent(webView)
119
+
120
+ // Also ensure the webview's container is transparent
121
+ webView.superview?.backgroundColor = .clear
122
+
123
+ // Force a layout pass to apply changes
124
+ webView.setNeedsLayout()
125
+ webView.layoutIfNeeded()
126
+ }
127
+ }
128
+
129
+ @objc func rotated() {
130
+ guard let previewView = self.previewView,
131
+ let posX = self.posX,
132
+ let posY = self.posY,
133
+ let width = self.width,
134
+ let heightValue = self.height else {
135
+ return
136
+ }
137
+ let paddingBottom = self.paddingBottom ?? 0
138
+ let height = heightValue - paddingBottom
139
+
140
+ // Handle auto-centering during rotation
141
+ // Always use the factorized method for consistent positioning
142
+ self.updateCameraFrame()
143
+
144
+ if let connection = self.cameraController.fileVideoOutput?.connection(with: .video) {
145
+ switch UIDevice.current.orientation {
146
+ case .landscapeRight:
147
+ connection.videoOrientation = .landscapeLeft
148
+ case .landscapeLeft:
149
+ connection.videoOrientation = .landscapeRight
150
+ case .portrait:
151
+ connection.videoOrientation = .portrait
152
+ case .portraitUpsideDown:
153
+ connection.videoOrientation = .portraitUpsideDown
154
+ default:
155
+ connection.videoOrientation = .portrait
156
+ }
157
+ }
158
+
159
+ cameraController.updateVideoOrientation()
160
+
161
+ cameraController.updateVideoOrientation()
162
+
163
+ // Update grid overlay frame if it exists - no animation
164
+ if let gridOverlay = self.cameraController.gridOverlayView {
165
+ CATransaction.begin()
166
+ CATransaction.setDisableActions(true)
167
+ gridOverlay.frame = previewView.bounds
168
+ CATransaction.commit()
169
+ }
170
+
171
+ // Ensure webview remains transparent after rotation
172
+ if self.isInitialized {
173
+ self.makeWebViewTransparent()
174
+ }
175
+ }
176
+
177
+ @objc func setAspectRatio(_ call: CAPPluginCall) {
178
+ guard self.isInitialized else {
179
+ call.reject("camera not started")
180
+ return
181
+ }
182
+
183
+ guard let newAspectRatio = call.getString("aspectRatio") else {
184
+ call.reject("aspectRatio parameter is required")
185
+ return
186
+ }
187
+
188
+ self.aspectRatio = newAspectRatio
189
+
190
+ DispatchQueue.main.async {
191
+ // When aspect ratio changes, always auto-center the view
192
+ // This ensures consistent behavior where changing aspect ratio recenters the view
193
+ self.posX = -1
194
+ self.posY = -1
195
+
196
+ // Calculate maximum size based on aspect ratio
197
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
198
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
199
+ let paddingBottom = self.paddingBottom ?? 0
200
+
201
+ // Calculate available space
202
+ let availableWidth: CGFloat
203
+ let availableHeight: CGFloat
204
+
205
+ if self.posX == -1 || self.posY == -1 {
206
+ // Auto-centering mode - use full dimensions
207
+ availableWidth = webViewWidth
208
+ availableHeight = webViewHeight - paddingBottom
209
+ } else {
210
+ // Manual positioning - calculate remaining space
211
+ availableWidth = webViewWidth - self.posX!
212
+ availableHeight = webViewHeight - self.posY! - paddingBottom
213
+ }
214
+
215
+ // Parse aspect ratio - convert to portrait orientation for camera use
216
+ let ratioParts = newAspectRatio.split(separator: ":").map { Double($0) ?? 1.0 }
217
+ // For camera, we want portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
218
+ let ratio = ratioParts[1] / ratioParts[0]
219
+
220
+ // Calculate maximum size that fits the aspect ratio in available space
221
+ let maxWidthByHeight = availableHeight * CGFloat(ratio)
222
+ let maxHeightByWidth = availableWidth / CGFloat(ratio)
223
+
224
+ if maxWidthByHeight <= availableWidth {
225
+ // Height is the limiting factor
226
+ self.width = maxWidthByHeight
227
+ self.height = availableHeight
228
+ } else {
229
+ // Width is the limiting factor
230
+ self.width = availableWidth
231
+ self.height = maxHeightByWidth
232
+ }
233
+
234
+ self.updateCameraFrame()
235
+
236
+ // Return the actual preview bounds
237
+ var result = JSObject()
238
+ result["x"] = Double(self.previewView.frame.origin.x)
239
+ result["y"] = Double(self.previewView.frame.origin.y)
240
+ result["width"] = Double(self.previewView.frame.width)
241
+ result["height"] = Double(self.previewView.frame.height)
242
+ call.resolve(result)
243
+ }
244
+ }
245
+
246
+ @objc func getAspectRatio(_ call: CAPPluginCall) {
247
+ guard self.isInitialized else {
248
+ call.reject("camera not started")
249
+ return
250
+ }
251
+ call.resolve(["aspectRatio": self.aspectRatio ?? "4:3"])
252
+ }
253
+
254
+ @objc func setGridMode(_ call: CAPPluginCall) {
255
+ guard self.isInitialized else {
256
+ call.reject("camera not started")
257
+ return
258
+ }
259
+
260
+ guard let gridMode = call.getString("gridMode") else {
261
+ call.reject("gridMode parameter is required")
262
+ return
263
+ }
264
+
265
+ self.gridMode = gridMode
266
+
267
+ // Update grid overlay
268
+ DispatchQueue.main.async {
269
+ if gridMode == "none" {
270
+ self.cameraController.removeGridOverlay()
271
+ } else {
272
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: gridMode)
273
+ }
274
+ }
275
+
276
+ call.resolve()
277
+ }
278
+
279
+ @objc func getGridMode(_ call: CAPPluginCall) {
280
+ guard self.isInitialized else {
281
+ call.reject("camera not started")
282
+ return
283
+ }
284
+ call.resolve(["gridMode": self.gridMode])
285
+ }
286
+
287
+ @objc func appDidBecomeActive() {
288
+ if self.isInitialized {
289
+ DispatchQueue.main.async {
290
+ self.makeWebViewTransparent()
291
+ }
292
+ }
293
+ }
294
+
295
+ @objc func appWillEnterForeground() {
296
+ if self.isInitialized {
297
+ DispatchQueue.main.async {
298
+ self.makeWebViewTransparent()
299
+ }
300
+ }
301
+ }
302
+
303
+ struct CameraInfo {
304
+ let deviceID: String
305
+ let position: String
306
+ let pictureSizes: [CGSize]
307
+ }
308
+
309
+ func getSupportedPictureSizes() -> [CameraInfo] {
310
+ var cameraInfos = [CameraInfo]()
311
+
312
+ // Discover all available cameras
313
+ let deviceTypes: [AVCaptureDevice.DeviceType] = [
314
+ .builtInWideAngleCamera,
315
+ .builtInUltraWideCamera,
316
+ .builtInTelephotoCamera,
317
+ .builtInDualCamera,
318
+ .builtInDualWideCamera,
319
+ .builtInTripleCamera,
320
+ .builtInTrueDepthCamera
321
+ ]
322
+
323
+ let session = AVCaptureDevice.DiscoverySession(
324
+ deviceTypes: deviceTypes,
325
+ mediaType: .video,
326
+ position: .unspecified
327
+ )
328
+
329
+ let devices = session.devices
330
+
331
+ for device in devices {
332
+ // Determine the position of the camera
333
+ var position = "Unknown"
334
+ switch device.position {
335
+ case .front:
336
+ position = "Front"
337
+ case .back:
338
+ position = "Back"
339
+ case .unspecified:
340
+ position = "Unspecified"
341
+ @unknown default:
342
+ position = "Unknown"
343
+ }
344
+
345
+ var pictureSizes = [CGSize]()
346
+
347
+ // Get supported formats
348
+ for format in device.formats {
349
+ let description = format.formatDescription
350
+ let dimensions = CMVideoFormatDescriptionGetDimensions(description)
351
+ let size = CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height))
352
+ if !pictureSizes.contains(size) {
353
+ pictureSizes.append(size)
354
+ }
355
+ }
356
+
357
+ // Sort sizes in descending order (largest to smallest)
358
+ pictureSizes.sort { $0.width * $0.height > $1.width * $1.height }
359
+
360
+ let cameraInfo = CameraInfo(deviceID: device.uniqueID, position: position, pictureSizes: pictureSizes)
361
+ cameraInfos.append(cameraInfo)
362
+ }
363
+
364
+ return cameraInfos
365
+ }
366
+
367
+ @objc func getSupportedPictureSizes(_ call: CAPPluginCall) {
368
+ let cameraInfos = getSupportedPictureSizes()
369
+ call.resolve([
370
+ "supportedPictureSizes": cameraInfos.map {
371
+ return [
372
+ "facing": $0.position,
373
+ "supportedPictureSizes": $0.pictureSizes.map { size in
374
+ return [
375
+ "width": String(describing: size.width),
376
+ "height": String(describing: size.height)
377
+ ]
378
+ }
379
+ ]
380
+ }
381
+ ])
382
+ }
383
+
384
+ @objc func start(_ call: CAPPluginCall) {
385
+ let startTime = CFAbsoluteTimeGetCurrent()
386
+ print("[CameraPreview] 🚀 START CALLED at \(Date())")
387
+
388
+ if self.isInitializing {
389
+ call.reject("camera initialization in progress")
390
+ return
391
+ }
392
+ if self.isInitialized {
393
+ call.reject("camera already started")
394
+ return
395
+ }
396
+ self.isInitializing = true
397
+
398
+ self.cameraPosition = call.getString("position") ?? "rear"
399
+ let deviceId = call.getString("deviceId")
400
+ let cameraMode = call.getBool("cameraMode") ?? false
401
+
402
+ // Set width - use screen width if not provided or if 0
403
+ if let width = call.getInt("width"), width > 0 {
404
+ self.width = CGFloat(width)
405
+ } else {
406
+ self.width = UIScreen.main.bounds.size.width
407
+ }
408
+
409
+ // Set height - use screen height if not provided or if 0
410
+ if let height = call.getInt("height"), height > 0 {
411
+ self.height = CGFloat(height)
412
+ } else {
413
+ self.height = UIScreen.main.bounds.size.height
414
+ }
415
+
416
+ // Set x position - use exact CSS pixel value from web view, or mark for centering
417
+ if let x = call.getInt("x") {
418
+ self.posX = CGFloat(x)
419
+ } else {
420
+ self.posX = -1 // Use -1 to indicate auto-centering
421
+ }
422
+
423
+ // Set y position - use exact CSS pixel value from web view, or mark for centering
424
+ if let y = call.getInt("y") {
425
+ self.posY = CGFloat(y)
426
+ } else {
427
+ self.posY = -1 // Use -1 to indicate auto-centering
428
+ }
429
+ if call.getInt("paddingBottom") != nil {
430
+ self.paddingBottom = CGFloat(call.getInt("paddingBottom")!)
431
+ }
432
+
433
+ self.rotateWhenOrientationChanged = call.getBool("rotateWhenOrientationChanged") ?? true
434
+ self.toBack = call.getBool("toBack") ?? true
435
+ self.storeToFile = call.getBool("storeToFile") ?? false
436
+ self.enableZoom = call.getBool("enableZoom") ?? false
437
+ self.disableAudio = call.getBool("disableAudio") ?? true
438
+ self.aspectRatio = call.getString("aspectRatio")
439
+ self.gridMode = call.getString("gridMode") ?? "none"
440
+ self.positioning = call.getString("positioning") ?? "top"
441
+
442
+ let userProvidedZoom = call.getFloat("initialZoomLevel")
443
+ let initialZoomLevel = userProvidedZoom ?? 1.5
444
+
445
+ if self.aspectRatio != nil && (call.getInt("width") != nil || call.getInt("height") != nil) {
446
+ call.reject("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.")
447
+ return
448
+ }
449
+
450
+ AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
451
+
452
+ guard granted else {
453
+ call.reject("permission failed")
454
+ return
455
+ }
456
+
457
+ if self.cameraController.captureSession?.isRunning ?? false {
458
+ call.reject("camera already started")
459
+ } else {
460
+ self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, initialZoomLevel: Float(initialZoomLevel)) {error in
461
+ if let error = error {
462
+ print(error)
463
+ call.reject(error.localizedDescription)
464
+ return
465
+ }
466
+ DispatchQueue.main.async {
467
+ self.completeStartCamera(call: call)
468
+ }
469
+ }
470
+ }
471
+ })
472
+ }
473
+
474
+ private func completeStartCamera(call: CAPPluginCall) {
475
+ // Create and configure the preview view first
476
+ self.updateCameraFrame()
477
+
478
+ // Make webview transparent - comprehensive approach
479
+ self.makeWebViewTransparent()
480
+
481
+ // Add the preview view to the webview itself to use same coordinate system
482
+ self.webView?.addSubview(self.previewView)
483
+ if self.toBack! {
484
+ self.webView?.sendSubviewToBack(self.previewView)
485
+ }
486
+
487
+ // Display the camera preview on the configured view
488
+ try? self.cameraController.displayPreview(on: self.previewView)
489
+
490
+ self.cameraController.setupGestures(target: self.previewView, enableZoom: self.enableZoom!)
491
+
492
+ // Add grid overlay if enabled
493
+ if self.gridMode != "none" {
494
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: self.gridMode)
495
+ }
496
+
497
+ if self.rotateWhenOrientationChanged == true {
498
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.rotated), name: UIDevice.orientationDidChangeNotification, object: nil)
499
+ }
500
+
501
+ // Add observers for app state changes to maintain transparency
502
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
503
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
504
+
505
+ self.isInitializing = false
506
+ self.isInitialized = true
507
+
508
+ // Set up callback to wait for first frame before resolving
509
+ self.cameraController.firstFrameReadyCallback = { [weak self] in
510
+ guard let self = self else { return }
511
+
512
+ DispatchQueue.main.async {
513
+ var returnedObject = JSObject()
514
+ returnedObject["width"] = self.previewView.frame.width as any JSValue
515
+ returnedObject["height"] = self.previewView.frame.height as any JSValue
516
+ returnedObject["x"] = self.previewView.frame.origin.x as any JSValue
517
+ returnedObject["y"] = self.previewView.frame.origin.y as any JSValue
518
+ call.resolve(returnedObject)
519
+ }
520
+ }
521
+
522
+ // If already received first frame (unlikely but possible), resolve immediately
523
+ if self.cameraController.hasReceivedFirstFrame {
524
+ var returnedObject = JSObject()
525
+ returnedObject["width"] = self.previewView.frame.width as any JSValue
526
+ returnedObject["height"] = self.previewView.frame.height as any JSValue
527
+ returnedObject["x"] = self.previewView.frame.origin.x as any JSValue
528
+ returnedObject["y"] = self.previewView.frame.origin.y as any JSValue
529
+ call.resolve(returnedObject)
530
+ }
531
+ }
532
+
533
+ @objc func flip(_ call: CAPPluginCall) {
534
+ guard isInitialized else {
535
+ call.reject("Camera not initialized")
536
+ return
537
+ }
538
+
539
+ // Disable user interaction during flip
540
+ self.previewView.isUserInteractionEnabled = false
541
+
542
+ do {
543
+ try self.cameraController.switchCameras()
544
+
545
+ // Update preview layer frame without animation
546
+ CATransaction.begin()
547
+ CATransaction.setDisableActions(true)
548
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
549
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
550
+ CATransaction.commit()
551
+
552
+ self.previewView.isUserInteractionEnabled = true
553
+
554
+ // Ensure webview remains transparent after flip
555
+ self.makeWebViewTransparent()
556
+
557
+ call.resolve()
558
+ } catch {
559
+ self.previewView.isUserInteractionEnabled = true
560
+ print("Failed to flip camera: \(error.localizedDescription)")
561
+ call.reject("Failed to flip camera: \(error.localizedDescription)")
562
+ }
563
+ }
564
+
565
+ @objc func stop(_ call: CAPPluginCall) {
566
+ if self.isInitializing {
567
+ call.reject("cannot stop camera while initialization is in progress")
568
+ return
569
+ }
570
+ if !self.isInitialized {
571
+ call.reject("camera not initialized")
572
+ return
573
+ }
574
+
575
+ // UI operations must be on main thread
576
+ DispatchQueue.main.async {
577
+ // Always attempt to stop and clean up, regardless of captureSession state
578
+ self.cameraController.removeGridOverlay()
579
+ if let previewView = self.previewView {
580
+ previewView.removeFromSuperview()
581
+ self.previewView = nil
582
+ }
583
+
584
+ self.webView?.isOpaque = true
585
+ self.isInitialized = false
586
+ self.isInitializing = false
587
+ self.cameraController.cleanup()
588
+
589
+ // Remove notification observers
590
+ NotificationCenter.default.removeObserver(self)
591
+
592
+ call.resolve()
593
+ }
594
+ }
595
+ // Get user's cache directory path
596
+ @objc func getTempFilePath() -> URL {
597
+ let path = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
598
+ let identifier = UUID()
599
+ let randomIdentifier = identifier.uuidString.replacingOccurrences(of: "-", with: "")
600
+ let finalIdentifier = String(randomIdentifier.prefix(8))
601
+ let fileName="cpcp_capture_"+finalIdentifier+".jpg"
602
+ let fileUrl=path.appendingPathComponent(fileName)
603
+ return fileUrl
604
+ }
605
+
606
+ @objc func capture(_ call: CAPPluginCall) {
607
+ print("[CameraPreview] capture called with options: \(call.options)")
608
+ let withExifLocation = call.getBool("withExifLocation", false)
609
+ print("[CameraPreview] capture called, withExifLocation: \(withExifLocation)")
610
+
611
+ if withExifLocation {
612
+ print("[CameraPreview] Location required for capture")
613
+
614
+ // Check location services before main thread dispatch
615
+ guard CLLocationManager.locationServicesEnabled() else {
616
+ print("[CameraPreview] Location services are disabled")
617
+ call.reject("Location services are disabled")
618
+ return
619
+ }
620
+
621
+ // Check if Info.plist has the required key
622
+ guard Bundle.main.object(forInfoDictionaryKey: "NSLocationWhenInUseUsageDescription") != nil else {
623
+ print("[CameraPreview] ERROR: NSLocationWhenInUseUsageDescription key missing from Info.plist")
624
+ call.reject("NSLocationWhenInUseUsageDescription key missing from Info.plist. Add this key with a description of how your app uses location.")
625
+ return
626
+ }
627
+
628
+ // Ensure location manager setup happens on main thread
629
+ DispatchQueue.main.async {
630
+ if self.locationManager == nil {
631
+ self.locationManager = CLLocationManager()
632
+ self.locationManager?.delegate = self
633
+ self.locationManager?.desiredAccuracy = kCLLocationAccuracyBest
634
+ }
635
+
636
+ // Check current authorization status
637
+ let currentStatus = self.locationManager?.authorizationStatus ?? .notDetermined
638
+
639
+ switch currentStatus {
640
+ case .authorizedWhenInUse, .authorizedAlways:
641
+ // Already authorized, get location and capture
642
+ self.getCurrentLocation { _ in
643
+ self.performCapture(call: call)
644
+ }
645
+
646
+ case .denied, .restricted:
647
+ // Permission denied
648
+ print("[CameraPreview] Location permission denied")
649
+ call.reject("Location permission denied")
650
+
651
+ case .notDetermined:
652
+ // Need to request permission
653
+ print("[CameraPreview] Location permission not determined, requesting...")
654
+ // Save the call for the delegate callback
655
+ print("[CameraPreview] Saving call for location authorization flow")
656
+ self.bridge?.saveCall(call)
657
+ self.permissionCallID = call.callbackId
658
+ self.waitingForLocation = true
659
+
660
+ // Request authorization - this will trigger locationManagerDidChangeAuthorization
661
+ print("[CameraPreview] Requesting location authorization...")
662
+ self.locationManager?.requestWhenInUseAuthorization()
663
+ // The delegate will handle the rest
664
+
665
+ @unknown default:
666
+ print("[CameraPreview] Unknown authorization status")
667
+ call.reject("Unknown location permission status")
668
+ }
669
+ }
670
+ } else {
671
+ print("[CameraPreview] No location required, performing capture directly")
672
+ self.performCapture(call: call)
673
+ }
674
+ }
675
+
676
+ private func performCapture(call: CAPPluginCall) {
677
+ print("[CameraPreview] performCapture called")
678
+ print("[CameraPreview] Call parameters: \(call.options)")
679
+ let quality = call.getFloat("quality", 85)
680
+ let saveToGallery = call.getBool("saveToGallery", false)
681
+ let withExifLocation = call.getBool("withExifLocation", false)
682
+ let width = call.getInt("width")
683
+ let height = call.getInt("height")
684
+ let aspectRatio = call.getString("aspectRatio")
685
+
686
+ print("[CameraPreview] Raw parameter values - width: \(String(describing: width)), height: \(String(describing: height)), aspectRatio: \(String(describing: aspectRatio))")
687
+
688
+ // Check for conflicting parameters
689
+ if aspectRatio != nil && (width != nil || height != nil) {
690
+ print("[CameraPreview] Error: Cannot set both aspectRatio and size (width/height)")
691
+ call.reject("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.")
692
+ return
693
+ }
694
+
695
+ // When no dimensions are specified, we should capture exactly what's visible in the preview
696
+ // Don't pass aspectRatio in this case - let the capture method handle preview matching
697
+ print("[CameraPreview] Capture decision - width: \(width == nil), height: \(height == nil), aspectRatio param: \(aspectRatio == nil)")
698
+ print("[CameraPreview] Stored aspectRatio: \(self.aspectRatio ?? "nil")")
699
+
700
+ // Only pass aspectRatio if explicitly provided in the capture call
701
+ // Never use the stored aspectRatio when capturing without dimensions
702
+ let captureAspectRatio: String? = aspectRatio
703
+
704
+ print("[CameraPreview] Capture params - quality: \(quality), saveToGallery: \(saveToGallery), withExifLocation: \(withExifLocation), width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil"), using aspectRatio: \(captureAspectRatio ?? "nil")")
705
+ print("[CameraPreview] Current location: \(self.currentLocation?.description ?? "nil")")
706
+ print("[CameraPreview] Preview dimensions: \(self.previewView.frame.width)x\(self.previewView.frame.height)")
707
+
708
+ self.cameraController.captureImage(width: width, height: height, aspectRatio: captureAspectRatio, quality: quality, gpsLocation: self.currentLocation) { (image, error) in
709
+ print("[CameraPreview] captureImage callback received")
710
+ DispatchQueue.main.async {
711
+ print("[CameraPreview] Processing capture on main thread")
712
+ if let error = error {
713
+ print("[CameraPreview] Capture error: \(error.localizedDescription)")
714
+ call.reject(error.localizedDescription)
715
+ return
716
+ }
717
+
718
+ guard let image = image,
719
+ let imageDataWithExif = self.createImageDataWithExif(
720
+ from: image,
721
+ quality: Int(quality),
722
+ location: withExifLocation ? self.currentLocation : nil
723
+ )
724
+ else {
725
+ print("[CameraPreview] Failed to create image data with EXIF")
726
+ call.reject("Failed to create image data with EXIF")
727
+ return
728
+ }
729
+
730
+ print("[CameraPreview] Image data created, size: \(imageDataWithExif.count) bytes")
731
+
732
+ if saveToGallery {
733
+ print("[CameraPreview] Saving to gallery...")
734
+ self.saveImageDataToGallery(imageData: imageDataWithExif) { success, error in
735
+ print("[CameraPreview] Save to gallery completed, success: \(success), error: \(error?.localizedDescription ?? "none")")
736
+ let exifData = self.getExifData(from: imageDataWithExif)
737
+ let base64Image = imageDataWithExif.base64EncodedString()
738
+
739
+ var result = JSObject()
740
+ result["value"] = base64Image
741
+ result["exif"] = exifData
742
+ result["gallerySaved"] = success
743
+ if !success, let error = error {
744
+ result["galleryError"] = error.localizedDescription
745
+ }
746
+
747
+ print("[CameraPreview] Resolving capture call with gallery save")
748
+ call.resolve(result)
749
+ }
750
+ } else {
751
+ print("[CameraPreview] Not saving to gallery, returning image data")
752
+ let exifData = self.getExifData(from: imageDataWithExif)
753
+ let base64Image = imageDataWithExif.base64EncodedString()
754
+
755
+ var result = JSObject()
756
+ result["value"] = base64Image
757
+ result["exif"] = exifData
758
+
759
+ print("[CameraPreview] Resolving capture call")
760
+ call.resolve(result)
761
+ }
762
+ }
763
+ }
764
+ }
765
+
766
+ private func getExifData(from imageData: Data) -> JSObject {
767
+ guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil),
768
+ let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
769
+ let exifDict = imageProperties[kCGImagePropertyExifDictionary as String] as? [String: Any] else {
770
+ return [:]
771
+ }
772
+
773
+ var exifData = JSObject()
774
+ for (key, value) in exifDict {
775
+ // Convert value to JSValue-compatible type
776
+ if let stringValue = value as? String {
777
+ exifData[key] = stringValue
778
+ } else if let numberValue = value as? NSNumber {
779
+ exifData[key] = numberValue
780
+ } else if let boolValue = value as? Bool {
781
+ exifData[key] = boolValue
782
+ } else if let arrayValue = value as? [Any] {
783
+ exifData[key] = arrayValue
784
+ } else if let dictValue = value as? [String: Any] {
785
+ exifData[key] = JSObject(_immutableCocoaDictionary: NSMutableDictionary(dictionary: dictValue))
786
+ } else {
787
+ // Convert other types to string as fallback
788
+ exifData[key] = String(describing: value)
789
+ }
790
+ }
791
+
792
+ return exifData
793
+ }
794
+
795
+ private func createImageDataWithExif(from image: UIImage, quality: Int, location: CLLocation?) -> Data? {
796
+ guard let originalImageData = image.jpegData(compressionQuality: CGFloat(Double(quality) / 100.0)) else {
797
+ return nil
798
+ }
799
+
800
+ guard let imageSource = CGImageSourceCreateWithData(originalImageData as CFData, nil),
801
+ let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
802
+ let cgImage = image.cgImage else {
803
+ return originalImageData
804
+ }
805
+
806
+ let mutableData = NSMutableData()
807
+ guard let destination = CGImageDestinationCreateWithData(mutableData, kUTTypeJPEG, 1, nil) else {
808
+ return originalImageData
809
+ }
810
+
811
+ var finalProperties = imageProperties
812
+
813
+ // Add GPS location if available
814
+ if let location = location {
815
+ let formatter = DateFormatter()
816
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
817
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
818
+
819
+ let gpsDict: [String: Any] = [
820
+ kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
821
+ kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
822
+ kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
823
+ kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
824
+ kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
825
+ kCGImagePropertyGPSAltitude as String: location.altitude,
826
+ kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
827
+ ]
828
+
829
+ finalProperties[kCGImagePropertyGPSDictionary as String] = gpsDict
830
+ }
831
+
832
+ // Create or update TIFF dictionary for device info
833
+ var tiffDict = finalProperties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] ?? [:]
834
+ tiffDict[kCGImagePropertyTIFFMake as String] = "Apple"
835
+ tiffDict[kCGImagePropertyTIFFModel as String] = UIDevice.current.model
836
+ finalProperties[kCGImagePropertyTIFFDictionary as String] = tiffDict
837
+
838
+ CGImageDestinationAddImage(destination, cgImage, finalProperties as CFDictionary)
839
+
840
+ if CGImageDestinationFinalize(destination) {
841
+ return mutableData as Data
842
+ }
843
+
844
+ return originalImageData
845
+ }
846
+
847
+ @objc func captureSample(_ call: CAPPluginCall) {
848
+ let quality: Int? = call.getInt("quality", 85)
849
+
850
+ self.cameraController.captureSample { image, error in
851
+ guard let image = image else {
852
+ print("Image capture error: \(String(describing: error))")
853
+ call.reject("Image capture error: \(String(describing: error))")
854
+ return
855
+ }
856
+
857
+ let imageData: Data?
858
+ if self.cameraPosition == "front" {
859
+ let flippedImage = image.withHorizontallyFlippedOrientation()
860
+ imageData = flippedImage.jpegData(compressionQuality: CGFloat(quality!/100))
861
+ } else {
862
+ imageData = image.jpegData(compressionQuality: CGFloat(quality!/100))
863
+ }
864
+
865
+ if self.storeToFile == false {
866
+ let imageBase64 = imageData?.base64EncodedString()
867
+ call.resolve(["value": imageBase64!])
868
+ } else {
869
+ do {
870
+ let fileUrl = self.getTempFilePath()
871
+ try imageData?.write(to: fileUrl)
872
+ call.resolve(["value": fileUrl.absoluteString])
873
+ } catch {
874
+ call.reject("Error writing image to file")
875
+ }
876
+ }
877
+ }
878
+ }
879
+
880
+ @objc func getSupportedFlashModes(_ call: CAPPluginCall) {
881
+ do {
882
+ let supportedFlashModes = try self.cameraController.getSupportedFlashModes()
883
+ call.resolve(["result": supportedFlashModes])
884
+ } catch {
885
+ call.reject("failed to get supported flash modes")
886
+ }
887
+ }
888
+
889
+ @objc func getHorizontalFov(_ call: CAPPluginCall) {
890
+ do {
891
+ let horizontalFov = try self.cameraController.getHorizontalFov()
892
+ call.resolve(["result": horizontalFov])
893
+ } catch {
894
+ call.reject("failed to get FOV")
895
+ }
896
+ }
897
+
898
+ @objc func setFlashMode(_ call: CAPPluginCall) {
899
+ guard let flashMode = call.getString("flashMode") else {
900
+ call.reject("failed to set flash mode. required parameter flashMode is missing")
901
+ return
902
+ }
903
+ do {
904
+ var flashModeAsEnum: AVCaptureDevice.FlashMode?
905
+ switch flashMode {
906
+ case "off":
907
+ flashModeAsEnum = AVCaptureDevice.FlashMode.off
908
+ case "on":
909
+ flashModeAsEnum = AVCaptureDevice.FlashMode.on
910
+ case "auto":
911
+ flashModeAsEnum = AVCaptureDevice.FlashMode.auto
912
+ default: break
913
+ }
914
+ if flashModeAsEnum != nil {
915
+ try self.cameraController.setFlashMode(flashMode: flashModeAsEnum!)
916
+ } else if flashMode == "torch" {
917
+ try self.cameraController.setTorchMode()
918
+ } else {
919
+ call.reject("Flash Mode not supported")
920
+ return
921
+ }
922
+ call.resolve()
923
+ } catch {
924
+ call.reject("failed to set flash mode")
925
+ }
926
+ }
927
+
928
+ @objc func startRecordVideo(_ call: CAPPluginCall) {
929
+ do {
930
+ try self.cameraController.captureVideo()
931
+ call.resolve()
932
+ } catch {
933
+ call.reject(error.localizedDescription)
934
+ }
935
+ }
936
+
937
+ @objc func stopRecordVideo(_ call: CAPPluginCall) {
938
+ self.cameraController.stopRecording { (fileURL, error) in
939
+ guard let fileURL = fileURL else {
940
+ print(error ?? "Video capture error")
941
+ guard let error = error else {
942
+ call.reject("Video capture error")
943
+ return
944
+ }
945
+ call.reject(error.localizedDescription)
946
+ return
947
+ }
948
+
949
+ call.resolve(["videoFilePath": fileURL.absoluteString])
950
+ }
951
+ }
952
+
953
+ @objc func isRunning(_ call: CAPPluginCall) {
954
+ let isRunning = self.isInitialized && (self.cameraController.captureSession?.isRunning ?? false)
955
+ call.resolve(["isRunning": isRunning])
956
+ }
957
+
958
+ @objc func getAvailableDevices(_ call: CAPPluginCall) {
959
+ let deviceTypes: [AVCaptureDevice.DeviceType] = [
960
+ .builtInWideAngleCamera,
961
+ .builtInUltraWideCamera,
962
+ .builtInTelephotoCamera,
963
+ .builtInDualCamera,
964
+ .builtInDualWideCamera,
965
+ .builtInTripleCamera,
966
+ .builtInTrueDepthCamera
967
+ ]
968
+
969
+ let session = AVCaptureDevice.DiscoverySession(
970
+ deviceTypes: deviceTypes,
971
+ mediaType: .video,
972
+ position: .unspecified
973
+ )
974
+
975
+ var devices: [[String: Any]] = []
976
+
977
+ // Collect all devices by position
978
+ for device in session.devices {
979
+ var lenses: [[String: Any]] = []
980
+
981
+ let constituentDevices = device.isVirtualDevice ? device.constituentDevices : [device]
982
+
983
+ for lensDevice in constituentDevices {
984
+ var deviceType: String
985
+ switch lensDevice.deviceType {
986
+ case .builtInWideAngleCamera: deviceType = "wideAngle"
987
+ case .builtInUltraWideCamera: deviceType = "ultraWide"
988
+ case .builtInTelephotoCamera: deviceType = "telephoto"
989
+ case .builtInDualCamera: deviceType = "dual"
990
+ case .builtInDualWideCamera: deviceType = "dualWide"
991
+ case .builtInTripleCamera: deviceType = "triple"
992
+ case .builtInTrueDepthCamera: deviceType = "trueDepth"
993
+ default: deviceType = "unknown"
994
+ }
995
+
996
+ var baseZoomRatio: Float = 1.0
997
+ if lensDevice.deviceType == .builtInUltraWideCamera {
998
+ baseZoomRatio = 0.5
999
+ } else if lensDevice.deviceType == .builtInTelephotoCamera {
1000
+ baseZoomRatio = 2.0 // A common value for telephoto lenses
1001
+ }
1002
+
1003
+ let lensInfo: [String: Any] = [
1004
+ "label": lensDevice.localizedName,
1005
+ "deviceType": deviceType,
1006
+ "focalLength": 4.25, // Placeholder
1007
+ "baseZoomRatio": baseZoomRatio,
1008
+ "minZoom": Float(lensDevice.minAvailableVideoZoomFactor),
1009
+ "maxZoom": Float(lensDevice.maxAvailableVideoZoomFactor)
1010
+ ]
1011
+ lenses.append(lensInfo)
1012
+ }
1013
+
1014
+ let deviceData: [String: Any] = [
1015
+ "deviceId": device.uniqueID,
1016
+ "label": device.localizedName,
1017
+ "position": device.position == .front ? "front" : "rear",
1018
+ "lenses": lenses,
1019
+ "minZoom": Float(device.minAvailableVideoZoomFactor),
1020
+ "maxZoom": Float(device.maxAvailableVideoZoomFactor),
1021
+ "isLogical": device.isVirtualDevice
1022
+ ]
1023
+
1024
+ devices.append(deviceData)
1025
+ }
1026
+
1027
+ call.resolve(["devices": devices])
1028
+ }
1029
+
1030
+ @objc func getZoom(_ call: CAPPluginCall) {
1031
+ guard isInitialized else {
1032
+ call.reject("Camera not initialized")
1033
+ return
1034
+ }
1035
+
1036
+ do {
1037
+ let zoomInfo = try self.cameraController.getZoom()
1038
+ let lensInfo = try self.cameraController.getCurrentLensInfo()
1039
+
1040
+ var minZoom = zoomInfo.min
1041
+ var maxZoom = zoomInfo.max
1042
+ var currentZoom = zoomInfo.current
1043
+
1044
+ // If using the multi-lens camera, translate the native zoom values for JS
1045
+ if self.cameraController.isUsingMultiLensVirtualCamera {
1046
+ minZoom -= 0.5
1047
+ maxZoom -= 0.5
1048
+ currentZoom -= 0.5
1049
+ }
1050
+
1051
+ call.resolve([
1052
+ "min": minZoom,
1053
+ "max": maxZoom,
1054
+ "current": currentZoom,
1055
+ "lens": [
1056
+ "focalLength": lensInfo.focalLength,
1057
+ "deviceType": lensInfo.deviceType,
1058
+ "baseZoomRatio": lensInfo.baseZoomRatio,
1059
+ "digitalZoom": Float(currentZoom) / lensInfo.baseZoomRatio
1060
+ ]
1061
+ ])
1062
+ } catch {
1063
+ call.reject("Failed to get zoom: \(error.localizedDescription)")
1064
+ }
1065
+ }
1066
+
1067
+ @objc func setZoom(_ call: CAPPluginCall) {
1068
+ guard isInitialized else {
1069
+ call.reject("Camera not initialized")
1070
+ return
1071
+ }
1072
+
1073
+ guard var level = call.getFloat("level") else {
1074
+ call.reject("level parameter is required")
1075
+ return
1076
+ }
1077
+
1078
+ // If using the multi-lens camera, translate the JS zoom value for the native layer
1079
+ if self.cameraController.isUsingMultiLensVirtualCamera {
1080
+ level += 0.5
1081
+ }
1082
+
1083
+ let ramp = call.getBool("ramp") ?? true
1084
+ let autoFocus = call.getBool("autoFocus") ?? true
1085
+
1086
+ do {
1087
+ try self.cameraController.setZoom(level: CGFloat(level), ramp: ramp, autoFocus: autoFocus)
1088
+ call.resolve()
1089
+ } catch {
1090
+ call.reject("Failed to set zoom: \(error.localizedDescription)")
1091
+ }
1092
+ }
1093
+
1094
+ @objc func getFlashMode(_ call: CAPPluginCall) {
1095
+ guard isInitialized else {
1096
+ call.reject("Camera not initialized")
1097
+ return
1098
+ }
1099
+
1100
+ do {
1101
+ let flashMode = try self.cameraController.getFlashMode()
1102
+ call.resolve(["flashMode": flashMode])
1103
+ } catch {
1104
+ call.reject("Failed to get flash mode: \(error.localizedDescription)")
1105
+ }
1106
+ }
1107
+
1108
+ @objc func setDeviceId(_ call: CAPPluginCall) {
1109
+ guard isInitialized else {
1110
+ call.reject("Camera not initialized")
1111
+ return
1112
+ }
1113
+
1114
+ guard let deviceId = call.getString("deviceId") else {
1115
+ call.reject("deviceId parameter is required")
1116
+ return
1117
+ }
1118
+
1119
+ // Disable user interaction during device swap
1120
+ self.previewView.isUserInteractionEnabled = false
1121
+
1122
+ do {
1123
+ try self.cameraController.swapToDevice(deviceId: deviceId)
1124
+
1125
+ // Update preview layer frame without animation
1126
+ CATransaction.begin()
1127
+ CATransaction.setDisableActions(true)
1128
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
1129
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
1130
+ CATransaction.commit()
1131
+
1132
+ self.previewView.isUserInteractionEnabled = true
1133
+
1134
+ // Ensure webview remains transparent after device switch
1135
+ self.makeWebViewTransparent()
1136
+
1137
+ call.resolve()
1138
+ } catch {
1139
+ self.previewView.isUserInteractionEnabled = true
1140
+ call.reject("Failed to swap to device \(deviceId): \(error.localizedDescription)")
1141
+ }
1142
+ }
1143
+
1144
+ @objc func getDeviceId(_ call: CAPPluginCall) {
1145
+ guard isInitialized else {
1146
+ call.reject("Camera not initialized")
1147
+ return
1148
+ }
1149
+
1150
+ do {
1151
+ let deviceId = try self.cameraController.getCurrentDeviceId()
1152
+ call.resolve(["deviceId": deviceId])
1153
+ } catch {
1154
+ call.reject("Failed to get device ID: \(error.localizedDescription)")
1155
+ }
1156
+ }
1157
+
1158
+ // MARK: - Capacitor Permissions
1159
+
1160
+ private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
1161
+ print("[CameraPreview] requestLocationPermission called")
1162
+ if self.locationManager == nil {
1163
+ print("[CameraPreview] Creating location manager")
1164
+ self.locationManager = CLLocationManager()
1165
+ self.locationManager?.delegate = self
1166
+ }
1167
+
1168
+ let authStatus = self.locationManager?.authorizationStatus
1169
+ print("[CameraPreview] Current authorization status: \(String(describing: authStatus))")
1170
+
1171
+ switch authStatus {
1172
+ case .authorizedWhenInUse, .authorizedAlways:
1173
+ print("[CameraPreview] Location already authorized")
1174
+ completion(true)
1175
+ case .notDetermined:
1176
+ print("[CameraPreview] Location not determined, requesting authorization...")
1177
+ self.permissionCompletion = completion
1178
+ self.locationManager?.requestWhenInUseAuthorization()
1179
+ case .denied, .restricted:
1180
+ print("[CameraPreview] Location denied or restricted")
1181
+ completion(false)
1182
+ case .none:
1183
+ print("[CameraPreview] Location manager authorization status is nil")
1184
+ completion(false)
1185
+ @unknown default:
1186
+ print("[CameraPreview] Unknown authorization status")
1187
+ completion(false)
1188
+ }
1189
+ }
1190
+
1191
+ private var permissionCompletion: ((Bool) -> Void)?
1192
+
1193
+ public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
1194
+ let status = manager.authorizationStatus
1195
+ print("[CameraPreview] locationManagerDidChangeAuthorization called, status: \(status.rawValue), thread: \(Thread.current)")
1196
+
1197
+ // Handle pending capture call if we have one
1198
+ if let callID = self.permissionCallID, self.waitingForLocation {
1199
+ print("[CameraPreview] Found pending capture call ID: \(callID)")
1200
+
1201
+ let handleAuthorization = {
1202
+ print("[CameraPreview] Getting saved call on thread: \(Thread.current)")
1203
+ guard let call = self.bridge?.savedCall(withID: callID) else {
1204
+ print("[CameraPreview] ERROR: Could not retrieve saved call")
1205
+ self.permissionCallID = nil
1206
+ self.waitingForLocation = false
1207
+ return
1208
+ }
1209
+ print("[CameraPreview] Successfully retrieved saved call")
1210
+
1211
+ switch status {
1212
+ case .authorizedWhenInUse, .authorizedAlways:
1213
+ print("[CameraPreview] Location authorized, getting location for capture")
1214
+ self.getCurrentLocation { _ in
1215
+ self.performCapture(call: call)
1216
+ self.bridge?.releaseCall(call)
1217
+ self.permissionCallID = nil
1218
+ self.waitingForLocation = false
1219
+ }
1220
+ case .denied, .restricted:
1221
+ print("[CameraPreview] Location denied, rejecting capture")
1222
+ call.reject("Location permission denied")
1223
+ self.bridge?.releaseCall(call)
1224
+ self.permissionCallID = nil
1225
+ self.waitingForLocation = false
1226
+ case .notDetermined:
1227
+ print("[CameraPreview] Authorization not determined yet")
1228
+ // Don't do anything, wait for user response
1229
+ @unknown default:
1230
+ print("[CameraPreview] Unknown status, rejecting capture")
1231
+ call.reject("Unknown location permission status")
1232
+ self.bridge?.releaseCall(call)
1233
+ self.permissionCallID = nil
1234
+ self.waitingForLocation = false
1235
+ }
1236
+ }
1237
+
1238
+ // Check if we're already on main thread
1239
+ if Thread.isMainThread {
1240
+ print("[CameraPreview] Already on main thread")
1241
+ handleAuthorization()
1242
+ } else {
1243
+ print("[CameraPreview] Not on main thread, dispatching")
1244
+ DispatchQueue.main.async(execute: handleAuthorization)
1245
+ }
1246
+ } else {
1247
+ print("[CameraPreview] No pending capture call")
1248
+ }
1249
+ }
1250
+
1251
+ public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
1252
+ print("[CameraPreview] locationManager didFailWithError: \(error.localizedDescription)")
1253
+ }
1254
+
1255
+ private func getCurrentLocation(completion: @escaping (CLLocation?) -> Void) {
1256
+ print("[CameraPreview] getCurrentLocation called")
1257
+ self.locationCompletion = completion
1258
+ self.locationManager?.startUpdatingLocation()
1259
+ print("[CameraPreview] Started updating location")
1260
+ }
1261
+
1262
+ private var locationCompletion: ((CLLocation?) -> Void)?
1263
+
1264
+ public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
1265
+ print("[CameraPreview] locationManager didUpdateLocations called, locations count: \(locations.count)")
1266
+ self.currentLocation = locations.last
1267
+ if let completion = locationCompletion {
1268
+ print("[CameraPreview] Calling location completion with location: \(self.currentLocation?.description ?? "nil")")
1269
+ self.locationManager?.stopUpdatingLocation()
1270
+ completion(self.currentLocation)
1271
+ locationCompletion = nil
1272
+ } else {
1273
+ print("[CameraPreview] No location completion handler found")
1274
+ }
1275
+ }
1276
+
1277
+ private func saveImageDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1278
+ // Check if NSPhotoLibraryUsageDescription is present in Info.plist
1279
+ guard Bundle.main.object(forInfoDictionaryKey: "NSPhotoLibraryUsageDescription") != nil else {
1280
+ let error = NSError(domain: "CameraPreview", code: 2, userInfo: [
1281
+ NSLocalizedDescriptionKey: "NSPhotoLibraryUsageDescription key missing from Info.plist. Add this key with a description of how your app uses photo library access."
1282
+ ])
1283
+ completion(false, error)
1284
+ return
1285
+ }
1286
+
1287
+ let status = PHPhotoLibrary.authorizationStatus()
1288
+
1289
+ switch status {
1290
+ case .authorized:
1291
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1292
+ case .notDetermined:
1293
+ PHPhotoLibrary.requestAuthorization { newStatus in
1294
+ if newStatus == .authorized {
1295
+ self.performSaveDataToGallery(imageData: imageData, completion: completion)
1296
+ } else {
1297
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1298
+ }
1299
+ }
1300
+ case .denied, .restricted:
1301
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1302
+ case .limited:
1303
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1304
+ @unknown default:
1305
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown photo library authorization status"]))
1306
+ }
1307
+ }
1308
+
1309
+ private func performSaveDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1310
+ // Create a temporary file to write the JPEG data with EXIF
1311
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
1312
+
1313
+ do {
1314
+ try imageData.write(to: tempURL)
1315
+
1316
+ PHPhotoLibrary.shared().performChanges({
1317
+ PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: tempURL)
1318
+ }, completionHandler: { success, error in
1319
+ // Clean up temporary file
1320
+ try? FileManager.default.removeItem(at: tempURL)
1321
+
1322
+ completion(success, error)
1323
+ })
1324
+ } catch {
1325
+ completion(false, error)
1326
+ }
1327
+ }
1328
+
1329
+ private func calculateCameraFrame(x: CGFloat? = nil, y: CGFloat? = nil, width: CGFloat? = nil, height: CGFloat? = nil, aspectRatio: String? = nil) -> CGRect {
1330
+ // Use provided values or existing ones
1331
+ let currentWidth = width ?? self.width ?? UIScreen.main.bounds.size.width
1332
+ let currentHeight = height ?? self.height ?? UIScreen.main.bounds.size.height
1333
+ let currentX = x ?? self.posX ?? -1
1334
+ let currentY = y ?? self.posY ?? -1
1335
+ let currentAspectRatio = aspectRatio ?? self.aspectRatio
1336
+
1337
+ let paddingBottom = self.paddingBottom ?? 0
1338
+ let adjustedHeight = currentHeight - CGFloat(paddingBottom)
1339
+
1340
+ // Cache webView dimensions for performance
1341
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
1342
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
1343
+
1344
+ var finalX = currentX
1345
+ var finalY = currentY
1346
+ var finalWidth = currentWidth
1347
+ var finalHeight = adjustedHeight
1348
+
1349
+ // Handle auto-centering when position is -1
1350
+ if currentX == -1 || currentY == -1 {
1351
+ // Only override dimensions if aspect ratio is provided and no explicit dimensions given
1352
+ if let ratio = currentAspectRatio,
1353
+ currentWidth == UIScreen.main.bounds.size.width &&
1354
+ currentHeight == UIScreen.main.bounds.size.height {
1355
+ finalWidth = webViewWidth
1356
+
1357
+ // Calculate height based on aspect ratio
1358
+ let ratioParts = ratio.split(separator: ":").compactMap { Double($0) }
1359
+ if ratioParts.count == 2 {
1360
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1361
+ let ratioValue = ratioParts[1] / ratioParts[0]
1362
+ finalHeight = finalWidth / CGFloat(ratioValue)
1363
+ }
1364
+ }
1365
+
1366
+ // Center horizontally if x is -1
1367
+ if currentX == -1 {
1368
+ finalX = (webViewWidth - finalWidth) / 2
1369
+ } else {
1370
+ finalX = currentX
1371
+ }
1372
+
1373
+ // Position vertically if y is -1
1374
+ if currentY == -1 {
1375
+ // Use full screen height for positioning
1376
+ let screenHeight = UIScreen.main.bounds.size.height
1377
+ switch self.positioning {
1378
+ case "top":
1379
+ finalY = 0
1380
+ print("[CameraPreview] Positioning at top: finalY=0")
1381
+ case "bottom":
1382
+ finalY = screenHeight - finalHeight
1383
+ print("[CameraPreview] Positioning at bottom: screenHeight=\(screenHeight), finalHeight=\(finalHeight), finalY=\(finalY)")
1384
+ default: // "center"
1385
+ finalY = (screenHeight - finalHeight) / 2
1386
+ print("[CameraPreview] Centering vertically: screenHeight=\(screenHeight), finalHeight=\(finalHeight), finalY=\(finalY)")
1387
+ }
1388
+ } else {
1389
+ finalY = currentY
1390
+ }
1391
+ }
1392
+
1393
+ return CGRect(x: finalX, y: finalY, width: finalWidth, height: finalHeight)
1394
+ }
1395
+
1396
+ private func updateCameraFrame() {
1397
+ guard let width = self.width, let height = self.height, let posX = self.posX, let posY = self.posY else {
1398
+ return
1399
+ }
1400
+
1401
+ // Ensure UI operations happen on main thread
1402
+ guard Thread.isMainThread else {
1403
+ DispatchQueue.main.async {
1404
+ self.updateCameraFrame()
1405
+ }
1406
+ return
1407
+ }
1408
+
1409
+ // Calculate the base frame using the factorized method
1410
+ var frame = calculateCameraFrame()
1411
+
1412
+ // Apply aspect ratio adjustments only if not auto-centering
1413
+ if posX != -1 && posY != -1, let aspectRatio = self.aspectRatio {
1414
+ let ratioParts = aspectRatio.split(separator: ":").compactMap { Double($0) }
1415
+ if ratioParts.count == 2 {
1416
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1417
+ let ratio = ratioParts[1] / ratioParts[0]
1418
+ let currentRatio = Double(frame.width) / Double(frame.height)
1419
+
1420
+ if currentRatio > ratio {
1421
+ let newWidth = Double(frame.height) * ratio
1422
+ frame.origin.x = frame.origin.x + (frame.width - CGFloat(newWidth)) / 2
1423
+ frame.size.width = CGFloat(newWidth)
1424
+ } else {
1425
+ let newHeight = Double(frame.width) / ratio
1426
+ frame.origin.y = frame.origin.y + (frame.height - CGFloat(newHeight)) / 2
1427
+ frame.size.height = CGFloat(newHeight)
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ // Disable ALL animations for frame updates - we want instant positioning
1433
+ CATransaction.begin()
1434
+ CATransaction.setDisableActions(true)
1435
+
1436
+ // Batch UI updates for better performance
1437
+ if self.previewView == nil {
1438
+ self.previewView = UIView(frame: frame)
1439
+ self.previewView.backgroundColor = UIColor.clear
1440
+ } else {
1441
+ self.previewView.frame = frame
1442
+ }
1443
+
1444
+ // Update preview layer frame efficiently
1445
+ if let previewLayer = self.cameraController.previewLayer {
1446
+ previewLayer.frame = self.previewView.bounds
1447
+ }
1448
+
1449
+ // Update grid overlay frame if it exists
1450
+ if let gridOverlay = self.cameraController.gridOverlayView {
1451
+ gridOverlay.frame = self.previewView.bounds
1452
+ }
1453
+
1454
+ CATransaction.commit()
1455
+ }
1456
+
1457
+ @objc func getPreviewSize(_ call: CAPPluginCall) {
1458
+ guard self.isInitialized else {
1459
+ call.reject("camera not started")
1460
+ return
1461
+ }
1462
+
1463
+ DispatchQueue.main.async {
1464
+ var result = JSObject()
1465
+ result["x"] = Double(self.previewView.frame.origin.x)
1466
+ result["y"] = Double(self.previewView.frame.origin.y)
1467
+ result["width"] = Double(self.previewView.frame.width)
1468
+ result["height"] = Double(self.previewView.frame.height)
1469
+ call.resolve(result)
1470
+ }
1471
+ }
1472
+
1473
+ @objc func setPreviewSize(_ call: CAPPluginCall) {
1474
+ guard self.isInitialized else {
1475
+ call.reject("camera not started")
1476
+ return
1477
+ }
1478
+
1479
+ // Always set to -1 for auto-centering if not explicitly provided
1480
+ if let x = call.getInt("x") {
1481
+ self.posX = CGFloat(x)
1482
+ } else {
1483
+ self.posX = -1 // Auto-center if X not provided
1484
+ }
1485
+
1486
+ if let y = call.getInt("y") {
1487
+ self.posY = CGFloat(y)
1488
+ } else {
1489
+ self.posY = -1 // Auto-center if Y not provided
1490
+ }
1491
+
1492
+ if let width = call.getInt("width") { self.width = CGFloat(width) }
1493
+ if let height = call.getInt("height") { self.height = CGFloat(height) }
1494
+
1495
+ DispatchQueue.main.async {
1496
+ // Direct update without animation for better performance
1497
+ self.updateCameraFrame()
1498
+ self.makeWebViewTransparent()
1499
+
1500
+ // Return the actual preview bounds
1501
+ var result = JSObject()
1502
+ result["x"] = Double(self.previewView.frame.origin.x)
1503
+ result["y"] = Double(self.previewView.frame.origin.y)
1504
+ result["width"] = Double(self.previewView.frame.width)
1505
+ result["height"] = Double(self.previewView.frame.height)
1506
+ call.resolve(result)
1507
+ }
1508
+ }
1509
+
1510
+ @objc func setFocus(_ call: CAPPluginCall) {
1511
+ guard isInitialized else {
1512
+ call.reject("Camera not initialized")
1513
+ return
1514
+ }
1515
+
1516
+ guard let x = call.getFloat("x"), let y = call.getFloat("y") else {
1517
+ call.reject("x and y parameters are required")
1518
+ return
1519
+ }
1520
+
1521
+ // Reject if values are outside 0-1 range
1522
+ if x < 0 || x > 1 || y < 0 || y > 1 {
1523
+ call.reject("Focus coordinates must be between 0 and 1")
1524
+ return
1525
+ }
1526
+
1527
+ DispatchQueue.main.async {
1528
+ do {
1529
+ // Convert normalized coordinates to view coordinates
1530
+ let viewX = CGFloat(x) * self.previewView.bounds.width
1531
+ let viewY = CGFloat(y) * self.previewView.bounds.height
1532
+ let focusPoint = CGPoint(x: viewX, y: viewY)
1533
+
1534
+ // Convert view coordinates to device coordinates
1535
+ guard let previewLayer = self.cameraController.previewLayer else {
1536
+ call.reject("Preview layer not available")
1537
+ return
1538
+ }
1539
+ let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: focusPoint)
1540
+
1541
+ try self.cameraController.setFocus(at: devicePoint, showIndicator: true, in: self.previewView)
1542
+ call.resolve()
1543
+ } catch {
1544
+ call.reject("Failed to set focus: \(error.localizedDescription)")
1545
+ }
1546
+ }
1547
+ }
1548
+ }