@capgo/camera-preview 8.11.8 → 8.11.10

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.
@@ -35,7 +35,7 @@ extension UIWindow {
35
35
  */
36
36
  @objc(CameraPreview)
37
37
  public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
38
- private let pluginVersion: String = "8.11.8"
38
+ private let pluginVersion: String = "8.11.10"
39
39
  public let identifier = "CameraPreviewPlugin"
40
40
  public let jsName = "CameraPreview"
41
41
  public let pluginMethods: [CAPPluginMethod] = [
@@ -111,6 +111,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
111
111
  var posY: CGFloat?
112
112
  var width: CGFloat?
113
113
  var height: CGFloat?
114
+ /// Whether `width` / `height` came from the caller rather than from defaults or aspect ratio sizing.
115
+ var widthIsExplicit = false
116
+ var heightIsExplicit = false
114
117
  var paddingBottom: CGFloat?
115
118
  var rotateWhenOrientationChanged: Bool?
116
119
  var toBack: Bool?
@@ -129,41 +132,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
129
132
  private var isPresentingPermissionAlert: Bool = false
130
133
  private var pendingStartBarcodeScannerOptions: (formats: [String], detectionInterval: Int)?
131
134
  private var hasResolvedStartCall: Bool = false
135
+ private var startGeneration: UInt = 0
136
+ private var firstFrameTimeoutWorkItem: DispatchWorkItem?
137
+ private var pendingStartCall: CAPPluginCall?
132
138
 
133
139
  // Store original webview colors to restore them when stopping
134
140
  private var originalWebViewBackgroundColor: UIColor?
135
141
  private var originalWebViewSubviewColors: [UIView: UIColor] = [:]
136
142
 
137
- // MARK: - Helper Methods for Aspect Ratio
138
-
139
- /// Parses aspect ratio string and returns the appropriate ratio for the current orientation
140
- private func parseAspectRatio(_ ratio: String, isPortrait: Bool) -> CGFloat {
141
- let parts = ratio.split(separator: ":").compactMap { Double($0) }
142
- guard parts.count == 2 else { return 1.0 }
143
-
144
- // For camera (portrait), we want portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
145
- return isPortrait ?
146
- CGFloat(parts[1] / parts[0]) :
147
- CGFloat(parts[0] / parts[1])
148
- }
149
-
150
- /// Calculates dimensions based on aspect ratio and available space
151
- private func calculateDimensionsForAspectRatio(_ aspectRatio: String, availableWidth: CGFloat, availableHeight: CGFloat, isPortrait: Bool) -> (width: CGFloat, height: CGFloat) {
152
- let ratio = parseAspectRatio(aspectRatio, isPortrait: isPortrait)
153
-
154
- // Calculate maximum size that fits the aspect ratio in available space
155
- let maxWidthByHeight = availableHeight * ratio
156
- let maxHeightByWidth = availableWidth / ratio
157
-
158
- if maxWidthByHeight <= availableWidth {
159
- // Height is the limiting factor
160
- return (width: maxWidthByHeight, height: availableHeight)
161
- } else {
162
- // Width is the limiting factor
163
- return (width: availableWidth, height: maxHeightByWidth)
164
- }
165
- }
166
-
167
143
  // MARK: - Transparency Methods
168
144
 
169
145
  private func makeWebViewTransparent() {
@@ -501,9 +477,16 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
501
477
  // Parse aspect ratio - convert to portrait orientation for camera use
502
478
  // Use the centralized calculation method
503
479
  if let aspectRatio = self.aspectRatio {
504
- let dimensions = calculateDimensionsForAspectRatio(aspectRatio, availableWidth: availableWidth, availableHeight: availableHeight, isPortrait: isPortrait)
480
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
481
+ aspectRatio,
482
+ availableWidth: availableWidth,
483
+ availableHeight: availableHeight,
484
+ isPortrait: isPortrait
485
+ )
505
486
  self.width = dimensions.width
506
487
  self.height = dimensions.height
488
+ self.widthIsExplicit = false
489
+ self.heightIsExplicit = false
507
490
  }
508
491
 
509
492
  self.updateCameraFrame()
@@ -685,7 +668,12 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
685
668
 
686
669
  // If force is true, kill everything and restart no matter what
687
670
  if force {
671
+ if let pendingStartCall = self.pendingStartCall, !self.hasResolvedStartCall {
672
+ self.failPendingStart(pendingStartCall, message: "Camera start cancelled")
673
+ }
688
674
  if self.isInitializing || self.isInitialized || self.cameraController.isCapturingPhoto || self.cameraController.stopRequestedAfterCapture {
675
+ self.startGeneration += 1
676
+ self.cancelFirstFrameTimeout()
689
677
  // Force stop everything synchronously
690
678
  DispatchQueue.main.sync {
691
679
  self.cameraController.removeGridOverlay()
@@ -732,6 +720,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
732
720
 
733
721
  self.isInitializing = true
734
722
  self.hasResolvedStartCall = false
723
+ self.pendingStartCall = call
724
+ self.startGeneration += 1
725
+ let startToken = self.startGeneration
735
726
 
736
727
  self.cameraPosition = call.getString("position") ?? "rear"
737
728
  let deviceId = call.getString("deviceId")
@@ -740,15 +731,19 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
740
731
  // Set width - use screen width if not provided or if 0
741
732
  if let width = call.getInt("width"), width > 0 {
742
733
  self.width = CGFloat(width)
734
+ self.widthIsExplicit = true
743
735
  } else {
744
736
  self.width = UIScreen.main.bounds.size.width
737
+ self.widthIsExplicit = false
745
738
  }
746
739
 
747
740
  // Set height - use screen height if not provided or if 0
748
741
  if let height = call.getInt("height"), height > 0 {
749
742
  self.height = CGFloat(height)
743
+ self.heightIsExplicit = true
750
744
  } else {
751
745
  self.height = UIScreen.main.bounds.size.height
746
+ self.heightIsExplicit = false
752
747
  }
753
748
 
754
749
  // Set x position - use exact CSS pixel value from web view, or mark for centering
@@ -819,18 +814,36 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
819
814
  }
820
815
  }
821
816
 
822
- self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, aspectMode: self.aspectMode, initialZoomLevel: initialZoomLevel, disableFocusIndicator: self.disableFocusIndicator, videoQuality: videoQuality) { error in
817
+ self.cameraController.onCameraInterrupted = { [weak self] reason, audioDropped in
818
+ self?.notifyListeners("cameraInterrupted", data: [
819
+ "reason": reason,
820
+ "audioDropped": audioDropped
821
+ ])
822
+ }
823
+ self.cameraController.onCameraInterruptionEnded = { [weak self] in
824
+ self?.notifyListeners("cameraInterruptionEnded", data: [:])
825
+ }
826
+ self.cameraController.onStartFailure = { [weak self] error in
827
+ guard let self = self, startToken == self.startGeneration, !self.hasResolvedStartCall else { return }
828
+ DispatchQueue.main.async {
829
+ self.failPendingStart(call, message: error.localizedDescription)
830
+ }
831
+ }
832
+
833
+ self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, aspectMode: self.aspectMode, initialZoomLevel: initialZoomLevel, disableFocusIndicator: self.disableFocusIndicator, videoQuality: videoQuality, startToken: startToken) { error in
823
834
  if let error = error {
824
835
  print(error)
825
836
  DispatchQueue.main.async {
826
- self.isInitializing = false
827
- self.pendingStartBarcodeScannerOptions = nil
828
- call.reject(error.localizedDescription)
837
+ guard startToken == self.startGeneration else { return }
838
+ self.failPendingStart(call, message: error.localizedDescription)
829
839
  }
830
840
  return
831
841
  }
832
842
 
833
843
  DispatchQueue.main.async {
844
+ guard startToken == self.startGeneration, !self.hasResolvedStartCall else {
845
+ return
846
+ }
834
847
  if self.rotateWhenOrientationChanged == true {
835
848
  UIDevice.current.beginGeneratingDeviceOrientationNotifications()
836
849
  self.isGeneratingDeviceOrientationNotifications = true
@@ -839,16 +852,15 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
839
852
  name: UIDevice.orientationDidChangeNotification,
840
853
  object: nil)
841
854
  }
842
- self.completeStartCamera(call: call)
855
+ self.completeStartCamera(call: call, startToken: startToken)
843
856
  }
844
857
  }
845
858
  }
