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

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 +212 -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 +731 -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 +292 -29
  23. package/dist/esm/definitions.d.ts +148 -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 +555 -97
  27. package/dist/esm/web.js.map +1 -1
  28. package/dist/plugin.cjs.js +553 -97
  29. package/dist/plugin.cjs.js.map +1 -1
  30. package/dist/plugin.js +553 -97
  31. package/dist/plugin.js.map +1 -1
  32. package/ios/Sources/CapgoCameraPreview/CameraController.swift +888 -214
  33. package/ios/Sources/CapgoCameraPreview/GridOverlayView.swift +65 -0
  34. package/ios/Sources/CapgoCameraPreview/Plugin.swift +967 -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,42 @@ 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 permissionCallID: String?
93
+ private var waitingForLocation: Bool = false
79
94
 
80
95
  // MARK: - Transparency Methods
81
-
96
+
82
97
  private func makeWebViewTransparent() {
83
98
  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
99
+
107
100
  DispatchQueue.main.async {
101
+ // Define a recursive function to traverse the view hierarchy
102
+ func makeSubviewsTransparent(_ view: UIView) {
103
+ // Set the background color to clear
104
+ view.backgroundColor = .clear
105
+
106
+ // Recurse for all subviews
107
+ for subview in view.subviews {
108
+ makeSubviewsTransparent(subview)
109
+ }
110
+ }
111
+
112
+ // Set the main webView to be transparent
113
+ webView.isOpaque = false
114
+ webView.backgroundColor = .clear
115
+
116
+ // Recursively make all subviews transparent
117
+ makeSubviewsTransparent(webView)
118
+
119
+ // Also ensure the webview's container is transparent
120
+ webView.superview?.backgroundColor = .clear
121
+
122
+ // Force a layout pass to apply changes
108
123
  webView.setNeedsLayout()
109
124
  webView.layoutIfNeeded()
110
125
  }
@@ -121,15 +136,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
121
136
  let paddingBottom = self.paddingBottom ?? 0
122
137
  let height = heightValue - paddingBottom
123
138
 
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
- }
139
+ // Handle auto-centering during rotation
140
+ // Always use the factorized method for consistent positioning
141
+ self.updateCameraFrame()
133
142
 
134
143
  if let connection = self.cameraController.fileVideoOutput?.connection(with: .video) {
135
144
  switch UIDevice.current.orientation {
@@ -147,13 +156,135 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
147
156
  }
148
157
 
149
158
  cameraController.updateVideoOrientation()
150
-
159
+
160
+ cameraController.updateVideoOrientation()
161
+
162
+ // Update grid overlay frame if it exists - no animation
163
+ if let gridOverlay = self.cameraController.gridOverlayView {
164
+ CATransaction.begin()
165
+ CATransaction.setDisableActions(true)
166
+ gridOverlay.frame = previewView.bounds
167
+ CATransaction.commit()
168
+ }
169
+
151
170
  // Ensure webview remains transparent after rotation
152
171
  if self.isInitialized {
153
172
  self.makeWebViewTransparent()
154
173
  }
155
174
  }
156
-
175
+
176
+ @objc func setAspectRatio(_ call: CAPPluginCall) {
177
+ guard self.isInitialized else {
178
+ call.reject("camera not started")
179
+ return
180
+ }
181
+
182
+ guard let newAspectRatio = call.getString("aspectRatio") else {
183
+ call.reject("aspectRatio parameter is required")
184
+ return
185
+ }
186
+
187
+ self.aspectRatio = newAspectRatio
188
+
189
+ DispatchQueue.main.async {
190
+ // When aspect ratio changes, always auto-center the view
191
+ // This ensures consistent behavior where changing aspect ratio recenters the view
192
+ self.posX = -1
193
+ self.posY = -1
194
+
195
+ // Calculate maximum size based on aspect ratio
196
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
197
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
198
+ let paddingBottom = self.paddingBottom ?? 0
199
+
200
+ // Calculate available space
201
+ let availableWidth: CGFloat
202
+ let availableHeight: CGFloat
203
+
204
+ if self.posX == -1 || self.posY == -1 {
205
+ // Auto-centering mode - use full dimensions
206
+ availableWidth = webViewWidth
207
+ availableHeight = webViewHeight - paddingBottom
208
+ } else {
209
+ // Manual positioning - calculate remaining space
210
+ availableWidth = webViewWidth - self.posX!
211
+ availableHeight = webViewHeight - self.posY! - paddingBottom
212
+ }
213
+
214
+ // Parse aspect ratio - convert to portrait orientation for camera use
215
+ let ratioParts = newAspectRatio.split(separator: ":").map { Double($0) ?? 1.0 }
216
+ // For camera, we want portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
217
+ let ratio = ratioParts[1] / ratioParts[0]
218
+
219
+ // Calculate maximum size that fits the aspect ratio in available space
220
+ let maxWidthByHeight = availableHeight * CGFloat(ratio)
221
+ let maxHeightByWidth = availableWidth / CGFloat(ratio)
222
+
223
+ if maxWidthByHeight <= availableWidth {
224
+ // Height is the limiting factor
225
+ self.width = maxWidthByHeight
226
+ self.height = availableHeight
227
+ } else {
228
+ // Width is the limiting factor
229
+ self.width = availableWidth
230
+ self.height = maxHeightByWidth
231
+ }
232
+
233
+ print("[CameraPreview] Aspect ratio changed to \(newAspectRatio), new size: \(self.width!)x\(self.height!)")
234
+
235
+ self.updateCameraFrame()
236
+
237
+ // Return the actual preview bounds
238
+ var result = JSObject()
239
+ result["x"] = Double(self.previewView.frame.origin.x)
240
+ result["y"] = Double(self.previewView.frame.origin.y)
241
+ result["width"] = Double(self.previewView.frame.width)
242
+ result["height"] = Double(self.previewView.frame.height)
243
+ call.resolve(result)
244
+ }
245
+ }
246
+
247
+ @objc func getAspectRatio(_ call: CAPPluginCall) {
248
+ guard self.isInitialized else {
249
+ call.reject("camera not started")
250
+ return
251
+ }
252
+ call.resolve(["aspectRatio": self.aspectRatio ?? "4:3"])
253
+ }
254
+
255
+ @objc func setGridMode(_ call: CAPPluginCall) {
256
+ guard self.isInitialized else {
257
+ call.reject("camera not started")
258
+ return
259
+ }
260
+
261
+ guard let gridMode = call.getString("gridMode") else {
262
+ call.reject("gridMode parameter is required")
263
+ return
264
+ }
265
+
266
+ self.gridMode = gridMode
267
+
268
+ // Update grid overlay
269
+ DispatchQueue.main.async {
270
+ if gridMode == "none" {
271
+ self.cameraController.removeGridOverlay()
272
+ } else {
273
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: gridMode)
274
+ }
275
+ }
276
+
277
+ call.resolve()
278
+ }
279
+
280
+ @objc func getGridMode(_ call: CAPPluginCall) {
281
+ guard self.isInitialized else {
282
+ call.reject("camera not started")
283
+ return
284
+ }
285
+ call.resolve(["gridMode": self.gridMode])
286
+ }
287
+
157
288
  @objc func appDidBecomeActive() {
158
289
  if self.isInitialized {
159
290
  DispatchQueue.main.async {
@@ -161,7 +292,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
161
292
  }
162
293
  }
163
294
  }
