@capgo/camera-preview 7.4.0-beta.1 → 7.4.0-beta.11

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 +195 -31
  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 +5 -3
  14. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +473 -88
  15. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +2065 -704
  16. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +95 -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 +152 -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 +235 -6
  23. package/dist/esm/definitions.d.ts +119 -3
  24. package/dist/esm/definitions.js.map +1 -1
  25. package/dist/esm/web.d.ts +47 -3
  26. package/dist/esm/web.js +297 -96
  27. package/dist/esm/web.js.map +1 -1
  28. package/dist/plugin.cjs.js +293 -96
  29. package/dist/plugin.cjs.js.map +1 -1
  30. package/dist/plugin.js +293 -96
  31. package/dist/plugin.js.map +1 -1
  32. package/ios/Sources/CapgoCameraPreview/CameraController.swift +364 -218
  33. package/ios/Sources/CapgoCameraPreview/GridOverlayView.swift +65 -0
  34. package/ios/Sources/CapgoCameraPreview/Plugin.swift +886 -242
  35. package/package.json +1 -1
@@ -1,6 +1,10 @@
1
1
  import Foundation
2
- import Capacitor
3
2
  import AVFoundation
3
+ import Photos
4
+ import Capacitor
5
+ import CoreImage
6
+ import CoreLocation
7
+ import MobileCoreServices
4
8
 
