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