@momo-kits/camerakit 0.168.1-beta.2 → 0.168.1-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ // File: packages/react-native-camera-kit/ios/ReactNativeCameraKit/CameraDirection.swift
2
+ // Created At: 2026-10-22 10:00:00 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5-5
6
+
7
+ // AI-GENERATED START: keep the camera backward-facing and upright (iPhone Duo), ported from the host app
8
+ import AVFoundation
9
+ import AVKit
10
+ import ImageIO
11
+ import UIKit
12
+
13
+ /// Keeps the back camera on a camera that points away from the person, on every iPhone.
14
+ ///
15
+ /// On iPhone Duo the direction a camera faces depends on the display the app is on, and opening or
16
+ /// closing the device moves the app to the other display. `AVCaptureDeviceDirectionCoordinator`
17
+ /// reports which cameras face backward relative to the preview view: the observer keeps the current
18
+ /// camera while it still faces backward and otherwise hands back a replacement. On an iPhone with
19
+ /// one display the rear cameras always face backward, so the handler never asks for a change.
20
+ ///
21
+ /// Kit-internal copy of the host's `ScannerCameraDirectionObserver` (ios/EvitanSwiftUI/Camera).
22
+ final class CameraDirectionObserver {
23
+ /// A replacement camera: its `uniqueID`, and whether it sits on the front (preview stays unmirrored).
24
+ typealias Replacement = (uniqueID: String, isFrontPosition: Bool)
25
+
26
+ private var coordinator: AnyObject?
27
+
28
+ /// Starts observing in relation to `previewView`; call on the main queue once the view is onscreen.
29
+ /// - Parameters:
30
+ /// - deviceTypes: the rear camera types the camera captures from, in order of preference.
31
+ /// - currentCameraID: the `uniqueID` of the camera in use.
32
+ /// - onReplace: called on the main queue when the current camera no longer faces backward.
33
+ func start(
34
+ previewView: UIView,
35
+ deviceTypes: [AVCaptureDevice.DeviceType],
36
+ currentCameraID: @escaping () -> String?,
37
+ onReplace: @escaping (Replacement) -> Void
38
+ ) {
39
+ #if canImport(UIKit, _version: 9127.0.85.28)
40
+ guard #available(iOS 27.1, *), coordinator == nil else { return }
41
+ // the two physical front cameras stand in for the virtual front camera, which the coordinator skips
42
+ let observedTypes = deviceTypes + [.builtInOuterUltraWideCamera, .builtInInnerUltraWideCamera]
43
+ coordinator = AVCaptureDeviceDirectionCoordinator(view: previewView, deviceTypes: observedTypes) { map in
44
+ let backward = map.backwardFacingDeviceDescriptors
45
+ if let current = currentCameraID(), backward.contains(where: { $0.uniqueID == current }) { return }
46
+ let rank = { (type: AVCaptureDevice.DeviceType) in
47
+ observedTypes.firstIndex(of: type) ?? observedTypes.count
48
+ }
49
+ // AVFoundation is not called here: the caller resolves the descriptor on its session queue
50
+ guard let replacement = backward.min(by: { rank($0.deviceType) < rank($1.deviceType) }) else { return }
51
+ onReplace((replacement.uniqueID, replacement.position == .front))
52
+ }
53
+ #endif
54
+ }
55
+
56
+ func stop() {
57
+ coordinator = nil
58
+ }
59
+ }
60
+
61
+ /// Rotation that keeps a camera's captured frames and preview level with the horizon.
62
+ ///
63
+ /// Wraps `AVCaptureDevice.RotationCoordinator` (iOS 17+). It reads gravity and the display the app is
64
+ /// on, so the angle stays right on iPhone Duo where moving between displays changes it; deriving it
65
+ /// from `UIDevice.orientation` or the interface orientation does not. Create one per camera.
66
+ ///
67
+ /// Kit-internal copy of the host's `CaptureRotationTracker` (ios/EvitanSwiftUI/Camera).
68
+ ///
69
+ /// Preview and capture angles are observed separately. They change independently: on the iPhone Duo
70
+ /// inner display the interface ignores a portrait-only lock and lays out in landscape, which moves the
71
+ /// preview angle while the capture angle (gravity) stays the same.
72
+ final class CaptureRotationTracker {
73
+ private var coordinator: AnyObject?
74
+ private var captureObservation: NSKeyValueObservation?
75
+ private var previewObservation: NSKeyValueObservation?
76
+ private let lock = NSLock()
77
+ private var latestCaptureAngle: CGFloat?
78
+
79
+ /// The capture angle in degrees (0, 90, 180, 270); nil before iOS 17 or before the first reading.
80
+ /// Safe to read from the session and sample buffer queues.
81
+ var captureAngle: CGFloat? {
82
+ lock.lock()
83
+ defer { lock.unlock() }
84
+ return latestCaptureAngle
85
+ }
86
+
87
+ /// The current preview angle in degrees; nil before iOS 17 or when not tracking. Main queue only.
88
+ /// Read it on layout too: the coordinator reports 0 while the layer is outside a view hierarchy.
89
+ var previewAngle: CGFloat? {
90
+ guard #available(iOS 17.0, *),
91
+ let rotationCoordinator = coordinator as? AVCaptureDevice.RotationCoordinator else { return nil }
92
+ return rotationCoordinator.videoRotationAngleForHorizonLevelPreview
93
+ }
94
+
95
+ /// Starts tracking `device`; call on the main queue, once `previewLayer` is in a window.
96
+ /// `onPreviewAngle` receives the preview angle on the main queue.
97
+ func start(device: AVCaptureDevice, previewLayer: CALayer?, onPreviewAngle: ((CGFloat) -> Void)? = nil) {
98
+ guard #available(iOS 17.0, *) else { return }
99
+ stop()
100
+ let rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: previewLayer)
101
+ coordinator = rotationCoordinator
102
+ store(rotationCoordinator.videoRotationAngleForHorizonLevelCapture)
103
+ onPreviewAngle?(rotationCoordinator.videoRotationAngleForHorizonLevelPreview)
104
+ let captureKeyPath = \AVCaptureDevice.RotationCoordinator.videoRotationAngleForHorizonLevelCapture
105
+ captureObservation = rotationCoordinator.observe(captureKeyPath) { [weak self] coordinator, _ in
106
+ self?.store(coordinator.videoRotationAngleForHorizonLevelCapture)
107
+ }
108
+ // the coordinator delivers KVO on the main queue; hop only if that ever changes
109
+ let previewKeyPath = \AVCaptureDevice.RotationCoordinator.videoRotationAngleForHorizonLevelPreview
110
+ previewObservation = rotationCoordinator.observe(previewKeyPath) { coordinator, _ in
111
+ let angle = coordinator.videoRotationAngleForHorizonLevelPreview
112
+ if Thread.isMainThread {
113
+ onPreviewAngle?(angle)
114
+ } else {
115
+ DispatchQueue.main.async { onPreviewAngle?(angle) }
116
+ }
117
+ }
118
+ }
119
+
120
+ func stop() {
121
+ captureObservation = nil
122
+ previewObservation = nil
123
+ coordinator = nil
124
+ store(nil)
125
+ }
126
+
127
+ /// The Vision / `CGImage` orientation of a frame captured at `angle`.
128
+ static func imageOrientation(forCaptureAngle angle: CGFloat, isMirrored: Bool) -> CGImagePropertyOrientation {
129
+ switch (Int(angle.rounded()) % 360 + 360) % 360 {
130
+ case 90: return isMirrored ? .leftMirrored : .right
131
+ case 180: return isMirrored ? .downMirrored : .down
132
+ case 270: return isMirrored ? .rightMirrored : .left
133
+ default: return isMirrored ? .upMirrored : .up
134
+ }
135
+ }
136
+
137
+ private func store(_ angle: CGFloat?) {
138
+ lock.lock()
139
+ latestCaptureAngle = angle
140
+ lock.unlock()
141
+ }
142
+ }
143
+ // AI-GENERATED END: keep the camera backward-facing and upright (iPhone Duo), ported from the host app
@@ -132,6 +132,13 @@ public class CameraView: UIView {
132
132
 
133
133
  focusInterfaceView.delegate = camera
134
134
 
135
+ // AI-GENERATED START: scan area follows the frame's layout (resize, rotation, iPhone Duo fold)
136
+ scannerInterfaceView.onFrameChange = { [weak self] frame in
137
+ guard let self else { return }
138
+ self.camera.update(scannerFrameSize: self.showFrame ? frame : nil)
139
+ }
140
+ // AI-GENERATED END: scan area follows the frame's layout (resize, rotation, iPhone Duo fold)
141
+
135
142
  handleCameraPermission()
136
143
 
137
144
  configureHardwareInteraction()
@@ -184,8 +191,9 @@ public class CameraView: UIView {
184
191
  camera.previewView.frame = bounds
185
192
 
186
193
  scannerInterfaceView.frame = bounds
187
- // If frame size changes, we have to update the scanner
188
- camera.update(scannerFrameSize: showFrame ? scannerInterfaceView.frameSize : nil)
194
+ // AI-GENERATED START: the scanner reports its frame from layoutSubviews (onFrameChange)
195
+ scannerInterfaceView.setNeedsLayout()
196
+ // AI-GENERATED END: the scanner reports its frame from layoutSubviews (onFrameChange)
189
197
 
190
198
  focusInterfaceView.frame = bounds
191
199
 
@@ -274,8 +282,9 @@ public class CameraView: UIView {
274
282
 
275
283
  if changedProps.contains("barcodeFrameSize"), let barcodeFrameSize, showFrame, scanBarcode {
276
284
  if let width = barcodeFrameSize["width"] as? CGFloat, let height = barcodeFrameSize["height"] as? CGFloat {
285
+ // AI-GENERATED START: the new frame reaches the camera from layoutSubviews (onFrameChange)
277
286
  scannerInterfaceView.update(frameSize: CGSize(width: width, height: height))
278
- camera.update(scannerFrameSize: showFrame ? scannerInterfaceView.frameSize : nil)
287
+ // AI-GENERATED END: the new frame reaches the camera from layoutSubviews (onFrameChange)
279
288
  }
280
289
  }
281
290
 
@@ -70,6 +70,27 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
70
70
 
71
71
  private var zoomStartedAt: Double = 1.0
72
72
 
73
+ // AI-GENERATED START: backward-facing, upright camera when iPhone Duo folds or unfolds
74
+ private let directionObserver = CameraDirectionObserver()
75
+ private let rotationTracker = CaptureRotationTracker()
76
+ /// The camera type JS asked for (sessionQueue only). On iPhone Duo the "back" camera can be a
77
+ /// front-position device, so the input's `position` no longer tells which type is in use.
78
+ private var cameraType: CameraType?
79
+ /// Main queue only: whether the direction coordinator runs, and the camera it compares against.
80
+ private var followsBackwardDirection = false
81
+ private var activeDevice: AVCaptureDevice?
82
+ private var lastPreviewSize: CGSize = .zero
83
+ /// Rear camera types the back camera captures from, in order of preference.
84
+ private static let rearDeviceTypes: [AVCaptureDevice.DeviceType] = [
85
+ .builtInWideAngleCamera,
86
+ .builtInTelephotoCamera,
87
+ .builtInUltraWideCamera,
88
+ .builtInDualCamera,
89
+ .builtInDualWideCamera,
90
+ .builtInTripleCamera
91
+ ]
92
+ // AI-GENERATED END: backward-facing, upright camera when iPhone Duo folds or unfolds
93
+
73
94
  // MARK: - Lifecycle
74
95
 
75
96
  override init() {
@@ -87,9 +108,45 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
87
108
  // crash class is closed by construction, on every device.
88
109
  cameraPreview.session = session
89
110
  cameraPreview.previewLayer.videoGravity = .resizeAspectFill
111
+
112
+ // AI-GENERATED START: start the direction coordinator only once the preview is onscreen
113
+ cameraPreview.onWindowChange = { [weak self] window in
114
+ guard let self else { return }
115
+ if window == nil {
116
+ self.directionObserver.stop()
117
+ } else {
118
+ // a rotation coordinator made while the layer was off-window reports a 0° preview for good
119
+ if let activeDevice = self.activeDevice {
120
+ self.trackRotation(of: activeDevice)
121
+ }
122
+ self.startDirectionObserverIfNeeded()
123
+ }
124
+ }
125
+ // AI-GENERATED END: start the direction coordinator only once the preview is onscreen
126
+
127
+ // AI-GENERATED START: re-read the preview angle on layout (rotation, iPhone Duo fold/unfold, display switch)
128
+ cameraPreview.onLayout = { [weak self] in
129
+ guard let self else { return }
130
+ if let angle = self.rotationTracker.previewAngle {
131
+ self.applyPreviewAngle(angle)
132
+ }
133
+ // metadataOutputRectConverted also depends on the layer's size
134
+ let size = self.cameraPreview.bounds.size
135
+ if size != self.lastPreviewSize {
136
+ self.lastPreviewSize = size
137
+ self.refreshRectOfInterest()
138
+ }
139
+ }
140
+ // AI-GENERATED END: re-read the preview angle on layout (rotation, iPhone Duo fold/unfold, display switch)
90
141
  }
91
142
 
92
143
  func cameraRemovedFromSuperview() {
144
+ // AI-GENERATED START: backward-facing, upright camera when iPhone Duo folds or unfolds
145
+ directionObserver.stop()
146
+ rotationTracker.stop()
147
+ // AI-GENERATED END: backward-facing, upright camera when iPhone Duo folds or unfolds
148
+
149
+
93
150
  sessionQueue.async { [weak self] in
94
151
  guard let self, self.setupResult == .success else { return }
95
152
 
@@ -106,6 +163,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
106
163
 
107
164
  #if !targetEnvironment(macCatalyst)
108
165
  motionManager?.stopAccelerometerUpdates()
166
+ // AI-GENERATED START: display-relative gravity for onOrientationChange (iPhone Duo)
167
+ motionManager?.stopDeviceMotionUpdates()
168
+ // AI-GENERATED END: display-relative gravity for onOrientationChange (iPhone Duo)
109
169
 
110
170
  NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
111
171
 
@@ -160,10 +220,17 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
160
220
  // pure connection-property write (no session config transaction → no race
161
221
  // with startRunning). The connection exists because configureSession has
162
222
  // committed the video input above.
223
+ // AI-GENERATED START: rotation coordinator angle, which follows the display iPhone Duo uses
224
+ let videoDevice = self.videoDeviceInput?.device
163
225
  DispatchQueue.main.async { [weak self] in
164
226
  guard let self else { return }
165
- self.setVideoOrientationToInterfaceOrientation()
227
+ if #available(iOS 17.0, *), let videoDevice {
228
+ self.followDevice(videoDevice, cameraType: cameraType)
229
+ } else {
230
+ self.setVideoOrientationToInterfaceOrientation()
231
+ }
166
232
  }
233
+ // AI-GENERATED END: rotation coordinator angle, which follows the display iPhone Duo uses
167
234
 
168
235
  // Order matches the previous behaviour: start, then observe, then torch.
169
236
  self.startSessionIfNeeded()
@@ -197,6 +264,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
197
264
  }
198
265
  session.addInput(videoDeviceInput)
199
266
  self.videoDeviceInput = videoDeviceInput
267
+ // AI-GENERATED START: remember the requested type, the input's position can differ on iPhone Duo
268
+ self.cameraType = cameraType
269
+ // AI-GENERATED END: remember the requested type, the input's position can differ on iPhone Duo
200
270
  self.resetZoom(forDevice: videoDevice)
201
271
 
202
272
  // 2. Preset — set inside the transaction with the device input already
@@ -428,9 +498,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
428
498
  func update(cameraType: CameraType) {
429
499
  sessionQueue.async { [weak self] in
430
500
  guard let self else { return }
431
- if self.videoDeviceInput?.device.position == cameraType.avPosition {
501
+ // AI-GENERATED START: compare the requested type, the input's position can differ on iPhone Duo
502
+ if self.cameraType == cameraType {
432
503
  return
433
504
  }
505
+ // AI-GENERATED END: compare the requested type, the input's position can differ on iPhone Duo
434
506
 
435
507
  // Avoid chaining device inputs when camera input is denied by the user, since both front and rear vido input devices will be nil
436
508
  guard self.setupResult == .success,
@@ -442,27 +514,131 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
442
514
 
443
515
  self.removeObservers()
444
516
  self.beginConfiguration()
445
- defer { self.commitConfiguration() }
446
517
 
447
518
  // Remove the existing device input first, since using the front and back camera simultaneously is not supported.
448
519
  self.session.removeInput(currentViewDeviceInput)
449
520
 
450
- if self.session.canAddInput(videoDeviceInput) {
521
+ // AI-GENERATED START: track the requested type and restore mirroring after an iPhone Duo swap
522
+ let didSwitch = self.session.canAddInput(videoDeviceInput)
523
+ if didSwitch {
451
524
  self.session.addInput(videoDeviceInput)
452
525
  self.resetZoom(forDevice: videoDevice)
453
526
  self.videoDeviceInput = videoDeviceInput
527
+ self.cameraType = cameraType
528
+ self.applyVideoMirroring(isFrontFacingBackward: false)
454
529
  } else {
455
530
  // If it fails, put back current camera
456
531
  self.session.addInput(currentViewDeviceInput)
457
532
  }
458
533
 
459
534
  self.addObservers()
535
+ // committed before the main queue reads the new preview connection
536
+ self.commitConfiguration()
460
537
 
461
538
  // We need to reapply the configuration after reloading the camera
462
539
  self.update(torchMode: self.torchMode)
540
+
541
+ guard didSwitch else { return }
542
+ DispatchQueue.main.async { [weak self] in
543
+ self?.followDevice(videoDevice, cameraType: cameraType)
544
+ }
545
+ // AI-GENERATED END: track the requested type and restore mirroring after an iPhone Duo swap
546
+ }
547
+ }
548
+
549
+ // AI-GENERATED START: backward-facing, upright camera when iPhone Duo folds or unfolds
550
+ /// Follows `device`'s rotation and, for the back camera, which way it faces; call on the main queue.
551
+ private func followDevice(_ device: AVCaptureDevice, cameraType: CameraType) {
552
+ activeDevice = device
553
+ followsBackwardDirection = cameraType == .back
554
+ trackRotation(of: device)
555
+ if followsBackwardDirection {
556
+ startDirectionObserverIfNeeded()
557
+ } else {
558
+ directionObserver.stop()
559
+ }
560
+ }
561
+
562
+ /// Starts following which cameras face backward relative to the preview; call on the main queue.
563
+ private func startDirectionObserverIfNeeded() {
564
+ guard followsBackwardDirection, cameraPreview.window != nil else { return }
565
+ directionObserver.start(
566
+ previewView: cameraPreview,
567
+ deviceTypes: Self.rearDeviceTypes,
568
+ currentCameraID: { [weak self] in self?.activeDevice?.uniqueID },
569
+ onReplace: { [weak self] replacement in self?.switchCamera(to: replacement) }
570
+ )
571
+ }
572
+
573
+ /// Swaps the video input for a camera that faces backward again after iPhone Duo folded or unfolded.
574
+ private func switchCamera(to replacement: CameraDirectionObserver.Replacement) {
575
+ sessionQueue.async { [weak self] in
576
+ guard let self, self.setupResult == .success, self.cameraType == .back,
577
+ let currentInput = self.videoDeviceInput,
578
+ currentInput.device.uniqueID != replacement.uniqueID,
579
+ let device = AVCaptureDevice(uniqueID: replacement.uniqueID),
580
+ let newInput = try? AVCaptureDeviceInput(device: device) else {
581
+ return
582
+ }
583
+
584
+ self.removeObservers()
585
+ self.beginConfiguration()
586
+ self.session.removeInput(currentInput)
587
+ // decide from a local flag, not by re-reading the shared input
588
+ let didSwitch = self.session.canAddInput(newInput)
589
+ if didSwitch {
590
+ self.session.addInput(newInput)
591
+ self.videoDeviceInput = newInput
592
+ // a front camera facing backward is shown and captured unmirrored, like a rear camera
593
+ self.applyVideoMirroring(isFrontFacingBackward: replacement.isFrontPosition)
594
+ } else if self.session.canAddInput(currentInput) {
595
+ self.session.addInput(currentInput)
596
+ }
597
+ self.addObservers()
598
+ self.commitConfiguration()
599
+
600
+ self.update(torchMode: self.torchMode)
601
+ guard didSwitch else { return }
602
+ self.resetZoom(forDevice: device)
603
+ DispatchQueue.main.async { [weak self] in
604
+ self?.followDevice(device, cameraType: .back)
605
+ }
606
+ }
607
+ }
608
+
609
+ /// Follows the capture and preview angles of `device`; call on the main queue.
610
+ private func trackRotation(of device: AVCaptureDevice) {
611
+ rotationTracker.start(device: device, previewLayer: cameraPreview.previewLayer) { [weak self] angle in
612
+ self?.applyPreviewAngle(angle)
463
613
  }
464
614
  }
465
615
 
616
+ /// Rotates the preview connection to `angle` when it differs; call on the main queue.
617
+ private func applyPreviewAngle(_ angle: CGFloat) {
618
+ guard #available(iOS 17.0, *),
619
+ let connection = cameraPreview.previewLayer.connection,
620
+ connection.videoRotationAngle != angle,
621
+ connection.isVideoRotationAngleSupported(angle) else {
622
+ return
623
+ }
624
+ connection.videoRotationAngle = angle
625
+ // metadataOutputRectConverted depends on the preview connection's angle
626
+ refreshRectOfInterest()
627
+ }
628
+
629
+ /// A front camera facing backward (iPhone Duo) stays unmirrored; every other camera gets the system
630
+ /// default back. MUST run on `sessionQueue`, inside a configuration transaction.
631
+ private func applyVideoMirroring(isFrontFacingBackward: Bool) {
632
+ assertOnSessionQueue()
633
+ session.connections.filter(\.isVideoMirroringSupported).forEach { connection in
634
+ connection.automaticallyAdjustsVideoMirroring = !isFrontFacingBackward
635
+ if isFrontFacingBackward {
636
+ connection.isVideoMirrored = false
637
+ }
638
+ }
639
+ }
640
+ // AI-GENERATED END: backward-facing, upright camera when iPhone Duo folds or unfolds
641
+
466
642
  func update(resizeMode: ResizeMode) {
467
643
  DispatchQueue.main.async {
468
644
  switch resizeMode {
@@ -524,10 +700,17 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
524
700
  return
525
701
  }
526
702
 
527
- // Set video orientation if available
528
- if let videoPreviewLayerOrientation {
703
+ // AI-GENERATED START: rotation coordinator angle, which follows the display iPhone Duo uses
704
+ // Gravity alone can't tell which display iPhone Duo uses, so iOS 17+ takes the rotation
705
+ // coordinator's horizon-level angle; the accelerometer value only covers older systems.
706
+ if #available(iOS 17.0, *), let captureAngle = self.rotationTracker.captureAngle {
707
+ if photoOutputConnection.isVideoRotationAngleSupported(captureAngle) {
708
+ photoOutputConnection.videoRotationAngle = captureAngle
709
+ }
710
+ } else if let videoPreviewLayerOrientation {
529
711
  photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
530
712
  }
713
+ // AI-GENERATED END: rotation coordinator angle, which follows the display iPhone Duo uses
531
714
 
532
715
  // Pause OCR + barcode work on the session queue, right before capture.
533
716
  self.pauseNonEssentialOutputs()
@@ -594,28 +777,49 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
594
777
  guard self.scannerFrameSize != scannerFrameSize else { return }
595
778
  self.sessionQueue.async {
596
779
  self.scannerFrameSize = scannerFrameSize
597
- if !self.session.isRunning {
598
- return
599
- }
780
+ // AI-GENERATED START: recompute the scan area when the frame or the preview angle changes
781
+ self.applyRectOfInterest()
782
+ }
783
+ }
600
784
 
601
- DispatchQueue.main.async {
602
- var visibleRect: CGRect?
603
- if let scannerFrameSize, scannerFrameSize != .zero {
604
- visibleRect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scannerFrameSize)
605
- }
785
+ /// Re-derives `rectOfInterest` from the scanner frame. Call whenever the frame or the preview
786
+ /// connection's angle changes: `metadataOutputRectConverted` depends on both.
787
+ private func refreshRectOfInterest() {
788
+ sessionQueue.async { [weak self] in
789
+ self?.applyRectOfInterest()
790
+ }
791
+ }
606
792
 
607
- self.sessionQueue.async {
608
- if self.metadataOutput.rectOfInterest == visibleRect {
609
- return
610
- }
793
+ /// MUST run on `sessionQueue`.
794
+ private func applyRectOfInterest() {
795
+ assertOnSessionQueue()
796
+ if !session.isRunning {
797
+ return
798
+ }
799
+ let scannerFrameSize = self.scannerFrameSize
611
800
 
612
- self.metadataOutput.rectOfInterest = visibleRect ?? CGRect(x: 0, y: 0, width: 1, height: 1)
613
- // We need to reapply the configuration after touching the metadataOutput
614
- self.update(torchMode: self.torchMode)
801
+ // metadataOutputRectConverted must be read on the main thread.
802
+ DispatchQueue.main.async {
803
+ var visibleRect: CGRect?
804
+ if let scannerFrameSize, scannerFrameSize != .zero {
805
+ visibleRect = self.cameraPreview.previewLayer
806
+ .metadataOutputRectConverted(fromLayerRect: scannerFrameSize)
807
+ }
808
+
809
+ self.sessionQueue.async {
810
+ // a photo capture pauses scanning; resumeNonEssentialOutputs restores the rect afterwards
811
+ guard self.inProgressPhotoCaptureDelegates.isEmpty,
812
+ self.metadataOutput.rectOfInterest != visibleRect else {
813
+ return
615
814
  }
815
+
816
+ self.metadataOutput.rectOfInterest = visibleRect ?? CGRect(x: 0, y: 0, width: 1, height: 1)
817
+ // We need to reapply the configuration after touching the metadataOutput
818
+ self.update(torchMode: self.torchMode)
616
819
  }
617
820
  }
618
821
  }
822
+ // AI-GENERATED END: recompute the scan area when the frame or the preview angle changes
619
823
 
620
824
 
621
825
  func isTextDetectionEnabled(_ isEnabled: Bool, onTextRead: ((String) -> Void)?) {
@@ -651,11 +855,19 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
651
855
  if now.timeIntervalSince(lastTextProcess) < textThrottle { return }
652
856
  lastTextProcess = now
653
857
 
858
+ // AI-GENERATED START: rotation coordinator angle, so OCR reads text in sideways or upside-down frames
859
+ let orientation = rotationTracker.captureAngle.map {
860
+ CaptureRotationTracker.imageOrientation(forCaptureAngle: $0, isMirrored: connection.isVideoMirrored)
861
+ } ?? .up
862
+ // AI-GENERATED END: rotation coordinator angle, so OCR reads text in sideways or upside-down frames
863
+
654
864
  globalOCRQueue.async {
655
865
  guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
656
866
  var requestOptions: [VNImageOption: Any] = [:]
657
867
 
658
- let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: requestOptions)
868
+ let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer,
869
+ orientation: orientation,
870
+ options: requestOptions)
659
871
  do {
660
872
  try handler.perform([request])
661
873
  } catch {
@@ -713,6 +925,16 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
713
925
  }
714
926
 
715
927
  private func getBestDevice(for cameraType: CameraType) -> AVCaptureDevice? {
928
+ // AI-GENERATED START: same rear camera set the host scanner uses; the wide angle stays first
929
+ if cameraType == .back,
930
+ let device = AVCaptureDevice.DiscoverySession(
931
+ deviceTypes: Self.rearDeviceTypes,
932
+ mediaType: .video,
933
+ position: .back
934
+ ).devices.first {
935
+ return device
936
+ }
937
+ // AI-GENERATED END: same rear camera set the host scanner uses; the wide angle stays first
716
938
  if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraType.avPosition) {
717
939
  return device // single-lens/physical device
718
940
  }
@@ -780,6 +1002,22 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
780
1002
  motionManager?.accelerometerUpdateInterval = 0.2
781
1003
  motionManager?.gyroUpdateInterval = 0.2
782
1004
 
1005
+ // AI-GENERATED START: display-relative gravity for onOrientationChange (iPhone Duo)
1006
+ // The accelerometer reports in the hardware frame, a quarter turn off from the UI on the iPhone Duo
1007
+ // inner display. Device motion referenced to the preview view arrives corrected for that display.
1008
+ #if canImport(UIKit, _version: 9127.0.85.28)
1009
+ if #available(iOS 27.0, *), let motionManager, motionManager.isDeviceMotionAvailable {
1010
+ motionManager.deviceMotionUpdateInterval = 0.2
1011
+ motionManager.deviceMotionBody = cameraPreview
1012
+ motionManager.startDeviceMotionUpdates(to: OperationQueue()) { [weak self] motion, error in
1013
+ guard error == nil, let gravity = motion?.gravity else { return }
1014
+ self?.reportOrientation(from: gravity)
1015
+ }
1016
+ return
1017
+ }
1018
+ #endif
1019
+ // AI-GENERATED END: display-relative gravity for onOrientationChange (iPhone Duo)
1020
+
783
1021
  motionManager?.startAccelerometerUpdates(to: OperationQueue(), withHandler: { [weak self] (accelerometerData, error) -> Void in
784
1022
  guard let self else { return }
785
1023
  guard error == nil else {
@@ -791,18 +1029,25 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
791
1029
  return
792
1030
  }
793
1031
 
794
- guard let newOrientation = self.deviceOrientation(from: accelerometerData.acceleration),
795
- newOrientation != self.deviceOrientation,
796
- let orientation = Orientation(from: newOrientation) else {
797
- return
798
- }
799
-
800
- self.deviceOrientation = newOrientation
801
- self.onOrientationChange?(["orientation": orientation.rawValue])
1032
+ // AI-GENERATED START: shared with the display-relative device motion path
1033
+ self.reportOrientation(from: accelerometerData.acceleration)
802
1034
  })
803
1035
  #endif
804
1036
  }
805
1037
 
1038
+ /// Sends `onOrientationChange` when gravity points to a new orientation.
1039
+ private func reportOrientation(from acceleration: CMAcceleration) {
1040
+ guard let newOrientation = deviceOrientation(from: acceleration),
1041
+ newOrientation != deviceOrientation,
1042
+ let orientation = Orientation(from: newOrientation) else {
1043
+ return
1044
+ }
1045
+
1046
+ deviceOrientation = newOrientation
1047
+ onOrientationChange?(["orientation": orientation.rawValue])
1048
+ }
1049
+ // AI-GENERATED END: shared with the display-relative device motion path
1050
+
806
1051
  private func deviceOrientation(from acceleration: CMAcceleration) -> UIDeviceOrientation? {
807
1052
  let threshold = 0.75
808
1053
  if acceleration.x >= threshold {
@@ -866,6 +1111,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
866
1111
  resetFocus?()
867
1112
  }
868
1113
 
1114
+ // AI-GENERATED START: legacy preview orientation, iOS 17+ uses the rotation coordinator (trackRotation)
1115
+ // AI-GENERATED END: legacy preview orientation, iOS 17+ uses the rotation coordinator (trackRotation)
869
1116
  private func setVideoOrientationToInterfaceOrientation() {
870
1117
  #if !targetEnvironment(macCatalyst)
871
1118
  var interfaceOrientation: UIInterfaceOrientation
@@ -23,4 +23,25 @@ class RealPreviewView: UIView {
23
23
  get { previewLayer.session }
24
24
  set { previewLayer.session = newValue }
25
25
  }
26
+
27
+ // AI-GENERATED START: report when the preview is onscreen, for the camera direction coordinator
28
+ /// Called on the main queue with the new window, or nil once the preview leaves it.
29
+ /// `CameraDirectionObserver` needs an onscreen preview view.
30
+ var onWindowChange: ((UIWindow?) -> Void)?
31
+
32
+ override func didMoveToWindow() {
33
+ super.didMoveToWindow()
34
+ onWindowChange?(window)
35
+ }
36
+ // AI-GENERATED END: report when the preview is onscreen, for the camera direction coordinator
37
+
38
+ // AI-GENERATED START: re-read the preview angle on layout (rotation, iPhone Duo fold/unfold, display switch)
39
+ /// Called on the main queue after every layout pass.
40
+ var onLayout: (() -> Void)?
41
+
42
+ override func layoutSubviews() {
43
+ super.layoutSubviews()
44
+ onLayout?()
45
+ }
46
+ // AI-GENERATED END: re-read the preview angle on layout (rotation, iPhone Duo fold/unfold, display switch)
26
47
  }
@@ -45,14 +45,28 @@ class ScannerInterfaceView: UIView {
45
45
  fatalError("init(coder:) has not been implemented")
46
46
  }
47
47
 
48
- override func draw(_ rect: CGRect) {
49
- super.draw(rect)
50
-
51
- frameView.frame = CGRect(x: 0, y: 0, width: barcodeFrameWidth, height: barcodeFrameHeight)
52
- frameView.center = center
48
+ // AI-GENERATED START: lay the frame out in the area clear of the iPhone Duo fold and camera punchout
49
+ /// Called on the main queue with the frame's rect, in this view's coordinates, after every layout.
50
+ var onFrameChange: ((CGRect) -> Void)?
51
+
52
+ // UIKit re-runs layout when the reserved regions change, so they are re-read here without polling.
53
+ override func layoutSubviews() {
54
+ super.layoutSubviews()
55
+
56
+ let area = ScannerReservedRegions.availableArea(in: self)
57
+ let size = CGSize(width: min(barcodeFrameWidth, area.width), height: min(barcodeFrameHeight, area.height))
58
+ let previousSize = frameView.bounds.size
59
+ frameView.frame = CGRect(origin: .zero, size: size)
60
+ frameView.center = CGPoint(x: area.midX, y: area.midY)
61
+ if previousSize != size {
62
+ // the corners and laser are laid out in ScannerFrameView.draw(_:)
63
+ frameView.setNeedsDisplay()
64
+ }
53
65
 
54
66
  updateOverlaySize(frameView.frame)
67
+ onFrameChange?(frameView.frame)
55
68
  }
69
+ // AI-GENERATED END: lay the frame out in the area clear of the iPhone Duo fold and camera punchout
56
70
 
57
71
  // MARK: - Public
58
72
 
@@ -79,20 +93,75 @@ class ScannerInterfaceView: UIView {
79
93
  func update(frameSize: CGSize) {
80
94
  barcodeFrameWidth = frameSize.width
81
95
  barcodeFrameHeight = frameSize.height
82
- frameView.setNeedsDisplay()
83
- setNeedsDisplay()
96
+ // AI-GENERATED START: the frame is laid out in layoutSubviews, not draw(_:)
97
+ setNeedsLayout()
98
+ // AI-GENERATED END: the frame is laid out in layoutSubviews, not draw(_:)
84
99
  }
85
100
 
86
101
  // MARK: - Private
87
102
 
103
+ // AI-GENERATED START: dim around a frame that is no longer always centered
88
104
  private func updateOverlaySize(_ frameRect: CGRect) {
89
- topOverlayView.frame = CGRect(x: 0, y: 0, width: frame.size.width, height: frameRect.origin.y)
90
- leftOverlayView.frame = CGRect(x: 0, y: frameRect.origin.y, width: (frame.size.width - barcodeFrameWidth) / 2, height: barcodeFrameHeight)
91
- rightOverlayView.frame = CGRect(x: (frame.size.width - barcodeFrameWidth) / 2 + barcodeFrameWidth, y: frameRect.origin.y, width: (frame.size.width - barcodeFrameWidth) / 2, height: barcodeFrameHeight)
105
+ topOverlayView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: frameRect.minY)
106
+ leftOverlayView.frame = CGRect(x: 0, y: frameRect.minY, width: frameRect.minX, height: frameRect.height)
107
+ rightOverlayView.frame = CGRect(
108
+ x: frameRect.maxX,
109
+ y: frameRect.minY,
110
+ width: bounds.width - frameRect.maxX,
111
+ height: frameRect.height)
92
112
  bottomOverlayView.frame = CGRect(
93
113
  x: 0,
94
- y: frameRect.origin.y + barcodeFrameHeight,
95
- width: frame.size.width,
96
- height: frame.size.height - frameRect.origin.y - barcodeFrameHeight)
114
+ y: frameRect.maxY,
115
+ width: bounds.width,
116
+ height: bounds.height - frameRect.maxY)
117
+ }
118
+ // AI-GENERATED END: dim around a frame that is no longer always centered
119
+ }
120
+
121
+ // AI-GENERATED START: keep the scanner frame clear of iPhone Duo reserved regions
122
+ /// The part of a scanner view the frame, laser and dimmed overlay are laid out in (iOS 27.1+, iPhone Duo):
123
+ /// - `.division`: while partially folded, the frame moves to the trailing (vertical fold) or bottom
124
+ /// (horizontal fold) part, like the host's `FoldPlacement`.
125
+ /// - `.occlusion`: a camera punchout the safe area leaves inside the area pushes only the nearest edge,
126
+ /// like the host's `QRScanReservedRegions`.
127
+ /// The whole view on every other device, and compiled out on SDKs older than iOS 27.1.
128
+ enum ScannerReservedRegions {
129
+ static func availableArea(in view: UIView) -> CGRect {
130
+ var area = view.bounds
131
+ #if canImport(UIKit, _version: 9127.0.85.28)
132
+ if #available(iOS 27.1, *) {
133
+ let folds = view.reservedRegions(kind: .division).filter(\.isActive).map(\.frame)
134
+ if let fold = folds.dropFirst().reduce(folds.first, { $0?.union($1) }) {
135
+ area = placement(avoiding: fold, in: area)
136
+ }
137
+ for region in view.reservedRegions(kind: .occlusion) where region.isActive {
138
+ area = clearing(region.frame, from: area)
139
+ }
140
+ }
141
+ #endif
142
+ return area
143
+ }
144
+
145
+ /// The trailing part of a vertical fold, or the bottom part of a horizontal one.
146
+ static func placement(avoiding fold: CGRect, in area: CGRect) -> CGRect {
147
+ guard area.width > 0, area.height > 0 else { return area }
148
+ if fold.height > fold.width {
149
+ return area.divided(atDistance: min(max(fold.maxX - area.minX, 0), area.width), from: .minXEdge).remainder
150
+ }
151
+ return area.divided(atDistance: min(max(fold.maxY - area.minY, 0), area.height), from: .minYEdge).remainder
152
+ }
153
+
154
+ /// Moves the one edge of `area` that clears `occlusion` with the least content lost.
155
+ static func clearing(_ occlusion: CGRect, from area: CGRect) -> CGRect {
156
+ guard area.width > 0, area.height > 0, occlusion.intersects(area) else { return area }
157
+ let candidates: [(cost: CGFloat, edge: CGRectEdge)] = [
158
+ (occlusion.maxY - area.minY, .minYEdge),
159
+ (area.maxY - occlusion.minY, .maxYEdge),
160
+ (occlusion.maxX - area.minX, .minXEdge),
161
+ (area.maxX - occlusion.minX, .maxXEdge),
162
+ ]
163
+ guard let cut = candidates.min(by: { $0.cost < $1.cost }) else { return area }
164
+ return area.divided(atDistance: cut.cost, from: cut.edge).remainder
97
165
  }
98
166
  }
167
+ // AI-GENERATED END: keep the scanner frame clear of iPhone Duo reserved regions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/camerakit",
3
- "version": "0.168.1-beta.2",
3
+ "version": "0.168.1-beta.20",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/teslamotors/react-native-camera-kit.git"