5
9
  extension UIWindow {
6
10
  static var isLandscape: Bool {
@@ -32,7 +36,7 @@ extension UIWindow {
32
36
  * here: https://capacitor.ionicframework.com/docs/plugins/ios
33
37
  */
34
38
  @objc(CameraPreview)
35
- public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
39
+ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
36
40
  public let identifier = "CameraPreviewPlugin"
37
41
  public let jsName = "CameraPreview"
38
42
  public let pluginMethods: [CAPPluginMethod] = [
@@ -54,11 +58,18 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
54
58
  CAPPluginMethod(name: "setZoom", returnType: CAPPluginReturnPromise),
55
59
  CAPPluginMethod(name: "getFlashMode", returnType: CAPPluginReturnPromise),
56
60
  CAPPluginMethod(name: "setDeviceId", returnType: CAPPluginReturnPromise),
57
- 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)
58
68
  ]
59
69
  // Camera state tracking
60
70
  private var isInitializing: Bool = false
61
71
  private var isInitialized: Bool = false
72
+ private var backgroundSession: AVCaptureSession?
62
73
 
63
74
  var previewView: UIView!
64
75
  var cameraPosition = String()
@@ -74,35 +85,41 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
74
85
  var enableZoom: Bool?
75
86
  var highResolutionOutput: Bool = false
76
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
77
94
 
78
95
  // MARK: - Transparency Methods
79
-
96
+
80
97
  private func makeWebViewTransparent() {
81
98
  guard let webView = self.webView else { return }
82
-
83
- // Define a recursive function to traverse the view hierarchy
84
- func makeSubviewsTransparent(_ view: UIView) {
85
- // Set the background color to clear
86
- view.backgroundColor = .clear
87
-
88
- // Recurse for all subviews
89
- for subview in view.subviews {
90
- makeSubviewsTransparent(subview)
91
- }
92
- }
93
-
94
- // Set the main webView to be transparent
95
- webView.isOpaque = false
96
- webView.backgroundColor = .clear
97
-
98
- // Recursively make all subviews transparent
99
- makeSubviewsTransparent(webView)
100
-
101
- // Also ensure the webview's container is transparent
102
- webView.superview?.backgroundColor = .clear
103
-
104
- // Force a layout pass to apply changes
99
+
105
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
106
123
  webView.setNeedsLayout()
107
124
  webView.layoutIfNeeded()
108
125
  }
@@ -119,14 +136,26 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
119
136
  let paddingBottom = self.paddingBottom ?? 0
120
137
  let height = heightValue - paddingBottom
121
138
 
122
- if UIWindow.isLandscape {
123
- previewView.frame = CGRect(x: posY, y: posX, width: max(height, width), height: min(height, width))
124
- self.cameraController.previewLayer?.frame = previewView.frame
125
- }
139
+ // Handle auto-centering during rotation
140
+ if posX == -1 || posY == -1 {
141
+ // Trigger full recalculation for auto-centered views
142
+ self.updateCameraFrame()
143
+ } else {
144
+ // Manual positioning - use original rotation logic with no animation
145
+ CATransaction.begin()
146
+ CATransaction.setDisableActions(true)
147
+
148
+ if UIWindow.isLandscape {
149
+ previewView.frame = CGRect(x: posY, y: posX, width: max(height, width), height: min(height, width))
150
+ self.cameraController.previewLayer?.frame = previewView.bounds
151
+ }
152
+
153
+ if UIWindow.isPortrait {
154
+ previewView.frame = CGRect(x: posX, y: posY, width: min(height, width), height: max(height, width))
155
+ self.cameraController.previewLayer?.frame = previewView.bounds
156
+ }
126
157
 
127
- if UIWindow.isPortrait {
128
- previewView.frame = CGRect(x: posX, y: posY, width: min(height, width), height: max(height, width))
129
- self.cameraController.previewLayer?.frame = previewView.frame
158
+ CATransaction.commit()
130
159
  }
131
160
 
132
161
  if let connection = self.cameraController.fileVideoOutput?.connection(with: .video) {
@@ -145,13 +174,132 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
145
174
  }
146
175
 
147
176
  cameraController.updateVideoOrientation()
148
-
177
+
178
+ cameraController.updateVideoOrientation()
179
+
180
+ // Update grid overlay frame if it exists - no animation
181
+ if let gridOverlay = self.cameraController.gridOverlayView {
182
+ CATransaction.begin()
183
+ CATransaction.setDisableActions(true)
184
+ gridOverlay.frame = previewView.bounds
185
+ CATransaction.commit()
186
+ }
187
+
149
188
  // Ensure webview remains transparent after rotation
150
189
  if self.isInitialized {
151
190
  self.makeWebViewTransparent()
152
191
  }
153
192
  }
154
-
193
+
194
+ @objc func setAspectRatio(_ call: CAPPluginCall) {
195
+ guard self.isInitialized else {
196
+ call.reject("camera not started")
197
+ return
198
+ }
199
+
200
+ guard let newAspectRatio = call.getString("aspectRatio") else {
201
+ call.reject("aspectRatio parameter is required")
202
+ return
203
+ }
204
+
205
+ self.aspectRatio = newAspectRatio
206
+
207
+ DispatchQueue.main.async {
208
+ // When aspect ratio changes, calculate maximum size possible from current position
209
+ if let posX = self.posX, let posY = self.posY {
210
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
211
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
212
+ let paddingBottom = self.paddingBottom ?? 0
213
+
214
+ // Calculate available space from current position
215
+ let availableWidth: CGFloat
216
+ let availableHeight: CGFloat
217
+
218
+ if posX == -1 || posY == -1 {
219
+ // Auto-centering mode - use full dimensions
220
+ availableWidth = webViewWidth
221
+ availableHeight = webViewHeight - paddingBottom
222
+ } else {
223
+ // Manual positioning - calculate remaining space
224
+ availableWidth = webViewWidth - posX
225
+ availableHeight = webViewHeight - posY - paddingBottom
226
+ }
227
+
228
+ // Parse aspect ratio - convert to portrait orientation for camera use
229
+ let ratioParts = newAspectRatio.split(separator: ":").map { Double($0) ?? 1.0 }
230
+ // For camera, we want portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
231
+ let ratio = ratioParts[1] / ratioParts[0]
232
+
233
+ // Calculate maximum size that fits the aspect ratio in available space
234
+ let maxWidthByHeight = availableHeight * CGFloat(ratio)
235
+ let maxHeightByWidth = availableWidth / CGFloat(ratio)
236
+
237
+ if maxWidthByHeight <= availableWidth {
238
+ // Height is the limiting factor
239
+ self.width = maxWidthByHeight
240
+ self.height = availableHeight
241
+ } else {
242
+ // Width is the limiting factor
243
+ self.width = availableWidth
244
+ self.height = maxHeightByWidth
245
+ }
246
+
247
+ print("[CameraPreview] Aspect ratio changed to \(newAspectRatio), new size: \(self.width!)x\(self.height!)")
248
+ }
249
+
250
+ self.updateCameraFrame()
251
+
252
+ // Return the actual preview bounds
253
+ var result = JSObject()
254
+ result["x"] = Double(self.previewView.frame.origin.x)
255
+ result["y"] = Double(self.previewView.frame.origin.y)
256
+ result["width"] = Double(self.previewView.frame.width)
257
+ result["height"] = Double(self.previewView.frame.height)
258
+ call.resolve(result)
259
+ }
260
+ }
261
+
262
+ @objc func getAspectRatio(_ call: CAPPluginCall) {
263
+ guard self.isInitialized else {
264
+ call.reject("camera not started")
265
+ return
266
+ }
267
+ call.resolve(["aspectRatio": self.aspectRatio ?? "4:3"])
268
+ }
269
+
270
+ @objc func setGridMode(_ call: CAPPluginCall) {
271
+ guard self.isInitialized else {
272
+ call.reject("camera not started")
273
+ return
274
+ }
275
+
276
+ guard let gridMode = call.getString("gridMode") else {
277
+ call.reject("gridMode parameter is required")
278
+ return
279
+ }
280
+
281
+ self.gridMode = gridMode
282
+
283
+ // Update grid overlay
284
+ DispatchQueue.main.async {
285
+ if gridMode == "none" {
286
+ self.cameraController.removeGridOverlay()
287
+ } else {
288
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: gridMode)
289
+ }
290
+ }
291
+
292
+ call.resolve()
293
+ }
294
+
295
+ @objc func getGridMode(_ call: CAPPluginCall) {
296
+ guard self.isInitialized else {
297
+ call.reject("camera not started")
298
+ return
299
+ }
300
+ call.resolve(["gridMode": self.gridMode])
301
+ }
302
+
155
303
  @objc func appDidBecomeActive() {
156
304
  if self.isInitialized {
157
305
  DispatchQueue.main.async {
@@ -159,7 +307,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
159
307
  }
160
308
  }
161
309
  }
162
-
310
+
163
311
  @objc func appWillEnterForeground() {
164
312
  if self.isInitialized {
165
313
  DispatchQueue.main.async {
@@ -266,18 +414,33 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
266
414
  self.highResolutionOutput = call.getBool("enableHighResolution") ?? false
267
415
  self.cameraController.highResolutionOutput = self.highResolutionOutput
268
416
 
269
- if call.getInt("width") != nil {
270
- self.width = CGFloat(call.getInt("width")!)
417
+ // Set width - use screen width if not provided or if 0
418
+ if let width = call.getInt("width"), width > 0 {
419
+ self.width = CGFloat(width)
271
420
  } else {
272
421
  self.width = UIScreen.main.bounds.size.width
273
422
  }
274
- if call.getInt("height") != nil {
275
- self.height = CGFloat(call.getInt("height")!)
423
+
424
+ // Set height - use screen height if not provided or if 0
425
+ if let height = call.getInt("height"), height > 0 {
426
+ self.height = CGFloat(height)
276
427
  } else {
277
428
  self.height = UIScreen.main.bounds.size.height
278
429
  }
279
- self.posX = call.getInt("x") != nil ? CGFloat(call.getInt("x")!)/UIScreen.main.scale: 0
280
- 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
430
+
431
+ // Set x position - use exact CSS pixel value from web view, or mark for centering
432
+ if let x = call.getInt("x") {
433
+ self.posX = CGFloat(x)
434
+ } else {
435
+ self.posX = -1 // Use -1 to indicate auto-centering
436
+ }
437
+
438
+ // Set y position - use exact CSS pixel value from web view, or mark for centering
439
+ if let y = call.getInt("y") {
440
+ self.posY = CGFloat(y)
441
+ } else {
442
+ self.posY = -1 // Use -1 to indicate auto-centering
443
+ }
281
444
  if call.getInt("paddingBottom") != nil {
282
445
  self.paddingBottom = CGFloat(call.getInt("paddingBottom")!)
283
446
  }
@@ -286,7 +449,17 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
286
449
  self.toBack = call.getBool("toBack") ?? true
287
450
  self.storeToFile = call.getBool("storeToFile") ?? false
288
451
  self.enableZoom = call.getBool("enableZoom") ?? false
289
- self.disableAudio = call.getBool("disableAudio") ?? false
452
+ self.disableAudio = call.getBool("disableAudio") ?? true
453
+ self.aspectRatio = call.getString("aspectRatio")
454
+ self.gridMode = call.getString("gridMode") ?? "none"
455
+ if self.aspectRatio != nil && (call.getInt("width") != nil || call.getInt("height") != nil) {
456
+ call.reject("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.")
457
+ return
458
+ }
459
+
460
+ print("[CameraPreview] Camera start parameters - aspectRatio: \(String(describing: self.aspectRatio)), gridMode: \(self.gridMode)")
461
+ print("[CameraPreview] Screen dimensions: \(UIScreen.main.bounds.size)")
462
+ 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))")
290
463
 
291
464
  AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
292
465
  guard granted else {
@@ -294,48 +467,69 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
294
467
  return
295
468
  }
296
469
 
297
- DispatchQueue.main.async {
298
- if self.cameraController.captureSession?.isRunning ?? false {
299
- call.reject("camera already started")
300
- } else {
301
- self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode) {error in
302
- if let error = error {
303
- print(error)
304
- call.reject(error.localizedDescription)
305
- return
306
- }
307
- let height = self.paddingBottom != nil ? self.height! - self.paddingBottom!: self.height!
308
- self.previewView = UIView(frame: CGRect(x: self.posX ?? 0, y: self.posY ?? 0, width: self.width!, height: height))
309
-
310
- // Make webview transparent - comprehensive approach
311
- self.makeWebViewTransparent()
312
-
313
- self.webView?.superview?.addSubview(self.previewView)
314
- if self.toBack! {
315
- self.webView?.superview?.bringSubviewToFront(self.webView!)
316
- }
317
- try? self.cameraController.displayPreview(on: self.previewView)
318
-
319
- let frontView = self.toBack! ? self.webView : self.previewView
320
- self.cameraController.setupGestures(target: frontView ?? self.previewView, enableZoom: self.enableZoom!)
321
-
322
- if self.rotateWhenOrientationChanged == true {
323
- NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.rotated), name: UIDevice.orientationDidChangeNotification, object: nil)
324
- }
325
-
326
- // Add observers for app state changes to maintain transparency
327
- NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
328
- NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
329
-
330
- self.isInitializing = false
331
- self.isInitialized = true
332
- call.resolve()
470
+ if self.cameraController.captureSession?.isRunning ?? false {
471
+ call.reject("camera already started")
472
+ } else {
473
+ // Pre-initialize session if not already done
474
+ if self.cameraController.captureSession == nil {
475
+ self.cameraController.prepareFullSession()
476
+ }
333
477
 
478
+ self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio) {error in
479
+ if let error = error {
480
+ print(error)
481
+ call.reject(error.localizedDescription)
482
+ return
483
+ }
484
+ DispatchQueue.main.async {
485
+ self.completeStartCamera(call: call)
334
486
  }
335
487
  }
336
488
  }
337
489
  })
490
+ }
491
+
492
+ private func completeStartCamera(call: CAPPluginCall) {
493
+ // Create and configure the preview view first
494
+ self.updateCameraFrame()
495
+
496
+ // Make webview transparent - comprehensive approach
497
+ self.makeWebViewTransparent()
498
+
499
+ // Add the preview view to the webview itself to use same coordinate system
500
+ self.webView?.addSubview(self.previewView)
501
+ if self.toBack! {
502
+ self.webView?.sendSubviewToBack(self.previewView)
503
+ }
504
+
505
+ // Display the camera preview on the configured view
506
+ try? self.cameraController.displayPreview(on: self.previewView)
507
+
508
+ let frontView = self.toBack! ? self.webView : self.previewView
509
+ self.cameraController.setupGestures(target: frontView ?? self.previewView, enableZoom: self.enableZoom!)
510
+
511
+ // Add grid overlay if enabled
512
+ if self.gridMode != "none" {
513
+ self.cameraController.addGridOverlay(to: self.previewView, gridMode: self.gridMode)
514
+ }
515
+
516
+ if self.rotateWhenOrientationChanged == true {
517
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.rotated), name: UIDevice.orientationDidChangeNotification, object: nil)
518
+ }
519
+
520
+ // Add observers for app state changes to maintain transparency
521
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
522
+ NotificationCenter.default.addObserver(self, selector: #selector(CameraPreview.appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
523
+
524
+ self.isInitializing = false
525
+ self.isInitialized = true
338
526
 
527
+ var returnedObject = JSObject()
528
+ returnedObject["width"] = self.previewView.frame.width as any JSValue
529
+ returnedObject["height"] = self.previewView.frame.height as any JSValue
530
+ returnedObject["x"] = self.previewView.frame.origin.x as any JSValue
531
+ returnedObject["y"] = self.previewView.frame.origin.y as any JSValue
532
+ call.resolve(returnedObject)
339
533
  }