164
-
295
+
165
296
  @objc func appWillEnterForeground() {
166
297
  if self.isInitialized {
167
298
  DispatchQueue.main.async {
@@ -265,21 +396,34 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
265
396
  self.cameraPosition = call.getString("position") ?? "rear"
266
397
  let deviceId = call.getString("deviceId")
267
398
  let cameraMode = call.getBool("cameraMode") ?? false
268
- self.highResolutionOutput = call.getBool("enableHighResolution") ?? false
269
- self.cameraController.highResolutionOutput = self.highResolutionOutput
270
399
 
271
- if call.getInt("width") != nil {
272
- self.width = CGFloat(call.getInt("width")!)
400
+ // Set width - use screen width if not provided or if 0
401
+ if let width = call.getInt("width"), width > 0 {
402
+ self.width = CGFloat(width)
273
403
  } else {
274
404
  self.width = UIScreen.main.bounds.size.width
275
405
  }
276
- if call.getInt("height") != nil {
277
- self.height = CGFloat(call.getInt("height")!)
406
+
407
+ // Set height - use screen height if not provided or if 0
408
+ if let height = call.getInt("height"), height > 0 {
409
+ self.height = CGFloat(height)
278
410
  } else {
279
411
  self.height = UIScreen.main.bounds.size.height
280
412
  }
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
413
+
414
+ // Set x position - use exact CSS pixel value from web view, or mark for centering
415
+ if let x = call.getInt("x") {
416
+ self.posX = CGFloat(x)
417
+ } else {
418
+ self.posX = -1 // Use -1 to indicate auto-centering
419
+ }
420
+
421
+ // Set y position - use exact CSS pixel value from web view, or mark for centering
422
+ if let y = call.getInt("y") {
423
+ self.posY = CGFloat(y)
424
+ } else {
425
+ self.posY = -1 // Use -1 to indicate auto-centering
426
+ }
283
427
  if call.getInt("paddingBottom") != nil {
284
428
  self.paddingBottom = CGFloat(call.getInt("paddingBottom")!)
285
429
  }
@@ -288,7 +432,18 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
288
432
  self.toBack = call.getBool("toBack") ?? true
289
433
  self.storeToFile = call.getBool("storeToFile") ?? false
290
434
  self.enableZoom = call.getBool("enableZoom") ?? false
291
- self.disableAudio = call.getBool("disableAudio") ?? false
435
+ self.disableAudio = call.getBool("disableAudio") ?? true
436
+ self.aspectRatio = call.getString("aspectRatio")
437
+ self.gridMode = call.getString("gridMode") ?? "none"
438
+ let initialZoomLevel = call.getFloat("initialZoomLevel") ?? 1.0
439
+ if self.aspectRatio != nil && (call.getInt("width") != nil || call.getInt("height") != nil) {
440
+ call.reject("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.")
441
+ return
442
+ }
443
+
444
+ print("[CameraPreview] Camera start parameters - aspectRatio: \(String(describing: self.aspectRatio)), gridMode: \(self.gridMode)")
445
+ print("[CameraPreview] Screen dimensions: \(UIScreen.main.bounds.size)")
446
+ 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
447
 
293
448
  AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
294
449
  guard granted else {
@@ -296,48 +451,80 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
296
451
  return
297
452
  }
298
453
 
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
-
454
+ if self.cameraController.captureSession?.isRunning ?? false {
455
+ call.reject("camera already started")
456
+ } else {
457
+ self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, initialZoomLevel: Float(initialZoomLevel)) {error in
458
+ if let error = error {
459
+ print(error)
460
+ call.reject(error.localizedDescription)
461
+ return
462
+ }
463
+ DispatchQueue.main.async {
464
+ self.completeStartCamera(call: call)
336
465
  }
337
466
  }
338
467
  }
339
468
  })
469
+ }
340
470
 
471
+ private func completeStartCamera(call: CAPPluginCall) {
472
+ // Create and configure the preview view first
473
+ self.updateCameraFrame()
474
+
475
+ // Make webview transparent - comprehensive approach
476
+ self.makeWebViewTransparent()
477
+
478
+ // Add the preview view to the webview itself to use same coordinate system
479
+ self.webView?.addSubview(self.previewView)
480
+ if self.toBack! {
481
+ self.webView?.sendSubviewToBack(self.previewView)
482
+ }
483
+
484
+ // Display the camera preview on the configured view
485
+ try? self.cameraController.displayPreview(on: self.previewView)
486
+
487
+ self.cameraController.setupGestures(target: self.previewView, enableZoom: self.enableZoom!)
488
+
489
+ // Add grid overlay if enabled
490
+ if self.gridMode != "none" {
491
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: self.gridMode)
492
+ }
493
+
494
+ if self.rotateWhenOrientationChanged == true {
495
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.rotated), name: UIDevice.orientationDidChangeNotification, object: nil)
496
+ }
497
+
498
+ // Add observers for app state changes to maintain transparency
499
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
500
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
501
+
502
+ self.isInitializing = false
503
+ self.isInitialized = true
504
+
505
+ // Set up callback to wait for first frame before resolving
506
+ self.cameraController.firstFrameReadyCallback = { [weak self] in
507
+ guard let self = self else { return }
508
+
509
+ DispatchQueue.main.async {
510
+ var returnedObject = JSObject()
511
+ returnedObject["width"] = self.previewView.frame.width as any JSValue
512
+ returnedObject["height"] = self.previewView.frame.height as any JSValue
513
+ returnedObject["x"] = self.previewView.frame.origin.x as any JSValue
514
+ returnedObject["y"] = self.previewView.frame.origin.y as any JSValue
515
+ call.resolve(returnedObject)
516
+ }
517
+ }
518
+
519
+ // If already received first frame (unlikely but possible), resolve immediately
520
+ if self.cameraController.hasReceivedFirstFrame {
521
+ var returnedObject = JSObject()
522
+ returnedObject["width"] = self.previewView.frame.width as any JSValue
523
+ returnedObject["height"] = self.previewView.frame.height as any JSValue
524
+ returnedObject["x"] = self.previewView.frame.origin.x as any JSValue
525
+ returnedObject["y"] = self.previewView.frame.origin.y as any JSValue
526
+ call.resolve(returnedObject)
527
+ }
341
528
  }