846
859
 
847
860
  let handleDenied: (AVAuthorizationStatus) -> Void = { _ in
848
861
  DispatchQueue.main.async {
849
- self.isInitializing = false
850
- self.pendingStartBarcodeScannerOptions = nil
851
- call.reject("camera permission denied. enable camera access in Settings.", "cameraPermissionDenied")
862
+ guard startToken == self.startGeneration else { return }
863
+ self.failPendingStart(call, message: "camera permission denied. enable camera access in Settings.", errorCode: "cameraPermissionDenied")
852
864
  }
853
865
  }
854
866
 
@@ -859,6 +871,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
859
871
  beginStart()
860
872
  case .notDetermined:
861
873
  AVCaptureDevice.requestAccess(for: .video) { granted in
874
+ guard startToken == self.startGeneration else { return }
862
875
  if granted {
863
876
  beginStart()
864
877
  } else {
@@ -873,7 +886,71 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
873
886
  }
874
887
  }
875
888
 
876
- private func completeStartCamera(call: CAPPluginCall) {
889
+ private func cancelFirstFrameTimeout() {
890
+ self.firstFrameTimeoutWorkItem?.cancel()
891
+ self.firstFrameTimeoutWorkItem = nil
892
+ }
893
+
894
+ private func teardownAfterFailedStart() {
895
+ self.cameraController.removeGridOverlay()
896
+ if let previewView = self.previewView {
897
+ previewView.removeFromSuperview()
898
+ self.previewView = nil
899
+ }
900
+ if let webView = self.webView {
901
+ webView.isOpaque = true
902
+ self.restoreWebViewBackground(webView)
903
+ }
904
+ NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
905
+ if self.isGeneratingDeviceOrientationNotifications {
906
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
907
+ self.isGeneratingDeviceOrientationNotifications = false
908
+ }
909
+ self.cameraController.invalidateActivePrepare()
910
+ self.cameraController.cleanup()
911
+ }
912
+
913
+ private func failPendingStart(_ call: CAPPluginCall, message: String, errorCode: String? = nil) {
914
+ let applyFailure = { [weak self] in
915
+ guard let self = self else { return }
916
+ guard !self.hasResolvedStartCall else { return }
917
+ self.hasResolvedStartCall = true
918
+ self.pendingStartCall = nil
919
+ self.cancelFirstFrameTimeout()
920
+ self.cameraController.firstFrameReadyCallback = nil
921
+ self.cameraController.onStartFailure = nil
922
+ self.teardownAfterFailedStart()
923
+ self.isInitializing = false
924
+ self.isInitialized = false
925
+ self.pendingStartBarcodeScannerOptions = nil
926
+ if let errorCode = errorCode {
927
+ call.reject(message, errorCode)
928
+ } else {
929
+ call.reject(message)
930
+ }
931
+ }
932
+
933
+ if Thread.isMainThread {
934
+ applyFailure()
935
+ } else {
936
+ DispatchQueue.main.sync {
937
+ applyFailure()
938
+ }
939
+ }
940
+ }
941
+
942
+ private func scheduleFirstFrameTimeout(for call: CAPPluginCall, startToken: UInt) {
943
+ self.cancelFirstFrameTimeout()
944
+ let workItem = DispatchWorkItem { [weak self] in
945
+ guard let self = self, startToken == self.startGeneration, !self.hasResolvedStartCall else { return }
946
+ self.failPendingStart(call, message: "Camera preview failed to start: timed out waiting for the first frame")
947
+ }
948
+ self.firstFrameTimeoutWorkItem = workItem
949
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: workItem)
950
+ }
951
+
952
+ private func completeStartCamera(call: CAPPluginCall, startToken: UInt) {
953
+ guard startToken == self.startGeneration else { return }
877
954
  // Create and configure the preview view first
878
955
  self.updateCameraFrame()
879
956
 
@@ -913,9 +990,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
913
990
 
914
991
  // Set up callback to wait for first frame before resolving
915
992
  self.cameraController.firstFrameReadyCallback = { [weak self] in
916
- guard let self = self else { return }
993
+ guard let self = self, startToken == self.startGeneration else { return }
917
994
 
918
995
  DispatchQueue.main.async {
996
+ guard startToken == self.startGeneration else { return }
919
997
  var returnedObject = JSObject()
920
998
  returnedObject["width"] = self.previewView.frame.width as any JSValue
921
999
  returnedObject["height"] = self.previewView.frame.height as any JSValue
@@ -925,9 +1003,12 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
925
1003
  }
926
1004
  }
927
1005
 
1006
+ self.scheduleFirstFrameTimeout(for: call, startToken: startToken)
1007
+
928
1008
  // If already received first frame (unlikely but possible), resolve immediately on main thread
929
1009
  if self.cameraController.hasReceivedFirstFrame {
930
1010
  DispatchQueue.main.async {
1011
+ guard startToken == self.startGeneration else { return }
931
1012
  var returnedObject = JSObject()
932
1013
  returnedObject["width"] = self.previewView.frame.width as any JSValue
933
1014
  returnedObject["height"] = self.previewView.frame.height as any JSValue
@@ -955,7 +1036,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
955
1036
  private func resolveStartCall(_ call: CAPPluginCall, returnedObject: JSObject) {
956
1037
  guard !hasResolvedStartCall else { return }
957
1038
  hasResolvedStartCall = true
1039
+ pendingStartCall = nil
1040
+ cancelFirstFrameTimeout()
958
1041
  cameraController.firstFrameReadyCallback = nil
1042
+ cameraController.onStartFailure = nil
959
1043
 
960
1044
  if let options = pendingStartBarcodeScannerOptions {
961
1045
  do {
@@ -1044,6 +1128,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1044
1128
 
1045
1129
  // UI operations must be on main thread
1046
1130
  DispatchQueue.main.async {
1131
+ if !self.hasResolvedStartCall, let pendingStartCall = self.pendingStartCall {
1132
+ self.failPendingStart(pendingStartCall, message: "Camera start cancelled")
1133
+ }
1134
+
1047
1135
  // If a photo capture is in-flight, defer cleanup until it finishes,
1048
1136
  // but hide the preview immediately so UI can close.
1049
1137
  self.cameraController.removeGridOverlay()
@@ -1059,6 +1147,8 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1059
1147
  self.restoreWebViewBackground(webView)
1060
1148
  }
1061
1149
 
1150
+ self.startGeneration += 1
1151
+ self.cancelFirstFrameTimeout()
1062
1152
  self.isInitialized = false
1063
1153
  self.isInitializing = false
1064
1154
 
@@ -1233,7 +1323,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1233
1323
  print("[CameraPreview] Location permission not determined, requesting...")
1234
1324
  // Save the call for the delegate callback
1235
1325
  print("[CameraPreview] Saving call for location authorization flow")
1236
- call.keepAlive = true
1326
+ self.bridge?.saveCall(call)
1237
1327
  self.permissionCallID = call.callbackId
1238
1328
  self.waitingForLocation = true
1239
1329
 
@@ -1286,6 +1376,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1286
1376
  }()
1287
1377
  print("[CameraPreview] Preview dimensions: \(previewWidth)x\(previewHeight)")
1288
1378
 
1379
+ let capturedFromFrontCamera = self.cameraController.currentCameraPosition == .front
1289
1380
  let gpsForThisCapture = (withExifLocation ?? false) ? self.currentLocation : nil
1290
1381
  self.cameraController.captureImage(width: width, height: height, quality: quality, gpsLocation: gpsForThisCapture, embedTimestamp: embedTimestamp, embedLocation: effectiveEmbedLocation, photoQualityPrioritization: photoQualityPrioritization) { (image, originalPhotoData, _, error) in
1291
1382
  print("[CameraPreview] captureImage callback received")
@@ -1311,7 +1402,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1311
1402
  }
1312
1403
 
1313
1404
  let imageToEncode: UIImage
1314
- if mirrorFrontCamera, self.cameraPosition == "front" {
1405
+ if mirrorFrontCamera, capturedFromFrontCamera {
1315
1406
  imageToEncode = self.cameraController.mirrorImageHorizontally(capturedImage)
1316
1407
  } else {
1317
1408
  imageToEncode = capturedImage
@@ -1534,6 +1625,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1534
1625
  @objc func captureSample(_ call: CAPPluginCall) {
1535
1626
  let quality: Int = call.getInt("quality") ?? 85
1536
1627
  let mirrorFrontCamera = call.getBool("mirrorFrontCamera", false) ?? false
1628
+ let capturedFromFrontCamera = self.cameraController.currentCameraPosition == .front
1537
1629
 
1538
1630
  self.cameraController.captureSample { image, error in
1539
1631
  guard let image = image else {
@@ -1543,7 +1635,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1543
1635
  }
1544
1636
 
1545
1637
  var outputImage = image
1546
- if mirrorFrontCamera, self.cameraPosition == "front" {
1638
+ if mirrorFrontCamera, capturedFromFrontCamera {
1547
1639
  outputImage = self.cameraController.mirrorImageHorizontally(image)
1548
1640
  }
1549
1641
  let imageData = outputImage.jpegData(compressionQuality: CGFloat(quality)/100)
@@ -2029,14 +2121,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2029
2121
  print("[CameraPreview] Location authorized, getting location for capture")
2030
2122
  self.getCurrentLocation { _ in
2031
2123
  self.performCapture(call: call)
2032
- call.keepAlive = false
2124
+ self.bridge?.releaseCall(call)
2033
2125
  self.permissionCallID = nil
2034
2126
  self.waitingForLocation = false
2035
2127
  }
2036
2128
  case .denied, .restricted:
2037
2129
  print("[CameraPreview] Location denied, rejecting capture")
2038
2130
  call.reject("Location permission denied")
2039
- call.keepAlive = false
2131
+ self.bridge?.releaseCall(call)
2040
2132
  self.permissionCallID = nil
2041
2133
  self.waitingForLocation = false
2042
2134
  case .notDetermined:
@@ -2045,7 +2137,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2045
2137
  @unknown default:
2046
2138
  print("[CameraPreview] Unknown status, rejecting capture")
2047
2139
  call.reject("Unknown location permission status")
2048
- call.keepAlive = false
2140
+ self.bridge?.releaseCall(call)
2049
2141
  self.permissionCallID = nil
2050
2142
  self.waitingForLocation = false
2051
2143
  }
@@ -2209,13 +2301,23 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2209
2301
  print("[CameraPreview] width: \(UIScreen.main.bounds.size.width) height: \(UIScreen.main.bounds.size.height)")
2210
2302
 
2211
2303
  // Calculate dimensions using centralized method
2212
- let dimensions = calculateDimensionsForAspectRatio(ratio, availableWidth: finalWidth, availableHeight: webViewHeight - paddingBottom, isPortrait: isPortrait)
2304
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
2305
+ ratio,
2306
+ availableWidth: finalWidth,
2307
+ availableHeight: webViewHeight - paddingBottom,
2308
+ isPortrait: isPortrait
2309
+ )
2213
2310
  if isPortrait {
2214
2311
  finalHeight = dimensions.height
2215
2312
  finalWidth = dimensions.width
2216
2313
  } else {
2217
2314
  // In landscape, recalculate based on available space
2218
- let landscapeDimensions = calculateDimensionsForAspectRatio(ratio, availableWidth: webViewWidth, availableHeight: webViewHeight - paddingBottom, isPortrait: isPortrait)
2315
+ let landscapeDimensions = AspectRatioLayout.dimensionsForAspectRatio(
2316
+ ratio,
2317
+ availableWidth: webViewWidth,
2318
+ availableHeight: webViewHeight - paddingBottom,
2319
+ isPortrait: isPortrait
2320
+ )
2219
2321
  finalWidth = landscapeDimensions.width
2220
2322
  finalHeight = landscapeDimensions.height
2221
2323
  }
@@ -2256,6 +2358,17 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2256
2358
  }
2257
2359
  }
2258
2360
 
2361
+ // A fill preview sizes each explicitly positioned axis from the web-view space
2362
+ // remaining after that coordinate, unless the caller supplied that dimension.
2363
+ if let ratio = currentAspectRatio, AspectRatioLayout.isFillMode(ratio) {
2364
+ if currentX != -1 && width == nil && !self.widthIsExplicit {
2365
+ finalWidth = AspectRatioLayout.remainingLength(from: currentX, in: webViewWidth)
2366
+ }
2367
+ if currentY != -1 && height == nil && !self.heightIsExplicit {
2368
+ finalHeight = AspectRatioLayout.remainingLength(from: currentY, in: webViewHeight - paddingBottom)
2369
+ }
2370
+ }
2371
+
2259
2372
  return CGRect(x: finalX, y: finalY, width: finalWidth, height: finalHeight)
2260
2373
  }
2261
2374
 
@@ -2276,9 +2389,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2276
2389
  var frame = calculateCameraFrame()
2277
2390
 
2278
2391
  // Apply aspect ratio adjustments only if not auto-centering
2279
- if posX != -1 && posY != -1, let aspectRatio = self.aspectRatio {
2280
- let isPortrait = self.isPortrait()
2281
- let ratio = parseAspectRatio(aspectRatio, isPortrait: isPortrait)
2392
+ if posX != -1 && posY != -1,
2393
+ let aspectRatio = self.aspectRatio,
2394
+ !AspectRatioLayout.isFillMode(aspectRatio),
2395
+ let ratio = AspectRatioLayout.parseViewportAspectRatio(aspectRatio, isPortrait: self.isPortrait()) {
2282
2396
  let currentRatio = frame.width / frame.height
2283
2397
 
2284
2398
  if currentRatio > ratio {
@@ -2352,8 +2466,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2352
2466
  self.posY = -1 // Auto-center if Y not provided
2353
2467
  }
2354
2468
 
2355
- if let width = call.getInt("width") { self.width = CGFloat(width) }
2356
- if let height = call.getInt("height") { self.height = CGFloat(height) }
2469
+ if let width = call.getInt("width") {
2470
+ self.width = CGFloat(width)
2471
+ self.widthIsExplicit = true
2472
+ }
2473
+ if let height = call.getInt("height") {
2474
+ self.height = CGFloat(height)
2475
+ self.heightIsExplicit = true
2476
+ }
2357
2477
 
2358
2478
  DispatchQueue.main.async {
2359
2479
  // Direct update without animation for better performance
@@ -1,15 +1,156 @@
1
1
  import XCTest
2
- @testable import CameraViewPlugin
2
+ @testable import CapgoCameraPreview
3
3
 
4
- class CameraViewTests: XCTestCase {
5
- func testEcho() {
6
- // This is an example of a functional test case for a plugin.
7
- // Use XCTAssert and related functions to verify your tests produce the correct results.
4
+ final class AspectRatioLayoutTests: XCTestCase {
5
+ func testFillModeIsRecognizedCaseInsensitively() {
6
+ XCTAssertTrue(AspectRatioLayout.isFillMode("fill"))
7
+ XCTAssertTrue(AspectRatioLayout.isFillMode("FILL"))
8
+ XCTAssertFalse(AspectRatioLayout.isFillMode("4:3"))
9
+ }
10
+
11
+ func testFillDoesNotParseAsNumericRatio() {
12
+ XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("fill", isPortrait: true))
13
+ XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("fill", isPortrait: false))
14
+ }
15
+
16
+ func testFillUsesFullAvailableSpace() {
17
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
18
+ "fill",
19
+ availableWidth: 428,
20
+ availableHeight: 926,
21
+ isPortrait: true
22
+ )
23
+
24
+ XCTAssertEqual(dimensions.width, 428)
25
+ XCTAssertEqual(dimensions.height, 926)
26
+ }
27
+
28
+ func testFourThreePortraitParsesAsThreeFour() {
29
+ let ratio = AspectRatioLayout.parseViewportAspectRatio("4:3", isPortrait: true)
30
+ XCTAssertEqual(ratio ?? 0, 0.75, accuracy: 0.0001)
31
+ }
32
+
33
+ func testFourThreePortraitLetterboxesInTallViewport() {
34
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
35
+ "4:3",
36
+ availableWidth: 428,
37
+ availableHeight: 926,
38
+ isPortrait: true
39
+ )
40
+
41
+ XCTAssertEqual(dimensions.width, 428, accuracy: 0.5)
42
+ XCTAssertLessThan(dimensions.height, 926)
43
+ XCTAssertGreaterThan(dimensions.height, 500)
44
+ }
45
+
46
+ func testMalformedRatioFallsBackToFullViewport() {
47
+ XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("4:bad:3", isPortrait: true))
48
+
49
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
50
+ "4:bad:3",
51
+ availableWidth: 428,
52
+ availableHeight: 926,
53
+ isPortrait: true
54
+ )
55
+
56
+ XCTAssertEqual(dimensions.width, 428)
57
+ XCTAssertEqual(dimensions.height, 926)
58
+ }
59
+
60
+ func testExtremeRatioQuotientFallsBackToFullViewport() {
61
+ XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("1e-300:1e300", isPortrait: true))
62
+
63
+ let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
64
+ "1e-300:1e300",
65
+ availableWidth: 428,
66
+ availableHeight: 926,
67
+ isPortrait: true
68
+ )
69
+
70
+ XCTAssertEqual(dimensions.width, 428)
71
+ XCTAssertEqual(dimensions.height, 926)
72
+ }
73
+
74
+ func testPositionedFillUsesRemainingWebViewSpace() {
75
+ let size = AspectRatioLayout.positionedFillSize(x: 20, y: 100, viewportWidth: 390, viewportHeight: 800)
8
76
 
9
- let implementation = CameraView()
10
- let value = "Hello, World!"
11
- let result = implementation.echo(value)
77
+ XCTAssertEqual(size.width, 370)
78
+ XCTAssertEqual(size.height, 700)
79
+ }
80
+
81
+ func testPositionedFillNeverReturnsNegativeSize() {
82
+ let size = AspectRatioLayout.positionedFillSize(x: 500, y: 900, viewportWidth: 390, viewportHeight: 800)
83
+
84
+ XCTAssertEqual(size.width, 0)
85
+ XCTAssertEqual(size.height, 0)
86
+ }
87
+
88
+ func testRemainingLengthIsComputedPerAxis() {
89
+ XCTAssertEqual(AspectRatioLayout.remainingLength(from: 40, in: 390), 350)
90
+ XCTAssertEqual(AspectRatioLayout.remainingLength(from: 0, in: 800), 800)
91
+ XCTAssertEqual(AspectRatioLayout.remainingLength(from: 900, in: 800), 0)
92
+ }
93
+
94
+ func testVisibleFillRatioMatchesPreviewWhenOrientationsAgree() {
95
+ let ratio = AspectRatioLayout.visibleFillCaptureAspectRatio(
96
+ previewSize: CGSize(width: 390, height: 844),
97
+ interfaceIsPortrait: true,
98
+ captureIsPortrait: true
99
+ )
100
+
101
+ XCTAssertEqual(ratio ?? 0, 390.0 / 844.0, accuracy: 0.0001)
102
+ }
103
+
104
+ func testVisibleFillRatioFlipsWhenCaptureOrientationDiffers() {
105
+ let ratio = AspectRatioLayout.visibleFillCaptureAspectRatio(
106
+ previewSize: CGSize(width: 390, height: 844),
107
+ interfaceIsPortrait: true,
108
+ captureIsPortrait: false
109
+ )
110
+
111
+ XCTAssertEqual(ratio ?? 0, 844.0 / 390.0, accuracy: 0.0001)
112
+ }
113
+
114
+ func testVisibleFillRatioRejectsEmptyPreview() {
115
+ XCTAssertNil(
116
+ AspectRatioLayout.visibleFillCaptureAspectRatio(
117
+ previewSize: .zero,
118
+ interfaceIsPortrait: true,
119
+ captureIsPortrait: true
120
+ )
121
+ )
122
+ }
123
+
124
+ func testCenterCropOfPortraitPhotoToTallPreviewTrimsSides() {
125
+ // 3:4 portrait photo shown in a 390x844 cover preview: the sides are cropped.
126
+ let rect = AspectRatioLayout.centerCropRect(
127
+ imageSize: CGSize(width: 3024, height: 4032),
128
+ targetAspectRatio: 390.0 / 844.0
129
+ )
130
+
131
+ XCTAssertEqual(rect.height, 4032)
132
+ XCTAssertEqual(rect.width, (4032 * 390.0 / 844.0).rounded(), accuracy: 2)
133
+ XCTAssertEqual(rect.midX, 1512, accuracy: 1)
134
+ XCTAssertEqual(rect.minY, 0)
135
+ }
136
+
137
+ func testCenterCropToWiderRatioTrimsTopAndBottom() {
138
+ let rect = AspectRatioLayout.centerCropRect(
139
+ imageSize: CGSize(width: 3024, height: 4032),
140
+ targetAspectRatio: 1
141
+ )
142
+
143
+ XCTAssertEqual(rect.width, 3024)
144
+ XCTAssertEqual(rect.height, 3024)
145
+ XCTAssertEqual(rect.minY, 504)
146
+ }
12
147
 
13
- XCTAssertEqual(value, result)
148
+ func testCenterCropWithInvalidRatioKeepsFullImage() {
149
+ let size = CGSize(width: 3024, height: 4032)
150
+ XCTAssertEqual(AspectRatioLayout.centerCropRect(imageSize: size, targetAspectRatio: 0), CGRect(origin: .zero, size: size))
151
+ XCTAssertEqual(
152
+ AspectRatioLayout.centerCropRect(imageSize: size, targetAspectRatio: .infinity),
153
+ CGRect(origin: .zero, size: size)
154
+ )
14
155
  }
15
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/camera-preview",
3
- "version": "8.11.8",
3
+ "version": "8.11.10",
4
4
  "description": "Camera preview",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
@@ -41,7 +41,8 @@
41
41
  ],
42
42
  "scripts": {
43
43
  "verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
44
- "verify:ios": "xcodebuild -scheme CapgoCameraPreview -destination generic/platform=iOS",
44
+ "verify:ios": "xcodebuild -scheme CapgoCameraPreview -destination generic/platform=iOS && bun run test:ios",
45
+ "test:ios": "bash ./scripts/test-ios.sh",
45
46
  "verify:android": "cd android && ./gradlew clean build test && cd ..",
46
47
  "verify:web": "bun run build",
47
48
  "lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",