@capgo/camera-preview 7.4.0-beta.2 → 7.4.0-beta.21

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