342
529
 
343
530
  @objc func flip(_ call: CAPPluginCall) {
@@ -346,68 +533,46 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
346
533
  return
347
534
  }
348
535
 
349
- DispatchQueue.main.async { [weak self] in
350
- guard let self = self else {
351
- call.reject("Camera controller deallocated")
352
- return
353
- }
536
+ // Disable user interaction during flip
537
+ self.previewView.isUserInteractionEnabled = false
354
538
 
355
- // Disable user interaction during flip
356
- self.previewView.isUserInteractionEnabled = false
539
+ do {
540
+ try self.cameraController.switchCameras()
357
541
 
358
- // Perform camera switch on background thread
359
- DispatchQueue.global(qos: .userInitiated).async {
360
- var retryCount = 0
361
- let maxRetries = 3
542
+ // Update preview layer frame without animation
543
+ CATransaction.begin()
544
+ CATransaction.setDisableActions(true)
545
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
546
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
547
+ CATransaction.commit()
362
548
 
363
- func attemptFlip() {
364
- do {
365
- try self.cameraController.switchCameras()
549
+ self.previewView.isUserInteractionEnabled = true
366
550
 
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
- }
551
+ // Ensure webview remains transparent after flip
552
+ self.makeWebViewTransparent()
393
553
 
394
- attemptFlip()
395
- }
554
+ call.resolve()
555
+ } catch {
556
+ self.previewView.isUserInteractionEnabled = true
557
+ print("Failed to flip camera: \(error.localizedDescription)")
558
+ call.reject("Failed to flip camera: \(error.localizedDescription)")
396
559
  }
397
560
  }
398
561
 
399
562
  @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
- }
563
+ if self.isInitializing {
564
+ call.reject("cannot stop camera while initialization is in progress")
565
+ return
566
+ }
567
+ if !self.isInitialized {
568
+ call.reject("camera not initialized")
569
+ return
570
+ }
409
571
 