340
534
 
341
535
  @objc func flip(_ call: CAPPluginCall) {
@@ -344,68 +538,46 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
344
538
  return
345
539
  }
346
540
 
347
- DispatchQueue.main.async { [weak self] in
348
- guard let self = self else {
349
- call.reject("Camera controller deallocated")
350
- return
351
- }
541
+ // Disable user interaction during flip
542
+ self.previewView.isUserInteractionEnabled = false
352
543
 
353
- // Disable user interaction during flip
354
- self.previewView.isUserInteractionEnabled = false
544
+ do {
545
+ try self.cameraController.switchCameras()
355
546
 
356
- // Perform camera switch on background thread
357
- DispatchQueue.global(qos: .userInitiated).async {
358
- var retryCount = 0
359
- let maxRetries = 3
547
+ // Update preview layer frame without animation
548
+ CATransaction.begin()
549
+ CATransaction.setDisableActions(true)
550
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
551
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
552
+ CATransaction.commit()
360
553
 
361
- func attemptFlip() {
362
- do {
363
- try self.cameraController.switchCameras()
554
+ self.previewView.isUserInteractionEnabled = true
364
555
 
365
- DispatchQueue.main.async {
366
- self.cameraController.previewLayer?.frame = self.previewView.bounds
367
- self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
368
- self.previewView.isUserInteractionEnabled = true
369
-
370
- // Ensure webview remains transparent after flip
371
- self.makeWebViewTransparent()
372
-
373
- call.resolve()
374
- }
375
- } catch {
376
- retryCount += 1
377
-
378
- if retryCount < maxRetries {
379
- DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) {
380
- attemptFlip()
381
- }
382
- } else {
383
- DispatchQueue.main.async {
384
- self.previewView.isUserInteractionEnabled = true
385
- print("Failed to flip camera after \(maxRetries) attempts: \(error.localizedDescription)")
386
- call.reject("Failed to flip camera: \(error.localizedDescription)")
387
- }
388
- }
389
- }
390
- }
556
+ // Ensure webview remains transparent after flip
557
+ self.makeWebViewTransparent()
391
558
 
392
- attemptFlip()
393
- }
559
+ call.resolve()
560
+ } catch {
561
+ self.previewView.isUserInteractionEnabled = true
562
+ print("Failed to flip camera: \(error.localizedDescription)")
563
+ call.reject("Failed to flip camera: \(error.localizedDescription)")
394
564
  }
