@capgo/camera-preview 7.4.0-beta.9 → 7.4.1

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