572
+ // UI operations must be on main thread
573
+ DispatchQueue.main.async {
410
574
  // Always attempt to stop and clean up, regardless of captureSession state
575
+ self.cameraController.removeGridOverlay()
411
576
  if let previewView = self.previewView {
412
577
  previewView.removeFromSuperview()
413
578
  self.previewView = nil
@@ -417,7 +582,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
417
582
  self.isInitialized = false
418
583
  self.isInitializing = false
419
584
  self.cameraController.cleanup()
420
-
585
+
421
586
  // Remove notification observers
422
587
  NotificationCenter.default.removeObserver(self)
423
588
 
@@ -436,39 +601,159 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
436
601
  }
437
602
 
438
603
  @objc func capture(_ call: CAPPluginCall) {
439
- DispatchQueue.main.async {
604
+ let withExifLocation = call.getBool("withExifLocation", false)
605
+ print("[CameraPreview] capture called, withExifLocation: \(withExifLocation)")
606
+
607
+ if withExifLocation {
608
+ print("[CameraPreview] Location required for capture")
609
+
610
+ // Check location services before main thread dispatch
611
+ guard CLLocationManager.locationServicesEnabled() else {
612
+ print("[CameraPreview] Location services are disabled")
613
+ call.reject("Location services are disabled")
614
+ return
615
+ }
616
+
617
+ // Check if Info.plist has the required key
618
+ guard Bundle.main.object(forInfoDictionaryKey: "NSLocationWhenInUseUsageDescription") != nil else {
619
+ print("[CameraPreview] ERROR: NSLocationWhenInUseUsageDescription key missing from Info.plist")
620
+ call.reject("NSLocationWhenInUseUsageDescription key missing from Info.plist. Add this key with a description of how your app uses location.")
621
+ return
622
+ }
623
+
624
+ // Ensure location manager setup happens on main thread
625
+ DispatchQueue.main.async {
626
+ if self.locationManager == nil {
627
+ print("[CameraPreview] Creating location manager on main thread")
628
+ self.locationManager = CLLocationManager()
629
+ self.locationManager?.delegate = self
630
+ self.locationManager?.desiredAccuracy = kCLLocationAccuracyBest
631
+ print("[CameraPreview] Location manager created, delegate set to: \(self)")
632
+ }
633
+
634
+ // Check current authorization status
635
+ let currentStatus = self.locationManager?.authorizationStatus ?? .notDetermined
636
+ print("[CameraPreview] Current authorization status: \(currentStatus.rawValue)")
637
+
638
+ switch currentStatus {
639
+ case .authorizedWhenInUse, .authorizedAlways:
640
+ // Already authorized, get location and capture
641
+ print("[CameraPreview] Already authorized, getting location immediately")
642
+ self.getCurrentLocation { _ in
643
+ self.performCapture(call: call)
644
+ }
645
+
646
+ case .denied, .restricted:
647
+ // Permission denied
648
+ print("[CameraPreview] Location permission denied")
649
+ call.reject("Location permission denied")
650
+
651
+ case .notDetermined:
652
+ // Need to request permission
653
+ print("[CameraPreview] Location permission not determined, requesting...")
654
+ // Save the call for the delegate callback
655
+ print("[CameraPreview] Saving call for location authorization flow")
656
+ self.bridge?.saveCall(call)
657
+ self.permissionCallID = call.callbackId
658
+ self.waitingForLocation = true
659
+
660
+ // Request authorization - this will trigger locationManagerDidChangeAuthorization
661
+ print("[CameraPreview] Requesting location authorization...")
662
+ self.locationManager?.requestWhenInUseAuthorization()
663
+ // The delegate will handle the rest
664
+
665
+ @unknown default:
666
+ print("[CameraPreview] Unknown authorization status")
667
+ call.reject("Unknown location permission status")
668
+ }
669
+ }
670
+ } else {
671
+ print("[CameraPreview] No location required, performing capture directly")
672
+ self.performCapture(call: call)
673
+ }
674
+ }
675
+
676
+ private func performCapture(call: CAPPluginCall) {
677
+ print("[CameraPreview] performCapture called")
678
+ let quality = call.getFloat("quality", 85)
679
+ let saveToGallery = call.getBool("saveToGallery", false)
680
+ let withExifLocation = call.getBool("withExifLocation", false)
681
+ let width = call.getInt("width")
682
+ let height = call.getInt("height")
683
+ let aspectRatio = call.getString("aspectRatio")
684
+
685
+ // Check for conflicting parameters
686
+ if aspectRatio != nil && (width != nil || height != nil) {
687
+ print("[CameraPreview] Error: Cannot set both aspectRatio and size (width/height)")
688
+ call.reject("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.")
689
+ return
690
+ }
440
691
 
441
- let quality = call.getFloat("quality", 85)
442
- let saveToGallery = call.getBool("saveToGallery", false)
692
+ // Use the stored aspectRatio if none is provided and no width/height is specified
693
+ // If no aspectRatio was set at all, use "4:3" as default (matching getAspectRatio behavior)
694
+ let captureAspectRatio: String? = if width == nil && height == nil && aspectRatio == nil {
695
+ self.aspectRatio ?? "4:3"
696
+ } else {
697
+ aspectRatio
698
+ }
699
+
700
+ print("[CameraPreview] Capture params - quality: \(quality), saveToGallery: \(saveToGallery), withExifLocation: \(withExifLocation), width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil"), using aspectRatio: \(captureAspectRatio ?? "nil")")
701
+ print("[CameraPreview] Current location: \(self.currentLocation?.description ?? "nil")")
443
702
 
444
- self.cameraController.captureImage(quality: quality) { (image, error) in
703
+ self.cameraController.captureImage(width: width, height: height, aspectRatio: captureAspectRatio, quality: quality, gpsLocation: self.currentLocation) { (image, error) in
704
+ print("[CameraPreview] captureImage callback received")
705
+ DispatchQueue.main.async {
706
+ print("[CameraPreview] Processing capture on main thread")
445
707
  if let error = error {
708
+ print("[CameraPreview] Capture error: \(error.localizedDescription)")
446
709
  call.reject(error.localizedDescription)
447
710
  return
448
711
  }
449
712
 
713
+ guard let image = image,
714
+ let imageDataWithExif = self.createImageDataWithExif(
715
+ from: image,
716
+ quality: Int(quality),
717
+ location: withExifLocation ? self.currentLocation : nil
718
+ )
719
+ else {
720
+ print("[CameraPreview] Failed to create image data with EXIF")
721
+ call.reject("Failed to create image data with EXIF")
722
+ return
723
+ }
724
+
725
+ print("[CameraPreview] Image data created, size: \(imageDataWithExif.count) bytes")
726
+
450
727
  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)
728
+ print("[CameraPreview] Saving to gallery...")
729
+ self.saveImageDataToGallery(imageData: imageDataWithExif) { success, error in
730
+ print("[CameraPreview] Save to gallery completed, success: \(success), error: \(error?.localizedDescription ?? "none")")
731
+ let exifData = self.getExifData(from: imageDataWithExif)
732
+ let base64Image = imageDataWithExif.base64EncodedString()
733
+
734
+ var result = JSObject()
735
+ result["value"] = base64Image
736
+ result["exif"] = exifData
737
+ result["gallerySaved"] = success
738
+ if !success, let error = error {
739
+ result["galleryError"] = error.localizedDescription
456
740
  }
457
- })
458
- }
459
741
 
460
- guard let imageData = image?.jpegData(compressionQuality: CGFloat(quality / 100.0)) else {
461
- call.reject("Failed to get JPEG data from image")
462
- return
742
+ print("[CameraPreview] Resolving capture call with gallery save")
743
+ call.resolve(result)
744
+ }
745
+ } else {
746
+ print("[CameraPreview] Not saving to gallery, returning image data")
747
+ let exifData = self.getExifData(from: imageDataWithExif)
748
+ let base64Image = imageDataWithExif.base64EncodedString()
749
+
750
+ var result = JSObject()
751
+ result["value"] = base64Image
752
+ result["exif"] = exifData
753
+
754
+ print("[CameraPreview] Resolving capture call")
755
+ call.resolve(result)
463
756
  }
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
757
  }
473
758
  }
474
759
  }
@@ -479,45 +764,109 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
479
764
  let exifDict = imageProperties[kCGImagePropertyExifDictionary as String] as? [String: Any] else {
480
765
  return [:]
481
766
  }
482
-
767
+
483
768
  var exifData = JSObject()
484
769
  for (key, value) in exifDict {
485
- exifData[key] = value
770
+ // Convert value to JSValue-compatible type
771
+ if let stringValue = value as? String {
772
+ exifData[key] = stringValue
773
+ } else if let numberValue = value as? NSNumber {
774
+ exifData[key] = numberValue
775
+ } else if let boolValue = value as? Bool {
776
+ exifData[key] = boolValue
777
+ } else if let arrayValue = value as? [Any] {
778
+ exifData[key] = arrayValue
779
+ } else if let dictValue = value as? [String: Any] {
780
+ exifData[key] = JSObject(_immutableCocoaDictionary: NSMutableDictionary(dictionary: dictValue))
781
+ } else {
782
+ // Convert other types to string as fallback
783
+ exifData[key] = String(describing: value)
784
+ }
486
785
  }
487
-
786
+
488
787
  return exifData
489
788
  }
490
789
 