395
565
  }
396
566
 
397
567
  @objc func stop(_ call: CAPPluginCall) {
398
- DispatchQueue.main.async {
399
- if self.isInitializing {
400
- call.reject("cannot stop camera while initialization is in progress")
401
- return
402
- }
403
- if !self.isInitialized {
404
- call.reject("camera not initialized")
405
- return
406
- }
568
+ if self.isInitializing {
569
+ call.reject("cannot stop camera while initialization is in progress")
570
+ return
571
+ }
572
+ if !self.isInitialized {
573
+ call.reject("camera not initialized")
574
+ return
575
+ }
407
576
 
577
+ // UI operations must be on main thread
578
+ DispatchQueue.main.async {
408
579
  // Always attempt to stop and clean up, regardless of captureSession state
580
+ self.cameraController.removeGridOverlay()
409
581
  if let previewView = self.previewView {
410
582
  previewView.removeFromSuperview()
411
583
  self.previewView = nil
@@ -415,7 +587,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
415
587
  self.isInitialized = false
416
588
  self.isInitializing = false
417
589
  self.cameraController.cleanup()
418
-
590
+
419
591
  // Remove notification observers
420
592
  NotificationCenter.default.removeObserver(self)
421
593
 
@@ -434,75 +606,250 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
434
606
  }
435
607
 
436
608
  @objc func capture(_ call: CAPPluginCall) {
437
- DispatchQueue.main.async {
609
+ let withExifLocation = call.getBool("withExifLocation", false)
610
+ print("[CameraPreview] capture called, withExifLocation: \(withExifLocation)")
438
611
 
439
- let quality: Int? = call.getInt("quality", 85)
612
+ if withExifLocation {
613
+ print("[CameraPreview] Location required for capture")
440
614
 
441
- self.cameraController.captureImage { (image, error) in
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
+ }
442
621
 
443
- guard let image = image else {
444
- print(error ?? "Image capture error")
445
- guard let error = error else {
446
- call.reject("Image capture error")
447
- return
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
+ }
628
+
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)
448
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
+ let quality = call.getFloat("quality", 85)
684
+ let saveToGallery = call.getBool("saveToGallery", false)
685
+ let withExifLocation = call.getBool("withExifLocation", false)
686
+ let width = call.getInt("width")
687
+ let height = call.getInt("height")
688
+
689
+ print("[CameraPreview] Capture params - quality: \(quality), saveToGallery: \(saveToGallery), withExifLocation: \(withExifLocation), width: \(width ?? -1), height: \(height ?? -1)")
690
+ print("[CameraPreview] Current location: \(self.currentLocation?.description ?? "nil")")
691
+
692
+ self.cameraController.captureImage(width: width, height: height, quality: quality, gpsLocation: self.currentLocation) { (image, error) in
693
+ print("[CameraPreview] captureImage callback received")
694
+ DispatchQueue.main.async {
695
+ print("[CameraPreview] Processing capture on main thread")
696
+ if let error = error {
697
+ print("[CameraPreview] Capture error: \(error.localizedDescription)")
449
698
  call.reject(error.localizedDescription)
450
699
  return
451
700
  }
452
- let imageData: Data?
453
- if self.cameraController.currentCameraPosition == .front {
454
- let flippedImage = image.withHorizontallyFlippedOrientation()
455
- imageData = flippedImage.jpegData(compressionQuality: CGFloat(quality!/100))
456
- } else {
457
- imageData = image.jpegData(compressionQuality: CGFloat(quality!/100))
701
+
702
+ guard let imageDataWithExif = self.createImageDataWithExif(from: image!, quality: Int(quality), location: withExifLocation ? self.currentLocation : nil) else {
703
+ print("[CameraPreview] Failed to create image data with EXIF")
704
+ call.reject("Failed to create image data with EXIF")
705
+ return
458
706
  }
459
707
 
460
- if self.storeToFile == false {
461
- let imageBase64 = imageData?.base64EncodedString()
462
- call.resolve(["value": imageBase64!])
463
- } else {
464
- do {
465
- let fileUrl=self.getTempFilePath()
466
- try imageData?.write(to: fileUrl)
467
- call.resolve(["value": fileUrl.absoluteString])
468
- } catch {
469
- call.reject("error writing image to file")
708
+ print("[CameraPreview] Image data created, size: \(imageDataWithExif.count) bytes")
709
+
710
+ if saveToGallery {
711
+ print("[CameraPreview] Saving to gallery...")
712
+ self.saveImageDataToGallery(imageData: imageDataWithExif) { success, error in
713
+ print("[CameraPreview] Save to gallery completed, success: \(success), error: \(error?.localizedDescription ?? "none")")
714
+ let exifData = self.getExifData(from: imageDataWithExif)
715
+ let base64Image = imageDataWithExif.base64EncodedString()
716
+
717
+ var result = JSObject()
718
+ result["value"] = base64Image
719
+ result["exif"] = exifData
720
+ result["gallerySaved"] = success
721
+ if !success, let error = error {
722
+ result["galleryError"] = error.localizedDescription
723
+ }
724
+
725
+ print("[CameraPreview] Resolving capture call with gallery save")
726
+ call.resolve(result)
470
727
  }
728
+ } else {
729
+ print("[CameraPreview] Not saving to gallery, returning image data")
730
+ let exifData = self.getExifData(from: imageDataWithExif)
731
+ let base64Image = imageDataWithExif.base64EncodedString()
732
+
733
+ var result = JSObject()
734
+ result["value"] = base64Image
735
+ result["exif"] = exifData
736
+
737
+ print("[CameraPreview] Resolving capture call")
738
+ call.resolve(result)
471
739
  }
472
740
  }
473
741
  }
474
742
  }
475
743
 
744
+ private func getExifData(from imageData: Data) -> JSObject {
745
+ guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil),
746
+ let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
747
+ let exifDict = imageProperties[kCGImagePropertyExifDictionary as String] as? [String: Any] else {
748
+ return [:]
749
+ }
750
+
751
+ var exifData = JSObject()
752
+ for (key, value) in exifDict {
753
+ // Convert value to JSValue-compatible type
754
+ if let stringValue = value as? String {
755
+ exifData[key] = stringValue
756
+ } else if let numberValue = value as? NSNumber {
757
+ exifData[key] = numberValue
758
+ } else if let boolValue = value as? Bool {
759
+ exifData[key] = boolValue
760
+ } else if let arrayValue = value as? [Any] {
761
+ exifData[key] = arrayValue
762
+ } else if let dictValue = value as? [String: Any] {
763
+ exifData[key] = JSObject(_immutableCocoaDictionary: NSMutableDictionary(dictionary: dictValue))
764
+ } else {
765
+ // Convert other types to string as fallback
766
+ exifData[key] = String(describing: value)
767
+ }
768
+ }
769
+
770
+ return exifData
771
+ }
772
+
773
+ private func createImageDataWithExif(from image: UIImage, quality: Int, location: CLLocation?) -> Data? {
774
+ guard let originalImageData = image.jpegData(compressionQuality: CGFloat(Double(quality) / 100.0)) else {
775
+ return nil
776
+ }
777
+
778
+ guard let imageSource = CGImageSourceCreateWithData(originalImageData as CFData, nil),
779
+ let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
780
+ let cgImage = image.cgImage else {
781
+ return originalImageData
782
+ }
783
+
784
+ let mutableData = NSMutableData()
785
+ guard let destination = CGImageDestinationCreateWithData(mutableData, kUTTypeJPEG, 1, nil) else {
786
+ return originalImageData
787
+ }
788
+
789
+ var finalProperties = imageProperties
790
+
791
+ // Add GPS location if available
792
+ if let location = location {
793
+ let formatter = DateFormatter()
794
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
795
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
796
+
797
+ let gpsDict: [String: Any] = [
798
+ kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
799
+ kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
800
+ kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
801
+ kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
802
+ kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
803
+ kCGImagePropertyGPSAltitude as String: location.altitude,
804
+ kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
805
+ ]
806
+
807
+ finalProperties[kCGImagePropertyGPSDictionary as String] = gpsDict
808
+ }
809
+
810
+ // Create or update TIFF dictionary for device info
811
+ var tiffDict = finalProperties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] ?? [:]
812
+ tiffDict[kCGImagePropertyTIFFMake as String] = "Apple"
813
+ tiffDict[kCGImagePropertyTIFFModel as String] = UIDevice.current.model
814
+ finalProperties[kCGImagePropertyTIFFDictionary as String] = tiffDict
815
+
816
+ CGImageDestinationAddImage(destination, cgImage, finalProperties as CFDictionary)
817
+
818
+ if CGImageDestinationFinalize(destination) {
819
+ return mutableData as Data
820
+ }
821
+
822
+ return originalImageData
823
+ }
824
+
476
825
  @objc func captureSample(_ call: CAPPluginCall) {
477
- DispatchQueue.main.async {
478
- let quality: Int? = call.getInt("quality", 85)
826
+ let quality: Int? = call.getInt("quality", 85)
479
827
 
480
- self.cameraController.captureSample { image, error in
481
- guard let image = image else {
482
- print("Image capture error: \(String(describing: error))")
483
- call.reject("Image capture error: \(String(describing: error))")
484
- return
485
- }
828
+ self.cameraController.captureSample { image, error in
829
+ guard let image = image else {
830
+ print("Image capture error: \(String(describing: error))")
831
+ call.reject("Image capture error: \(String(describing: error))")
832
+ return
833
+ }
486
834
 
487
- let imageData: Data?
488
- if self.cameraPosition == "front" {
489
- let flippedImage = image.withHorizontallyFlippedOrientation()
490
- imageData = flippedImage.jpegData(compressionQuality: CGFloat(quality!/100))
491
- } else {
492
- imageData = image.jpegData(compressionQuality: CGFloat(quality!/100))
493
- }
835
+ let imageData: Data?
836
+ if self.cameraPosition == "front" {
837
+ let flippedImage = image.withHorizontallyFlippedOrientation()
838
+ imageData = flippedImage.jpegData(compressionQuality: CGFloat(quality!/100))
839
+ } else {
840
+ imageData = image.jpegData(compressionQuality: CGFloat(quality!/100))
841
+ }
494
842
 
495
- if self.storeToFile == false {
496
- let imageBase64 = imageData?.base64EncodedString()
497
- call.resolve(["value": imageBase64!])
498
- } else {
499
- do {
500
- let fileUrl = self.getTempFilePath()
501
- try imageData?.write(to: fileUrl)
502
- call.resolve(["value": fileUrl.absoluteString])
503
- } catch {
504
- call.reject("Error writing image to file")
505
- }
843
+ if self.storeToFile == false {
844
+ let imageBase64 = imageData?.base64EncodedString()
845
+ call.resolve(["value": imageBase64!])
846
+ } else {
847
+ do {
848
+ let fileUrl = self.getTempFilePath()
849
+ try imageData?.write(to: fileUrl)
850
+ call.resolve(["value": fileUrl.absoluteString])
851
+ } catch {
852
+ call.reject("Error writing image to file")
506
853
  }
507
854
  }
508
855
  }
@@ -557,31 +904,27 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
557
904
  }
558
905
 
559
906
  @objc func startRecordVideo(_ call: CAPPluginCall) {
560
- DispatchQueue.main.async {
561
- do {
562
- try self.cameraController.captureVideo()
563
- call.resolve()
564
- } catch {
565
- call.reject(error.localizedDescription)
566
- }
907
+ do {
908
+ try self.cameraController.captureVideo()
909
+ call.resolve()
910
+ } catch {
911
+ call.reject(error.localizedDescription)
567
912
  }
568
913
  }
569
914
 
570
915
  @objc func stopRecordVideo(_ call: CAPPluginCall) {
571
- DispatchQueue.main.async {
572
- self.cameraController.stopRecording { (fileURL, error) in
573
- guard let fileURL = fileURL else {
574
- print(error ?? "Video capture error")
575
- guard let error = error else {
576
- call.reject("Video capture error")
577
- return
578
- }
579
- call.reject(error.localizedDescription)
916
+ self.cameraController.stopRecording { (fileURL, error) in
917
+ guard let fileURL = fileURL else {
918
+ print(error ?? "Video capture error")
919
+ guard let error = error else {
920
+ call.reject("Video capture error")
580
921
  return
581
922
  }
582
-
583
- call.resolve(["videoFilePath": fileURL.absoluteString])
923
+ call.reject(error.localizedDescription)
924
+ return
584
925
  }
926
+
927
+ call.resolve(["videoFilePath": fileURL.absoluteString])
585
928
  }
586
929
  }
587
930
 
@@ -612,20 +955,20 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
612
955
  // Collect all devices by position
613
956
  for device in session.devices {
614
957
  var lenses: [[String: Any]] = []
615
-
958
+
616
959
  let constituentDevices = device.isVirtualDevice ? device.constituentDevices : [device]
617
-
960
+
618
961
  for lensDevice in constituentDevices {
619
962
  var deviceType: String
620
963
  switch lensDevice.deviceType {
621
- case .builtInWideAngleCamera: deviceType = "wideAngle"
622
- case .builtInUltraWideCamera: deviceType = "ultraWide"
623
- case .builtInTelephotoCamera: deviceType = "telephoto"
624
- case .builtInDualCamera: deviceType = "dual"
625
- case .builtInDualWideCamera: deviceType = "dualWide"
626
- case .builtInTripleCamera: deviceType = "triple"
627
- case .builtInTrueDepthCamera: deviceType = "trueDepth"
628
- default: deviceType = "unknown"
964
+ case .builtInWideAngleCamera: deviceType = "wideAngle"
965
+ case .builtInUltraWideCamera: deviceType = "ultraWide"
966
+ case .builtInTelephotoCamera: deviceType = "telephoto"
967
+ case .builtInDualCamera: deviceType = "dual"
968
+ case .builtInDualWideCamera: deviceType = "dualWide"
969
+ case .builtInTripleCamera: deviceType = "triple"
970
+ case .builtInTrueDepthCamera: deviceType = "trueDepth"
971
+ default: deviceType = "unknown"
629
972
  }
630
973
 
631
974
  var baseZoomRatio: Float = 1.0
@@ -634,7 +977,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
634
977
  } else if lensDevice.deviceType == .builtInTelephotoCamera {
635
978
  baseZoomRatio = 2.0 // A common value for telephoto lenses
636
979
  }
637
-
980
+
638
981
  let lensInfo: [String: Any] = [
639
982
  "label": lensDevice.localizedName,
640
983
  "deviceType": deviceType,
@@ -645,7 +988,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
645
988
  ]
646
989
  lenses.append(lensInfo)
647
990
  }
648
-
991
+
649
992
  let deviceData: [String: Any] = [
650
993
  "deviceId": device.uniqueID,
651
994
  "label": device.localizedName,
@@ -655,7 +998,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
655
998
  "maxZoom": Float(device.maxAvailableVideoZoomFactor),
656
999
  "isLogical": device.isVirtualDevice
657
1000
  ]
658
-
1001
+
659
1002
  devices.append(deviceData)
660
1003
  }
661
1004
 
@@ -671,7 +1014,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
671
1014
  do {
672
1015
  let zoomInfo = try self.cameraController.getZoom()
673
1016
  let lensInfo = try self.cameraController.getCurrentLensInfo()
674
-
1017
+
675
1018
  var minZoom = zoomInfo.min
676
1019
  var maxZoom = zoomInfo.max
677
1020
  var currentZoom = zoomInfo.current
@@ -750,40 +1093,32 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
750
1093
  return
751
1094
  }
752
1095
 
753
- DispatchQueue.main.async { [weak self] in
754
- guard let self = self else {
755
- call.reject("Camera controller deallocated")
756
- return
757
- }
1096
+ // Disable user interaction during device swap
1097
+ self.previewView.isUserInteractionEnabled = false
758
1098
 
759
- // Disable user interaction during device swap
760
- self.previewView.isUserInteractionEnabled = false
1099
+ do {
1100
+ try self.cameraController.swapToDevice(deviceId: deviceId)
761
1101
 
762
- DispatchQueue.global(qos: .userInitiated).async {
763
- do {
764
- try self.cameraController.swapToDevice(deviceId: deviceId)
1102
+ // Update preview layer frame without animation
1103
+ CATransaction.begin()
1104
+ CATransaction.setDisableActions(true)
1105
+ self.cameraController.previewLayer?.frame = self.previewView.bounds
1106
+ self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
1107
+ CATransaction.commit()
765
1108
 
766
- DispatchQueue.main.async {
767
- self.cameraController.previewLayer?.frame = self.previewView.bounds
768
- self.cameraController.previewLayer?.videoGravity = .resizeAspectFill
769
- self.previewView.isUserInteractionEnabled = true
770
-
771
- // Ensure webview remains transparent after device switch
772
- self.makeWebViewTransparent()
773
-
774
- call.resolve()
775
- }
776
- } catch {
777
- DispatchQueue.main.async {
778
- self.previewView.isUserInteractionEnabled = true
779
- call.reject("Failed to swap to device \(deviceId): \(error.localizedDescription)")
780
- }
781
- }
782
- }
1109
+ self.previewView.isUserInteractionEnabled = true
1110
+
1111
+ // Ensure webview remains transparent after device switch
1112
+ self.makeWebViewTransparent()
1113
+
1114
+ call.resolve()
1115
+ } catch {
1116
+ self.previewView.isUserInteractionEnabled = true
1117
+ call.reject("Failed to swap to device \(deviceId): \(error.localizedDescription)")
783
1118
  }
784
1119
  }
785
1120
 
786
- @objc func getDeviceId(_ call: CAPPluginCall) {
1121
+ @objc func getDeviceId(_ call: CAPPluginCall) {
787
1122
  guard isInitialized else {
788
1123
  call.reject("Camera not initialized")
789
1124
  return
@@ -797,6 +1132,315 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin {
797
1132
  }
798
1133
  }
799
1134
 
1135
+ // MARK: - Capacitor Permissions
1136
+
1137
+ private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
1138
+ print("[CameraPreview] requestLocationPermission called")
1139
+ if self.locationManager == nil {
1140
+ print("[CameraPreview] Creating location manager")
1141
+ self.locationManager = CLLocationManager()
1142
+ self.locationManager?.delegate = self
1143
+ }
1144
+
1145
+ let authStatus = self.locationManager?.authorizationStatus
1146
+ print("[CameraPreview] Current authorization status: \(String(describing: authStatus))")
1147
+
1148
+ switch authStatus {
1149
+ case .authorizedWhenInUse, .authorizedAlways:
1150
+ print("[CameraPreview] Location already authorized")
1151
+ completion(true)
1152
+ case .notDetermined:
1153
+ print("[CameraPreview] Location not determined, requesting authorization...")
1154
+ self.permissionCompletion = completion
1155
+ self.locationManager?.requestWhenInUseAuthorization()
1156
+ case .denied, .restricted:
1157
+ print("[CameraPreview] Location denied or restricted")
1158
+ completion(false)
1159
+ case .none:
1160
+ print("[CameraPreview] Location manager authorization status is nil")
1161
+ completion(false)
1162
+ @unknown default:
1163
+ print("[CameraPreview] Unknown authorization status")
1164
+ completion(false)
1165
+ }
1166
+ }
1167
+
1168
+ private var permissionCompletion: ((Bool) -> Void)?
1169
+
1170
+ public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
1171
+ let status = manager.authorizationStatus
1172
+ print("[CameraPreview] locationManagerDidChangeAuthorization called, status: \(status.rawValue), thread: \(Thread.current)")
1173
+
1174
+ // Handle pending capture call if we have one
1175
+ if let callID = self.permissionCallID, self.waitingForLocation {
1176
+ print("[CameraPreview] Found pending capture call ID: \(callID)")
1177
+
1178
+ let handleAuthorization = {
1179
+ print("[CameraPreview] Getting saved call on thread: \(Thread.current)")
1180
+ guard let call = self.bridge?.savedCall(withID: callID) else {
1181
+ print("[CameraPreview] ERROR: Could not retrieve saved call")
1182
+ self.permissionCallID = nil
1183
+ self.waitingForLocation = false
1184
+ return
1185
+ }
1186
+ print("[CameraPreview] Successfully retrieved saved call")
1187
+
1188
+ switch status {
1189
+ case .authorizedWhenInUse, .authorizedAlways:
1190
+ print("[CameraPreview] Location authorized, getting location for capture")
1191
+ self.getCurrentLocation { _ in
1192
+ self.performCapture(call: call)
1193
+ self.bridge?.releaseCall(call)
1194
+ self.permissionCallID = nil
1195
+ self.waitingForLocation = false
1196
+ }
1197
+ case .denied, .restricted:
1198
+ print("[CameraPreview] Location denied, rejecting capture")
1199
+ call.reject("Location permission denied")
1200
+ self.bridge?.releaseCall(call)
1201
+ self.permissionCallID = nil
1202
+ self.waitingForLocation = false
1203
+ case .notDetermined:
1204
+ print("[CameraPreview] Authorization not determined yet")
1205
+ // Don't do anything, wait for user response
1206
+ @unknown default:
1207
+ print("[CameraPreview] Unknown status, rejecting capture")
1208
+ call.reject("Unknown location permission status")
1209
+ self.bridge?.releaseCall(call)
1210
+ self.permissionCallID = nil
1211
+ self.waitingForLocation = false
1212
+ }
1213
+ }
1214
+
1215
+ // Check if we're already on main thread
1216
+ if Thread.isMainThread {
1217
+ print("[CameraPreview] Already on main thread")
1218
+ handleAuthorization()
1219
+ } else {
1220
+ print("[CameraPreview] Not on main thread, dispatching")
1221
+ DispatchQueue.main.async(execute: handleAuthorization)
1222
+ }
1223
+ } else {
1224
+ print("[CameraPreview] No pending capture call")
1225
+ }
1226
+ }
1227
+
1228
+ public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
1229
+ print("[CameraPreview] locationManager didFailWithError: \(error.localizedDescription)")
1230
+ }
1231
+
1232
+ private func getCurrentLocation(completion: @escaping (CLLocation?) -> Void) {
1233
+ print("[CameraPreview] getCurrentLocation called")
1234
+ self.locationCompletion = completion
1235
+ self.locationManager?.startUpdatingLocation()
1236
+ print("[CameraPreview] Started updating location")
1237
+ }
1238
+
1239
+ private var locationCompletion: ((CLLocation?) -> Void)?
1240
+
1241
+ public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
1242
+ print("[CameraPreview] locationManager didUpdateLocations called, locations count: \(locations.count)")
1243
+ self.currentLocation = locations.last
1244
+ if let completion = locationCompletion {
1245
+ print("[CameraPreview] Calling location completion with location: \(self.currentLocation?.description ?? "nil")")
1246
+ self.locationManager?.stopUpdatingLocation()
1247
+ completion(self.currentLocation)
1248
+ locationCompletion = nil
1249
+ } else {
1250
+ print("[CameraPreview] No location completion handler found")
1251
+ }
1252
+ }
1253
+
1254
+ private func saveImageDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1255
+ // Check if NSPhotoLibraryUsageDescription is present in Info.plist
1256
+ guard Bundle.main.object(forInfoDictionaryKey: "NSPhotoLibraryUsageDescription") != nil else {
1257
+ let error = NSError(domain: "CameraPreview", code: 2, userInfo: [
1258
+ NSLocalizedDescriptionKey: "NSPhotoLibraryUsageDescription key missing from Info.plist. Add this key with a description of how your app uses photo library access."
1259
+ ])
1260
+ completion(false, error)
1261
+ return
1262
+ }
1263
+
1264
+ let status = PHPhotoLibrary.authorizationStatus()
1265
+
1266
+ switch status {
1267
+ case .authorized:
1268
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1269
+ case .notDetermined:
1270
+ PHPhotoLibrary.requestAuthorization { newStatus in
1271
+ if newStatus == .authorized {
1272
+ self.performSaveDataToGallery(imageData: imageData, completion: completion)
1273
+ } else {
1274
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1275
+ }
1276
+ }
1277
+ case .denied, .restricted:
1278
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Photo library access denied"]))
1279
+ case .limited:
1280
+ performSaveDataToGallery(imageData: imageData, completion: completion)
1281
+ @unknown default:
1282
+ completion(false, NSError(domain: "CameraPreview", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown photo library authorization status"]))
1283
+ }
1284
+ }
1285
+
1286
+ private func performSaveDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1287
+ // Create a temporary file to write the JPEG data with EXIF
1288
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
1289
+
1290
+ do {
1291
+ try imageData.write(to: tempURL)
1292
+
1293
+ PHPhotoLibrary.shared().performChanges({
1294
+ PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: tempURL)
1295
+ }, completionHandler: { success, error in
1296
+ // Clean up temporary file
1297
+ try? FileManager.default.removeItem(at: tempURL)
1298
+
1299
+ completion(success, error)
1300
+ })
1301
+ } catch {
1302
+ completion(false, error)
1303
+ }
1304
+ }
1305
+
1306
+ private func updateCameraFrame() {
1307
+ guard let width = self.width, var height = self.height, let posX = self.posX, let posY = self.posY else {
1308
+ return
1309
+ }
1310
+
1311
+ // Ensure UI operations happen on main thread
1312
+ guard Thread.isMainThread else {
1313
+ DispatchQueue.main.async {
1314
+ self.updateCameraFrame()
1315
+ }
1316
+ return
1317
+ }
1318
+
1319
+ let paddingBottom = self.paddingBottom ?? 0
1320
+ height -= paddingBottom
1321
+
1322
+ // Cache webView dimensions for performance
1323
+ let webViewWidth = self.webView?.frame.width ?? UIScreen.main.bounds.width
1324
+ let webViewHeight = self.webView?.frame.height ?? UIScreen.main.bounds.height
1325
+
1326
+ var finalX = posX
1327
+ var finalY = posY
1328
+ var finalWidth = width
1329
+ var finalHeight = height
1330
+
1331
+ // Handle auto-centering when position is -1
1332
+ if posX == -1 || posY == -1 {
1333
+ finalWidth = webViewWidth
1334
+
1335
+ // Calculate height based on aspect ratio or use provided height
1336
+ if let aspectRatio = self.aspectRatio {
1337
+ let ratioParts = aspectRatio.split(separator: ":").compactMap { Double($0) }
1338
+ if ratioParts.count == 2 {
1339
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1340
+ let ratio = ratioParts[1] / ratioParts[0]
1341
+ finalHeight = finalWidth / CGFloat(ratio)
1342
+ }
1343
+ }
1344
+
1345
+ finalX = posX == -1 ? 0 : posX
1346
+
1347
+ if posY == -1 {
1348
+ let availableHeight = webViewHeight - paddingBottom
1349
+ finalY = finalHeight < availableHeight ? (availableHeight - finalHeight) / 2 : 0
1350
+ }
1351
+ }
1352
+
1353
+ var frame = CGRect(x: finalX, y: finalY, width: finalWidth, height: finalHeight)
1354
+
1355
+ // Apply aspect ratio adjustments only if not auto-centering
1356
+ if posX != -1 && posY != -1, let aspectRatio = self.aspectRatio {
1357
+ let ratioParts = aspectRatio.split(separator: ":").compactMap { Double($0) }
1358
+ if ratioParts.count == 2 {
1359
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
1360
+ let ratio = ratioParts[1] / ratioParts[0]
1361
+ let currentRatio = Double(finalWidth) / Double(finalHeight)
1362
+
1363
+ if currentRatio > ratio {
1364
+ let newWidth = Double(finalHeight) * ratio
1365
+ frame.origin.x = finalX + (Double(finalWidth) - newWidth) / 2
1366
+ frame.size.width = CGFloat(newWidth)
1367
+ } else {
1368
+ let newHeight = Double(finalWidth) / ratio
1369
+ frame.origin.y = finalY + (Double(finalHeight) - newHeight) / 2
1370
+ frame.size.height = CGFloat(newHeight)
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ // Disable ALL animations for frame updates - we want instant positioning
1376
+ CATransaction.begin()
1377
+ CATransaction.setDisableActions(true)
1378
+
1379
+ // Batch UI updates for better performance
1380
+ if self.previewView == nil {
1381
+ self.previewView = UIView(frame: frame)
1382
+ self.previewView.backgroundColor = UIColor.clear
1383
+ } else {
1384
+ self.previewView.frame = frame
1385
+ }
1386
+
1387
+ // Update preview layer frame efficiently
1388
+ if let previewLayer = self.cameraController.previewLayer {
1389
+ previewLayer.frame = self.previewView.bounds
1390
+ }
1391
+
1392
+ // Update grid overlay frame if it exists
1393
+ if let gridOverlay = self.cameraController.gridOverlayView {
1394
+ gridOverlay.frame = self.previewView.bounds
1395
+ }
1396
+
1397
+ CATransaction.commit()
1398
+ }
1399
+
1400
+ @objc func getPreviewSize(_ call: CAPPluginCall) {
1401
+ guard self.isInitialized else {
1402
+ call.reject("camera not started")
1403
+ return
1404
+ }
1405
+
1406
+ DispatchQueue.main.async {
1407
+ var result = JSObject()
1408
+ result["x"] = Double(self.previewView.frame.origin.x)
1409
+ result["y"] = Double(self.previewView.frame.origin.y)
1410
+ result["width"] = Double(self.previewView.frame.width)
1411
+ result["height"] = Double(self.previewView.frame.height)
1412
+ call.resolve(result)
1413
+ }
1414
+ }
1415
+
1416
+ @objc func setPreviewSize(_ call: CAPPluginCall) {
1417
+ guard self.isInitialized else {
1418
+ call.reject("camera not started")
1419
+ return
1420
+ }
1421
+
1422
+ // Only update position if explicitly provided, otherwise keep auto-centering
1423
+ if let x = call.getInt("x") {
1424
+ self.posX = CGFloat(x)
1425
+ }
1426
+ if let y = call.getInt("y") {
1427
+ self.posY = CGFloat(y)
1428
+ }
1429
+ if let width = call.getInt("width") { self.width = CGFloat(width) }
1430
+ if let height = call.getInt("height") { self.height = CGFloat(height) }
800
1431
 
1432
+ DispatchQueue.main.async {
1433
+ // Direct update without animation for better performance
1434
+ self.updateCameraFrame()
1435
+ self.makeWebViewTransparent()
801
1436
 
1437
+ // Return the actual preview bounds
1438
+ var result = JSObject()
1439
+ result["x"] = Double(self.previewView.frame.origin.x)
1440
+ result["y"] = Double(self.previewView.frame.origin.y)
1441
+ result["width"] = Double(self.previewView.frame.width)
1442
+ result["height"] = Double(self.previewView.frame.height)
1443
+ call.resolve(result)
1444
+ }
1445
+ }
802
1446
  }