790
+ private func createImageDataWithExif(from image: UIImage, quality: Int, location: CLLocation?) -> Data? {
791
+ guard let originalImageData = image.jpegData(compressionQuality: CGFloat(Double(quality) / 100.0)) else {
792
+ return nil
793
+ }
794
+
795
+ guard let imageSource = CGImageSourceCreateWithData(originalImageData as CFData, nil),
796
+ let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
797
+ let cgImage = image.cgImage else {
798
+ return originalImageData
799
+ }
800
+
801
+ let mutableData = NSMutableData()
802
+ guard let destination = CGImageDestinationCreateWithData(mutableData, kUTTypeJPEG, 1, nil) else {
803
+ return originalImageData
804
+ }
805
+
806
+ var finalProperties = imageProperties
807
+
808
+ // Add GPS location if available
809
+ if let location = location {
810
+ let formatter = DateFormatter()
811
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
812
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
813
+
814
+ let gpsDict: [String: Any] = [
815
+ kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
816
+ kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
817
+ kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
818
+ kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
819
+ kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
820
+ kCGImagePropertyGPSAltitude as String: location.altitude,
821
+ kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
822
+ ]
823
+
824
+ finalProperties[kCGImagePropertyGPSDictionary as String] = gpsDict
825
+ }
826
+
827
+ // Create or update TIFF dictionary for device info
828
+ var tiffDict = finalProperties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] ?? [:]
829
+ tiffDict[kCGImagePropertyTIFFMake as String] = "Apple"
830
+ tiffDict[kCGImagePropertyTIFFModel as String] = UIDevice.current.model
831
+ finalProperties[kCGImagePropertyTIFFDictionary as String] = tiffDict
832
+
833
+ CGImageDestinationAddImage(destination, cgImage, finalProperties as CFDictionary)
834
+
835
+ if CGImageDestinationFinalize(destination) {
836
+ return mutableData as Data
837
+ }
838
+
839
+ return originalImageData
840
+ }
841
+
491
842
  @objc func captureSample(_ call: CAPPluginCall) {
492
- DispatchQueue.main.async {
493
- let quality: Int? = call.getInt("quality", 85)
843
+ let quality: Int? = call.getInt("quality", 85)
494
844
 
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
- }
845
+ self.cameraController.captureSample { image, error in
846
+ guard let image = image else {
847
+ print("Image capture error: \(String(describing: error))")
848
+ call.reject("Image capture error: \(String(describing: error))")
849
+ return
850
+ }
501
851
 
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
- }
852
+ let imageData: Data?
853
+ if self.cameraPosition == "front" {
854
+ let flippedImage = image.withHorizontallyFlippedOrientation()
855
+ imageData = flippedImage.jpegData(compressionQuality: CGFloat(quality!/100))
856
+ } else {
857
+ imageData = image.jpegData(compressionQuality: CGFloat(quality!/100))
858
+ }
509
859
 
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
- }
860
+ if self.storeToFile == false {
861
+ let imageBase64 = imageData?.base64EncodedString()
862
+ call.resolve(["value": imageBase64!])
863
+ } else {
864
+ do {
865
+ let fileUrl = self.getTempFilePath()
866
+ try imageData?.write(to: fileUrl)
867
+ call.resolve(["value": fileUrl.absoluteString])
868
+ } catch {
869
+ call.reject("Error writing image to file")
521
870
  }
522
871
  }
523
872
  }
@@ -572,31 +921,27 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
572
921
  }
573
922
 
574
923
  @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
- }
924
+ do {
925
+ try self.cameraController.captureVideo()
926
+ call.resolve()
927
+ } catch {
928
+ call.reject(error.localizedDescription)
582
929
  }
583
930
  }
584
931
 
585
932
  @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)
933
+ self.cameraController.stopRecording { (fileURL, error) in
934
+ guard let fileURL = fileURL else {
935
+ print(error ?? "Video capture error")
936
+ guard let error = error else {
937
+ call.reject("Video capture error")
595
938
  return
596
939
  }
597
-
598
- call.resolve(["videoFilePath": fileURL.absoluteString])
940
+ call.reject(error.localizedDescription)
941
+ return
599
942
  }
943
+
944
+ call.resolve(["videoFilePath": fileURL.absoluteString])
600
945
  }
601
946
  }
602
947
 
@@ -627,20 +972,20 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
627
972
  // Collect all devices by position
628
973
  for device in session.devices {
629
974
  var lenses: [[String: Any]] = []
630
-
975
+
631
976
  let constituentDevices = device.isVirtualDevice ? device.constituentDevices : [device]
632
-
977
+
633
978
  for lensDevice in constituentDevices {
634
979
  var deviceType: String
635
980
  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"
981
+ case .builtInWideAngleCamera: deviceType = "wideAngle"
982
+ case .builtInUltraWideCamera: deviceType = "ultraWide"
983
+ case .builtInTelephotoCamera: deviceType = "telephoto"
984
+ case .builtInDualCamera: deviceType = "dual"
985
+ case .builtInDualWideCamera: deviceType = "dualWide"
986
+ case .builtInTripleCamera: deviceType = "triple"
987
+ case .builtInTrueDepthCamera: deviceType = "trueDepth"
988
+ default: deviceType = "unknown"
644
989
  }
645
990
 
646
991
  var baseZoomRatio: Float = 1.0
@@ -649,7 +994,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
649
994
  } else if lensDevice.deviceType == .builtInTelephotoCamera {
650
995
  baseZoomRatio = 2.0 // A common value for telephoto lenses
651
996
  }
652
-
997
+
653
998
  let lensInfo: [String: Any] = [
654
999
  "label": lensDevice.localizedName,
655
1000
  "deviceType": deviceType,
@@ -660,7 +1005,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
660
1005
  ]
661
1006
  lenses.append(lensInfo)
662
1007
  }
663
-
1008
+
664
1009
  let deviceData: [String: Any] = [
665
1010
  "deviceId": device.uniqueID,
666
1011
  "label": device.localizedName,
@@ -670,7 +1015,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
670
1015
  "maxZoom": Float(device.maxAvailableVideoZoomFactor),
671
1016
  "isLogical": device.isVirtualDevice
672
1017
  ]
673
-
1018
+
674
1019
  devices.append(deviceData)
675
1020
  }
676
1021
 
@@ -686,7 +1031,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
686
1031
  do {
687
1032
  let zoomInfo = try self.cameraController.getZoom()
688
1033
  let lensInfo = try self.cameraController.getCurrentLensInfo()
689
-
1034
+
690
1035
  var minZoom = zoomInfo.min
691
1036
  var maxZoom = zoomInfo.max
692
1037
  var currentZoom = zoomInfo.current
@@ -731,9 +1076,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
731
1076
  }
732
1077
 
733
1078
  let ramp = call.getBool("ramp") ?? true
1079
+ let autoFocus = call.getBool("autoFocus") ?? true
734
1080
 
735
1081
  do {
736
- try self.cameraController.setZoom(level: CGFloat(level), ramp: ramp)
1082
+ try self.cameraController.setZoom(level: CGFloat(level), ramp: ramp, autoFocus: autoFocus)
737
1083
  call.resolve()
738
1084
  } catch {
739
1085
  call.reject("Failed to set zoom: \(error.localizedDescription)")
@@ -765,40 +1111,32 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
765
1111
  return
766
1112
  }
767
1113
 
768
- DispatchQueue.main.async { [weak self] in
769
- guard let self = self else {
770
- call.reject("Camera controller deallocated")
771
- return
772
- }
1114
+ // Disable user interaction during device swap
1115
+ self.previewView.isUserInteractionEnabled = false
773
1116
 
774
- // Disable user interaction during device swap
775
- self.previewView.isUserInteractionEnabled = false
1117
+ do {
1118
+ try self.cameraController.swapToDevice(deviceId: deviceId)
776
1119
 
777
- DispatchQueue.global(qos: .userInitiated).async {
778
- do {
779
- try self.cameraController.swapToDevice(deviceId: deviceId)
1120
+ // Update preview layer frame without animation
1121
+ CATransaction.begin()
1122
+ CATransaction.setDisableActions(true)
1123
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
1124
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
1125
+ CATransaction.commit()
780
1126
 
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
- }
1127
+ self.previewView.isUserInteractionEnabled = true
1128
+
1129
+ // Ensure webview remains transparent after device switch
1130
+ self.makeWebViewTransparent()
1131
+
1132
+ call.resolve()
1133
+ } catch {
1134
+ self.previewView.isUserInteractionEnabled = true
1135
+ call.reject("Failed to swap to device \(deviceId): \(error.localizedDescription)")
798
1136
  }
799
1137
  }
800
1138
 
801
- @objc func getDeviceId(_ call: CAPPluginCall) {
1139
+ @objc func getDeviceId(_ call: CAPPluginCall) {
802
1140
  guard isInitialized else {
803
1141
  call.reject("Camera not initialized")
804
1142
  return
@@ -812,6 +1150,385 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
812
1150
  }
813
1151
  }
814
1152
 
1153
+ // MARK: - Capacitor Permissions
1154
+
1155
+ private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
1156
+ print("[CameraPreview] requestLocationPermission called")
1157
+ if self.locationManager == nil {
1158
+ print("[CameraPreview] Creating location manager")
1159
+ self.locationManager = CLLocationManager()
1160
+ self.locationManager?.delegate = self
1161
+ }
1162
+
1163
+ let authStatus = self.locationManager?.authorizationStatus
1164
+ print("[CameraPreview] Current authorization status: \(String(describing: authStatus))")
1165
+
1166
+ switch authStatus {
1167
+ case .authorizedWhenInUse, .authorizedAlways:
1168
+ print("[CameraPreview] Location already authorized")
1169
+ completion(true)
1170
+ case .notDetermined:
1171
+ print("[CameraPreview] Location not determined, requesting authorization...")
1172
+ self.permissionCompletion = completion
1173
+ self.locationManager?.requestWhenInUseAuthorization()
1174
+ case .denied, .restricted:
1175
+ print("[CameraPreview] Location denied or restricted")
1176
+ completion(false)
1177
+ case .none:
1178
+ print("[CameraPreview] Location manager authorization status is nil")
1179
+ completion(false)
1180
+ @unknown default:
1181
+ print("[CameraPreview] Unknown authorization status")
1182
+ completion(false)
1183
+ }
1184
+ }
1185
+
1186
+ private var permissionCompletion: ((Bool) -> Void)?
1187
+
1188
+ public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
1189
+ let status = manager.authorizationStatus
1190
+ print("[CameraPreview] locationManagerDidChangeAuthorization called, status: \(status.rawValue), thread: \(Thread.current)")
1191
+
1192
+ // Handle pending capture call if we have one
1193
+ if let callID = self.permissionCallID, self.waitingForLocation {
1194
+ print("[CameraPreview] Found pending capture call ID: \(callID)")
1195
+
1196
+ let handleAuthorization = {
1197
+ print("[CameraPreview] Getting saved call on thread: \(Thread.current)")
1198
+ guard let call = self.bridge?.savedCall(withID: callID) else {
1199
+ print("[CameraPreview] ERROR: Could not retrieve saved call")
1200
+ self.permissionCallID = nil
1201
+ self.waitingForLocation = false
1202
+ return
1203
+ }
1204
+ print("[CameraPreview] Successfully retrieved saved call")
1205
+
1206
+ switch status {
1207
+ case .authorizedWhenInUse, .authorizedAlways:
1208
+ print("[CameraPreview] Location authorized, getting location for capture")
1209
+ self.getCurrentLocation { _ in
1210
+ self.performCapture(call: call)
1211
+ self.bridge?.releaseCall(call)
1212
+ self.permissionCallID = nil
1213
+ self.waitingForLocation = false
1214
+ }
1215
+ case .denied, .restricted:
1216
+ print("[CameraPreview] Location denied, rejecting capture")
1217
+ call.reject("Location permission denied")
1218
+ self.bridge?.releaseCall(call)
1219
+ self.permissionCallID = nil
1220
+ self.waitingForLocation = false
1221
+ case .notDetermined:
1222
+ print("[CameraPreview] Authorization not determined yet")
1223
+ // Don't do anything, wait for user response
1224
+ @unknown default:
1225
+ print("[CameraPreview] Unknown status, rejecting capture")
1226
+ call.reject("Unknown location permission status")
1227
+ self.bridge?.releaseCall(call)
1228
+ self.permissionCallID = nil
1229
+ self.waitingForLocation = false
1230
+ }
1231
+ }
1232
+
1233
+ // Check if we're already on main thread
1234
+ if Thread.isMainThread {
1235
+ print("[CameraPreview] Already on main thread")
1236
+ handleAuthorization()
1237
+ } else {
1238
+ print("[CameraPreview] Not on main thread, dispatching")
1239
+ DispatchQueue.main.async(execute: handleAuthorization)
1240
+ }
1241
+ } else {
1242
+ print("[CameraPreview] No pending capture call")
1243
+ }
1244
+ }
1245
+
1246
+ public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
1247
+ print("[CameraPreview] locationManager didFailWithError: \(error.localizedDescription)")
1248
+ }
1249
+
1250
+ private func getCurrentLocation(completion: @escaping (CLLocation?) -> Void) {
1251
+ print("[CameraPreview] getCurrentLocation called")
1252
+ self.locationCompletion = completion
1253
+ self.locationManager?.startUpdatingLocation()
1254
+ print("[CameraPreview] Started updating location")
1255
+ }
1256
+
1257
+ private var locationCompletion: ((CLLocation?) -> Void)?
1258
+
1259
+ public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
1260
+ print("[CameraPreview] locationManager didUpdateLocations called, locations count: \(locations.count)")
1261
+ self.currentLocation = locations.last
1262
+ if let completion = locationCompletion {
1263
+ print("[CameraPreview] Calling location completion with location: \(self.currentLocation?.description ?? "nil")")
1264
+ self.locationManager?.stopUpdatingLocation()
1265
+ completion(self.currentLocation)
1266
+ locationCompletion = nil
1267
+ } else {
1268
+ print("[CameraPreview] No location completion handler found")
1269
+ }
1270
+ }
1271
+
1272
+ private func saveImageDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1273
+ // Check if NSPhotoLibraryUsageDescription is present in Info.plist
1274
+ guard Bundle.main.object(forInfoDictionaryKey: "NSPhotoLibraryUsageDescription") != nil else {
1275
+ let error = NSError(domain: "CameraPreview", code: 2, userInfo: [
1276
+ NSLocalizedDescriptionKey: "NSPhotoLibraryUsageDescription key missing from Info.plist. Add this key with a description of how your app uses photo library access."
1277
+ ])
1278
+ completion(false, error)
1279
+ return
1280
+ }
1281
+
1282
+ let status = PHPhotoLibrary.authorizationStatus()
1283
+
1284
+ switch status {
1285
+ case .authorized:
1286
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1287
+ case .notDetermined:
1288
+ PHPhotoLibrary.requestAuthorization { newStatus in
1289
+ if newStatus == .authorized {
1290
+ self.performSaveDataToGallery(imageData: imageData, completion: completion)
1291
+ } else {
1292
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1293
+ }
1294
+ }
1295
+ case .denied, .restricted:
1296
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1297
+ case .limited:
1298
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1299
+ @unknown default:
1300
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown photo library authorization status"]))
1301
+ }
1302
+ }
1303
+
1304
+ private func performSaveDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1305
+ // Create a temporary file to write the JPEG data with EXIF
1306
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
1307
+
1308
+ do {
1309
+ try imageData.write(to: tempURL)
1310
+
1311
+ PHPhotoLibrary.shared().performChanges({
1312
+ PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: tempURL)
1313
+ }, completionHandler: { success, error in
1314
+ // Clean up temporary file
1315
+ try? FileManager.default.removeItem(at: tempURL)
1316
+
1317
+ completion(success, error)
1318
+ })
1319
+ } catch {
1320
+ completion(false, error)
1321
+ }
1322
+ }
1323
+
1324
+ private func calculateCameraFrame(x: CGFloat? = nil, y: CGFloat? = nil, width: CGFloat? = nil, height: CGFloat? = nil, aspectRatio: String? = nil) -> CGRect {
1325
+ // Use provided values or existing ones
1326
+ let currentWidth = width ?? self.width ?? UIScreen.main.bounds.size.width
1327
+ let currentHeight = height ?? self.height ?? UIScreen.main.bounds.size.height
1328
+ let currentX = x ?? self.posX ?? -1
1329
+ let currentY = y ?? self.posY ?? -1
1330
+ let currentAspectRatio = aspectRatio ?? self.aspectRatio
1331
+
1332
+ let paddingBottom = self.paddingBottom ?? 0
1333
+ let adjustedHeight = currentHeight - CGFloat(paddingBottom)
1334
+
1335
+ // Cache webView dimensions for performance
1336
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
1337
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
1338
+
1339
+ var finalX = currentX
1340
+ var finalY = currentY
1341
+ var finalWidth = currentWidth
1342
+ var finalHeight = adjustedHeight
1343
+
1344
+ // Handle auto-centering when position is -1
1345
+ if currentX == -1 || currentY == -1 {
1346
+ // Only override dimensions if aspect ratio is provided and no explicit dimensions given
1347
+ if let ratio = currentAspectRatio,
1348
+ currentWidth == UIScreen.main.bounds.size.width &&
1349
+ currentHeight == UIScreen.main.bounds.size.height {
1350
+ finalWidth = webViewWidth
1351
+
1352
+ // Calculate height based on aspect ratio
1353
+ let ratioParts = ratio.split(separator: ":").compactMap { Double($0) }
1354
+ if ratioParts.count == 2 {
1355
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1356
+ let ratioValue = ratioParts[1] / ratioParts[0]
1357
+ finalHeight = finalWidth / CGFloat(ratioValue)
1358
+ }
1359
+ }
1360
+
1361
+ // Center horizontally if x is -1
1362
+ if currentX == -1 {
1363
+ finalX = (webViewWidth - finalWidth) / 2
1364
+ } else {
1365
+ finalX = currentX
1366
+ }
1367
+
1368
+ // Center vertically if y is -1
1369
+ if currentY == -1 {
1370
+ // Use full screen height for centering
1371
+ let screenHeight = UIScreen.main.bounds.size.height
1372
+ finalY = (screenHeight - finalHeight) / 2
1373
+ print("[CameraPreview] Centering vertically: screenHeight=\(screenHeight), finalHeight=\(finalHeight), finalY=\(finalY)")
1374
+ } else {
1375
+ finalY = currentY
1376
+ }
1377
+ }
1378
+
1379
+ return CGRect(x: finalX, y: finalY, width: finalWidth, height: finalHeight)
1380
+ }
1381
+
1382
+ private func updateCameraFrame() {
1383
+ guard let width = self.width, let height = self.height, let posX = self.posX, let posY = self.posY else {
1384
+ return
1385
+ }
1386
+
1387
+ // Ensure UI operations happen on main thread
1388
+ guard Thread.isMainThread else {
1389
+ DispatchQueue.main.async {
1390
+ self.updateCameraFrame()
1391
+ }
1392
+ return
1393
+ }
1394
+
1395
+ // Calculate the base frame using the factorized method
1396
+ var frame = calculateCameraFrame()
1397
+
1398
+ // Apply aspect ratio adjustments only if not auto-centering
1399
+ if posX != -1 && posY != -1, let aspectRatio = self.aspectRatio {
1400
+ let ratioParts = aspectRatio.split(separator: ":").compactMap { Double($0) }
1401
+ if ratioParts.count == 2 {
1402
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1403
+ let ratio = ratioParts[1] / ratioParts[0]
1404
+ let currentRatio = Double(frame.width) / Double(frame.height)
1405
+
1406
+ if currentRatio > ratio {
1407
+ let newWidth = Double(frame.height) * ratio
1408
+ frame.origin.x = frame.origin.x + (frame.width - CGFloat(newWidth)) / 2
1409
+ frame.size.width = CGFloat(newWidth)
1410
+ } else {
1411
+ let newHeight = Double(frame.width) / ratio
1412
+ frame.origin.y = frame.origin.y + (frame.height - CGFloat(newHeight)) / 2
1413
+ frame.size.height = CGFloat(newHeight)
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ // Disable ALL animations for frame updates - we want instant positioning
1419
+ CATransaction.begin()
1420
+ CATransaction.setDisableActions(true)
1421
+
1422
+ // Batch UI updates for better performance
1423
+ if self.previewView == nil {
1424
+ self.previewView = UIView(frame: frame)
1425
+ self.previewView.backgroundColor = UIColor.clear
1426
+ } else {
1427
+ self.previewView.frame = frame
1428
+ }
1429
+
1430
+ // Update preview layer frame efficiently
1431
+ if let previewLayer = self.cameraController.previewLayer {
1432
+ previewLayer.frame = self.previewView.bounds
1433
+ }
1434
+
1435
+ // Update grid overlay frame if it exists
1436
+ if let gridOverlay = self.cameraController.gridOverlayView {
1437
+ gridOverlay.frame = self.previewView.bounds
1438
+ }
1439
+
1440
+ CATransaction.commit()
1441
+ }
1442
+
1443
+ @objc func getPreviewSize(_ call: CAPPluginCall) {
1444
+ guard self.isInitialized else {
1445
+ call.reject("camera not started")
1446
+ return
1447
+ }
1448
+
1449
+ DispatchQueue.main.async {
1450
+ var result = JSObject()
1451
+ result["x"] = Double(self.previewView.frame.origin.x)
1452
+ result["y"] = Double(self.previewView.frame.origin.y)
1453
+ result["width"] = Double(self.previewView.frame.width)
1454
+ result["height"] = Double(self.previewView.frame.height)
1455
+ call.resolve(result)
1456
+ }
1457
+ }
1458
+
1459
+ @objc func setPreviewSize(_ call: CAPPluginCall) {
1460
+ guard self.isInitialized else {
1461
+ call.reject("camera not started")
1462
+ return
1463
+ }
1464
+
1465
+ // Always set to -1 for auto-centering if not explicitly provided
1466
+ if let x = call.getInt("x") {
1467
+ self.posX = CGFloat(x)
1468
+ } else {
1469
+ self.posX = -1 // Auto-center if X not provided
1470
+ }
1471
+
1472
+ if let y = call.getInt("y") {
1473
+ self.posY = CGFloat(y)
1474
+ } else {
1475
+ self.posY = -1 // Auto-center if Y not provided
1476
+ }
1477
+
1478
+ if let width = call.getInt("width") { self.width = CGFloat(width) }
1479
+ if let height = call.getInt("height") { self.height = CGFloat(height) }
1480
+
1481
+ DispatchQueue.main.async {
1482
+ // Direct update without animation for better performance
1483
+ self.updateCameraFrame()
1484
+ self.makeWebViewTransparent()
1485
+
1486
+ // Return the actual preview bounds
1487
+ var result = JSObject()
1488
+ result["x"] = Double(self.previewView.frame.origin.x)
1489
+ result["y"] = Double(self.previewView.frame.origin.y)
1490
+ result["width"] = Double(self.previewView.frame.width)
1491
+ result["height"] = Double(self.previewView.frame.height)
1492
+ call.resolve(result)
1493
+ }
1494
+ }
1495
+
1496
+ @objc func setFocus(_ call: CAPPluginCall) {
1497
+ guard isInitialized else {
1498
+ call.reject("Camera not initialized")
1499
+ return
1500
+ }
1501
+
1502
+ guard let x = call.getFloat("x"), let y = call.getFloat("y") else {
1503
+ call.reject("x and y parameters are required")
1504
+ return
1505
+ }
1506
+
1507
+ // Reject if values are outside 0-1 range
1508
+ if x < 0 || x > 1 || y < 0 || y > 1 {
1509
+ call.reject("Focus coordinates must be between 0 and 1")
1510
+ return
1511
+ }
815
1512
 
1513
+ DispatchQueue.main.async {
1514
+ do {
1515
+ // Convert normalized coordinates to view coordinates
1516
+ let viewX = CGFloat(x) * self.previewView.bounds.width
1517
+ let viewY = CGFloat(y) * self.previewView.bounds.height
1518
+ let focusPoint = CGPoint(x: viewX, y: viewY)
1519
+
1520
+ // Convert view coordinates to device coordinates
1521
+ guard let previewLayer = self.cameraController.previewLayer else {
1522
+ call.reject("Preview layer not available")
1523
+ return
1524
+ }
1525
+ let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: focusPoint)
816
1526
 
1527
+ try self.cameraController.setFocus(at: devicePoint, showIndicator: true, in: self.previewView)
1528
+ call.resolve()
1529
+ } catch {
1530
+ call.reject("Failed to set focus: \(error.localizedDescription)")
1531
+ }
1532
+ }
1533
+ }
817
1534
  }