@momo-kits/camerakit 0.161.2-beta.5 → 0.161.2-beta.8

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.
@@ -84,10 +84,10 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
84
84
  - (void)prepareView
85
85
  {
86
86
  _view = [[CKCameraView alloc] init];
87
-
87
+
88
88
  // just need to pass something, it won't really be used on fabric, but it's used to create events (it won't impact sending them)
89
89
  _view.reactTag = @-1;
90
-
90
+
91
91
  __weak __typeof__(self) weakSelf = self;
92
92
 
93
93
  [_view setOnReadCode:^(NSDictionary* event) {
@@ -137,7 +137,7 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
137
137
  std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onMRZ({.docMRZ = docMRZ});
138
138
  }
139
139
  }];
140
-
140
+
141
141
  self.contentView = _view;
142
142
  }
143
143
 
@@ -232,7 +232,7 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
232
232
  }
233
233
  id zoomMode = CKConvertFollyDynamicToId(newProps.zoomMode);
234
234
  if (zoomMode != nil) {
235
- _view.zoomMode = [focusMode isEqualToString:@"on"] ? CKZoomModeOn : CKZoomModeOff;
235
+ _view.zoomMode = [zoomMode isEqualToString:@"on"] ? CKZoomModeOn : CKZoomModeOff;
236
236
  [changedProps addObject:@"zoomMode"];
237
237
  }
238
238
  id zoom = CKConvertFollyDynamicToId(newProps.zoom);
@@ -251,8 +251,8 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
251
251
  _view.barcodeFrameSize = @{@"width": @(barcodeWidth), @"height": @(barcodeHeight)};
252
252
  [changedProps addObject:@"barcodeFrameSize"];
253
253
  }
254
-
255
-
254
+
255
+
256
256
  [super updateProps:props oldProps:oldProps];
257
257
  [_view didSetProps:changedProps];
258
258
  }
@@ -260,6 +260,12 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
260
260
  - (void)prepareForRecycle
261
261
  {
262
262
  [super prepareForRecycle];
263
+
264
+ if (_view != nil) {
265
+ [_view removeFromSuperview];
266
+ _view = nil;
267
+ }
268
+
263
269
  [self prepareView];
264
270
  }
265
271
 
@@ -18,12 +18,8 @@ import Foundation
18
18
  return CameraView()
19
19
  }
20
20
 
21
- @objc public static func capture(camera: CameraView,
22
- options: NSDictionary,
23
- resolve: @escaping RCTPromiseResolveBlock,
24
- reject: @escaping RCTPromiseRejectBlock) {
25
- camera.capture(options as! [String: Any], onSuccess: { resolve($0) },
26
- onError: { reject("capture_error", $0, nil) })
21
+ @objc public static func capture(camera: CameraView, options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
22
+ camera.capture((options as? [String: Any]) ?? [:], onSuccess: { resolve($0) }, onError: { reject("capture_error", $0, nil) })
27
23
  }
28
24
 
29
25
  @objc public static func checkDeviceCameraAuthorizationStatus(_ resolve: @escaping RCTPromiseResolveBlock,
@@ -361,8 +361,8 @@ public class CameraView: UIView {
361
361
  let features = detector?.features(in: ciImage)
362
362
 
363
363
  if let firstFeature = features?.first as? CIQRCodeFeature {
364
- if (firstFeature.messageString != nil ) {
365
- self.onBarcodeRead(barcode: firstFeature.messageString!, codeFormat: CodeFormat.qr)
364
+ if let messageString = firstFeature.messageString {
365
+ self.onBarcodeRead(barcode: messageString, codeFormat: CodeFormat.qr)
366
366
  }
367
367
  return
368
368
  }
@@ -22,19 +22,19 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
22
22
 
23
23
  private let cameraPreview = RealPreviewView(frame: .zero)
24
24
  private let session = AVCaptureSession()
25
- // Communicate with the session and other session objects on this queue.
26
- private let sessionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit")
27
-
25
+ private static let sharedSessionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit.session")
26
+ private let sessionQueue = RealCamera.sharedSessionQueue
27
+
28
28
  // utilities
29
29
  private var setupResult: SetupResult = .notStarted
30
30
  private var configurationDepth: Int = 0 // Tracks nested beginConfiguration/commitConfiguration calls
31
31
  private var pendingStop: Bool = false // Queues stop request during configuration
32
32
  private var backgroundRecordingId: UIBackgroundTaskIdentifier = .invalid
33
-
33
+
34
34
  private var videoDeviceInput: AVCaptureDeviceInput?
35
35
  private let photoOutput = AVCapturePhotoOutput()
36
36
  private let metadataOutput = AVCaptureMetadataOutput()
37
-
37
+
38
38
  private var resizeMode: ResizeMode = .cover
39
39
  private var flashMode: FlashMode = .auto
40
40
  private var torchMode: TorchMode = .off
@@ -49,44 +49,40 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
49
49
  private var lastOnZoom: Double?
50
50
  private var zoom: Double?
51
51
  private var maxZoom: Double?
52
-
52
+
53
53
  private var deviceOrientation = UIDeviceOrientation.unknown
54
54
  private var motionManager: CMMotionManager?
55
-
55
+
56
56
  // KVO observation
57
57
  private var adjustingFocusObservation: NSKeyValueObservation?
58
58
 
59
+ private var notificationObservers: [NSObjectProtocol] = []
60
+
59
61
  // Keep delegate objects in memory to avoid collecting them before photo capturing finishes
60
62
  private var inProgressPhotoCaptureDelegates = [Int64: PhotoCaptureDelegate]()
61
-
63
+
62
64
  private var onTextRead: ((_ text: String) -> Void)?
63
65
  private let videoDataOutput = AVCaptureVideoDataOutput()
64
66
  private var textRequest: VNRecognizeTextRequest?
65
67
  private var textDetectionEnabled = false
66
68
  private var lastTextProcess = Date.distantPast
67
69
  private let textThrottle: TimeInterval = 0.35 // seconds
68
-
70
+
69
71
  private var zoomStartedAt: Double = 1.0
70
-
72
+
71
73
  // MARK: - Lifecycle
72
74
 
73
75
  func cameraRemovedFromSuperview() {
74
- sessionQueue.async {
75
- if self.setupResult == .success {
76
- // Only stop if session is running
77
- guard self.session.isRunning else {
78
- self.removeObservers()
79
- return
80
- }
76
+ sessionQueue.async { [weak self] in
77
+ guard let self, self.setupResult == .success else { return }
81
78
 
82
- // If we're currently configuring the session, mark pending stop
83
- // and let the configuration completion handle it
84
- if self.configurationDepth > 0 {
85
- self.pendingStop = true
86
- return
87
- }
79
+ // Stop now if idle/safe, or defer the stop until the open configuration
80
+ // transaction commits (stopSessionIfNeeded sets pendingStop in that case).
81
+ self.stopSessionIfNeeded()
88
82
 
89
- self.session.stopRunning()
83
+ // If the stop was deferred, handlePendingStopIfNeeded() will remove the
84
+ // observers once the transaction commits; otherwise remove them now.
85
+ if !self.pendingStop {
90
86
  self.removeObservers()
91
87
  }
92
88
  }
@@ -99,99 +95,120 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
99
95
  UIDevice.current.endGeneratingDeviceOrientationNotifications()
100
96
  #endif
101
97
  }
102
-
98
+
103
99
  deinit {
104
100
  removeObservers()
105
- }
106
-
107
- // MARK: - Public
108
101
 
109
- func setup(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) {
110
-
111
- // Setup the capture session with priority on basic video preview first
102
+ // Ensure the capture hardware is released even if cameraRemovedFromSuperview()
103
+ // was never delivered (e.g. a forced React surface cleanup during rapid
104
+ // mount/unmount). Capture only the session object so the block does not
105
+ // resurrect `self`.
106
+ let session = self.session
112
107
  sessionQueue.async {
113
- self.setupResult = self.setupBasicVideoInput(cameraType: cameraType)
114
-
115
- if self.setupResult == .success {
116
- self.session.startRunning()
117
-
118
- self.setupAdditionalOutputs(supportedBarcodeType: supportedBarcodeType)
108
+ if session.isRunning {
109
+ session.stopRunning()
110
+ }
111
+ }
112
+ }
119
113
 
120
- self.addObservers()
114
+ // MARK: - Public
121
115
 
122
- self.update(torchMode: self.torchMode)
123
- }
116
+ func setup(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) {
117
+ sessionQueue.async { [weak self] in
118
+ guard let self else { return }
119
+
120
+ // Idempotency guard: a RealCamera configures its session exactly once.
121
+ // A fresh instance is created on every mount / Fabric recycle, so this
122
+ // never blocks a legitimate restart — resuming after stopCamera() is
123
+ // handled by startCamera() -> startSessionIfNeeded().
124
+ guard self.setupResult == .notStarted else { return }
125
+
126
+ // Configure the WHOLE session (inputs, preset, outputs) inside a single
127
+ // begin/commit transaction on the session queue. The preset is set here
128
+ // and never on the main queue, so the startRunning() below can no longer
129
+ // observe the session mid-configuration from another thread.
130
+ //
131
+ // This is the root-cause fix for:
132
+ // "[AVCaptureSession startRunning] startRunning may not be called between
133
+ // calls to beginConfiguration and commitConfiguration".
134
+ self.setupResult = self.configureSession(cameraType: cameraType,
135
+ supportedBarcodeType: supportedBarcodeType)
136
+
137
+ guard self.setupResult == .success else { return }
138
+
139
+ // Order matches the previous behaviour: start, then observe, then torch.
140
+ self.startSessionIfNeeded()
141
+ self.addObservers()
142
+ self.update(torchMode: self.torchMode)
124
143
 
125
- DispatchQueue.main.async {
144
+ // Only the preview LAYER is touched on the main queue. Connecting an
145
+ // AVCaptureVideoPreviewLayer to a session from the main thread is
146
+ // sanctioned by AVFoundation (see Apple's AVCam) and does NOT open a
147
+ // session configuration transaction, so it cannot race startRunning().
148
+ DispatchQueue.main.async { [weak self] in
149
+ guard let self else { return }
126
150
  self.cameraPreview.session = self.session
127
151
  self.cameraPreview.previewLayer.videoGravity = .resizeAspectFill
128
- self.session.sessionPreset = .photo
129
152
  self.setVideoOrientationToInterfaceOrientation()
130
153
  }
131
154
  }
132
-
133
- DispatchQueue.global(qos: .utility).async {
134
- self.initializeMotionManager()
135
- }
136
155
 
156
+ DispatchQueue.global(qos: .utility).async { [weak self] in
157
+ self?.initializeMotionManager()
158
+ }
137
159
  }
138
160
 
139
161
  // MARK: - Private optimization methods
140
-
141
- private func setupBasicVideoInput(cameraType: CameraType) -> SetupResult {
162
+
163
+ /// Configures the video input, session preset and every output in ONE atomic
164
+ /// transaction. Must be called on `sessionQueue`. Order is significant:
165
+ /// beginConfiguration -> addInput -> set preset -> addOutputs -> commitConfiguration.
166
+ private func configureSession(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) -> SetupResult {
167
+ assertOnSessionQueue()
142
168
  guard let videoDevice = self.getBestDevice(for: cameraType),
143
169
  let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
144
170
  return .sessionConfigurationFailed
145
171
  }
146
-
147
- configurationDepth += 1
148
- session.beginConfiguration()
149
- defer {
150
- session.commitConfiguration()
151
- configurationDepth -= 1
152
- handlePendingStopIfNeeded()
153
- }
154
-
155
- if session.canAddInput(videoDeviceInput) {
156
- session.addInput(videoDeviceInput)
157
- self.videoDeviceInput = videoDeviceInput
158
- self.resetZoom(forDevice: videoDevice)
159
- return .success
160
- }
161
- return .sessionConfigurationFailed
162
- }
163
-
164
- private func setupAdditionalOutputs(supportedBarcodeType: [CodeFormat]) {
165
- configurationDepth += 1
166
- session.beginConfiguration()
167
- defer {
168
- session.commitConfiguration()
169
- configurationDepth -= 1
170
- handlePendingStopIfNeeded()
172
+
173
+ beginConfiguration()
174
+ defer { commitConfiguration() }
175
+
176
+ // 1. Video input
177
+ guard session.canAddInput(videoDeviceInput) else {
178
+ return .sessionConfigurationFailed
179
+ }
180
+ session.addInput(videoDeviceInput)
181
+ self.videoDeviceInput = videoDeviceInput
182
+ self.resetZoom(forDevice: videoDevice)
183
+
184
+ // 2. Preset — set inside the transaction with the device input already
185
+ // present, so `canSetSessionPreset` reflects the real device. `.photo` is
186
+ // supported by every camera device; if it somehow is not, we keep whatever
187
+ // preset the session defaults to rather than crashing.
188
+ if session.canSetSessionPreset(.photo) {
189
+ session.sessionPreset = .photo
171
190
  }
172
-
173
- // Add photo output
191
+
192
+ // 3. Photo output
174
193
  if #available(iOS 13.0, *) {
175
194
  if let maxPhotoQualityPrioritization = maxPhotoQualityPrioritization {
176
195
  photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization.avQualityPrioritization
177
196
  }
178
197
  }
179
-
180
198
  if session.canAddOutput(photoOutput) {
181
199
  session.addOutput(photoOutput)
182
-
183
- if let photoOutputConnection = self.photoOutput.connection(with: .video) {
184
- if photoOutputConnection.isVideoStabilizationSupported {
185
- photoOutputConnection.preferredVideoStabilizationMode = .auto
186
- }
200
+ // Connection only exists after the output is added to the session.
201
+ if let photoOutputConnection = self.photoOutput.connection(with: .video),
202
+ photoOutputConnection.isVideoStabilizationSupported {
203
+ photoOutputConnection.preferredVideoStabilizationMode = .auto
187
204
  }
188
205
  }
189
-
190
- // Add metadata output for barcode scanning
206
+
207
+ // 4. Metadata output for barcode scanning
191
208
  if self.session.canAddOutput(metadataOutput) {
192
209
  self.session.addOutput(metadataOutput)
193
210
  metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
194
-
211
+
195
212
  let availableTypes = self.metadataOutput.availableMetadataObjectTypes
196
213
  let filteredTypes = supportedBarcodeType
197
214
  .map { $0.toAVMetadataObjectType() }
@@ -199,39 +216,59 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
199
216
 
200
217
  metadataOutput.metadataObjectTypes = filteredTypes
201
218
  }
202
-
203
- // add for text detections
204
- if (textRequest != nil && self.session.canAddOutput(self.videoDataOutput)) {
219
+
220
+ // 5. Video data output for text / MRZ detection
221
+ if textRequest != nil && self.session.canAddOutput(self.videoDataOutput) {
205
222
  self.session.addOutput(self.videoDataOutput)
206
223
  }
224
+
225
+ return .success
207
226
  }
208
-
227
+
209
228
  // MARK: - Pause / Resume non-essential outputs
229
+
230
+ /// Disables OCR + barcode work while a photo is captured.
231
+ /// MUST be called on `sessionQueue` (these are capture-output mutations).
210
232
  private func pauseNonEssentialOutputs() {
233
+ assertOnSessionQueue()
211
234
  videoDataOutput.setSampleBufferDelegate(nil, queue: nil)
212
235
  metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 0, height: 0)
213
236
  }
214
-
237
+
238
+ /// Re-enables OCR + barcode work after a capture. May be called from any
239
+ /// thread (photo-capture completion handlers run on an arbitrary queue), so it
240
+ /// hops to the main queue only to read preview-layer geometry and applies all
241
+ /// output mutations back on `sessionQueue`.
215
242
  private func resumeNonEssentialOutputs() {
216
- if textDetectionEnabled {
217
- videoDataOutput.setSampleBufferDelegate(self, queue: globalOCRQueue)
218
- }
219
- // Restore real rect of interest
220
- if let scanner = scannerFrameSize, scanner != .zero {
221
- metadataOutput.rectOfInterest =
222
- cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scanner)
223
- } else {
224
- metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1)
243
+ sessionQueue.async { [weak self] in
244
+ guard let self else { return }
245
+ if self.textDetectionEnabled {
246
+ self.videoDataOutput.setSampleBufferDelegate(self, queue: globalOCRQueue)
247
+ }
248
+
249
+ guard let scanner = self.scannerFrameSize, scanner != .zero else {
250
+ self.metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1)
251
+ return
252
+ }
253
+
254
+ // metadataOutputRectConverted must be read on the main thread.
255
+ DispatchQueue.main.async { [weak self] in
256
+ guard let self else { return }
257
+ let rect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scanner)
258
+ self.sessionQueue.async { [weak self] in
259
+ self?.metadataOutput.rectOfInterest = rect
260
+ }
261
+ }
225
262
  }
226
263
  }
227
-
264
+
228
265
  func zoomPinchStart() {
229
266
  sessionQueue.async {
230
267
  guard let videoDevice = self.videoDeviceInput?.device else { return }
231
268
  self.zoomStartedAt = videoDevice.videoZoomFactor
232
269
  }
233
270
  }
234
-
271
+
235
272
  func zoomPinchChange(pinchScale: CGFloat) {
236
273
  guard !pinchScale.isNaN else { return }
237
274
 
@@ -240,7 +277,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
240
277
 
241
278
  let desiredZoomFactor = (self.zoomStartedAt / self.defaultZoomFactor(for: videoDevice)) * pinchScale
242
279
  let zoomForDevice = self.getValidZoom(forDevice: videoDevice, zoom: desiredZoomFactor)
243
-
280
+
244
281
  if zoomForDevice != self.normalizedZoom(for: videoDevice) {
245
282
  // Only trigger zoom changes if it's an uncontrolled component (zoom isn't manually set)
246
283
  // otherwise it's likely to cause issues inf. loops
@@ -251,14 +288,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
251
288
  }
252
289
  }
253
290
  }
254
-
291
+
255
292
  func update(maxZoom: Double?) {
256
293
  self.maxZoom = maxZoom
257
294
 
258
295
  // Re-update zoom value in case the max was increased
259
296
  self.update(zoom: self.zoom)
260
297
  }
261
-
298
+
262
299
  func update(zoom: Double?) {
263
300
  sessionQueue.async {
264
301
  self.zoom = zoom
@@ -269,7 +306,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
269
306
  self.setZoomFor(videoDevice, to: zoomForDevice)
270
307
  }
271
308
  }
272
-
309
+
273
310
  /**
274
311
  `desiredZoom` can be nil when we want to notify what the zoom factor really is
275
312
  */
@@ -290,11 +327,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
290
327
  lastOnZoom = desiredOrCameraZoom
291
328
  self.onZoomCallback?(["zoom": desiredOrCameraZoom])
292
329
  }
293
-
330
+
294
331
  func update(onZoom: RCTDirectEventBlock?) {
295
332
  self.onZoomCallback = onZoom
296
333
  }
297
-
334
+
298
335
  func focus(at touchPoint: CGPoint, focusBehavior: FocusBehavior) {
299
336
  DispatchQueue.main.async {
300
337
  let devicePoint = self.cameraPreview.previewLayer.captureDevicePointConverted(fromLayerPoint: touchPoint)
@@ -309,7 +346,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
309
346
  self.resetFocus = nil
310
347
  self.focusFinished = nil
311
348
  }
312
-
349
+
313
350
  do {
314
351
  try videoDevice.lockForConfiguration()
315
352
 
@@ -332,11 +369,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
332
369
  }
333
370
  }
334
371
  }
335
-
372
+
336
373
  func update(onOrientationChange: RCTDirectEventBlock?) {
337
374
  self.onOrientationChange = onOrientationChange
338
375
  }
339
-
376
+
340
377
  func update(torchMode: TorchMode) {
341
378
  sessionQueue.async {
342
379
  self.torchMode = torchMode
@@ -353,29 +390,26 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
353
390
  }
354
391
  }
355
392
  }
356
-
393
+
357
394
  func update(flashMode: FlashMode) {
358
395
  self.flashMode = flashMode
359
396
  }
360
-
397
+
361
398
  func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) {
362
399
  guard #available(iOS 13.0, *) else { return }
363
- guard maxPhotoQualityPrioritization != self.maxPhotoQualityPrioritization else { return }
364
- sessionQueue.async {
365
- self.configurationDepth += 1
366
- self.session.beginConfiguration()
367
- defer {
368
- self.session.commitConfiguration()
369
- self.configurationDepth -= 1
370
- self.handlePendingStopIfNeeded()
371
- }
400
+ sessionQueue.async { [weak self] in
401
+ guard let self else { return }
402
+ guard maxPhotoQualityPrioritization != self.maxPhotoQualityPrioritization else { return }
403
+ self.beginConfiguration()
404
+ defer { self.commitConfiguration() }
372
405
  self.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization
373
406
  self.photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization?.avQualityPrioritization ?? .balanced
374
407
  }
375
408
  }
376
-
409
+
377
410
  func update(cameraType: CameraType) {
378
- sessionQueue.async {
411
+ sessionQueue.async { [weak self] in
412
+ guard let self else { return }
379
413
  if self.videoDeviceInput?.device.position == cameraType.avPosition {
380
414
  return
381
415
  }
@@ -387,19 +421,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
387
421
  let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
388
422
  return
389
423
  }
390
-
424
+
391
425
  self.removeObservers()
392
- self.configurationDepth += 1
393
- self.session.beginConfiguration()
394
- defer {
395
- self.session.commitConfiguration()
396
- self.configurationDepth -= 1
397
- self.handlePendingStopIfNeeded()
398
- }
426
+ self.beginConfiguration()
427
+ defer { self.commitConfiguration() }
399
428
 
400
429
  // Remove the existing device input first, since using the front and back camera simultaneously is not supported.
401
430
  self.session.removeInput(currentViewDeviceInput)
402
-
431
+
403
432
  if self.session.canAddInput(videoDeviceInput) {
404
433
  self.session.addInput(videoDeviceInput)
405
434
  self.resetZoom(forDevice: videoDevice)
@@ -408,14 +437,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
408
437
  // If it fails, put back current camera
409
438
  self.session.addInput(currentViewDeviceInput)
410
439
  }
411
-
440
+
412
441
  self.addObservers()
413
442
 
414
443
  // We need to reapply the configuration after reloading the camera
415
444
  self.update(torchMode: self.torchMode)
416
445
  }
417
446
  }
418
-
447
+
419
448
  func update(resizeMode: ResizeMode) {
420
449
  DispatchQueue.main.async {
421
450
  switch resizeMode {
@@ -426,7 +455,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
426
455
  }
427
456
  }
428
457
  }
429
-
458
+
430
459
  func capturePicture(onWillCapture: @escaping () -> Void,
431
460
  onSuccess: @escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions) -> Void,
432
461
  onError: @escaping (_ message: String) -> Void) {
@@ -435,39 +464,41 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
435
464
  entering the session queue. Do this to ensure that UI elements are accessed on
436
465
  the main thread and session configuration is done on the session queue.
437
466
  */
438
-
439
- // Pause OCR + barcode before capturing
440
- self.pauseNonEssentialOutputs()
441
-
467
+
442
468
  DispatchQueue.main.async { [weak self] in
443
469
  guard let self = self else {
444
470
  onError("Camera was deallocated")
445
471
  return
446
472
  }
447
-
473
+
448
474
  let videoPreviewLayerOrientation =
449
475
  self.videoOrientation(from: self.deviceOrientation) ?? self.cameraPreview.previewLayer.connection?.videoOrientation
450
-
476
+
451
477
  self.sessionQueue.async { [weak self] in
452
478
  guard let self = self else {
453
479
  onError("Camera was deallocated")
454
480
  return
455
481
  }
456
482
 
483
+ guard self.inProgressPhotoCaptureDelegates.isEmpty else {
484
+ onError("Capture already in progress")
485
+ return
486
+ }
487
+
457
488
  // Validate that the session is ready for photo capture
458
489
  guard self.session.isRunning else {
459
490
  print("Cannot capture photo: session is not running")
460
491
  onError("Camera session is not running")
461
492
  return
462
493
  }
463
-
494
+
464
495
  // Ensure photo output has an active video connection
465
496
  guard let photoOutputConnection = self.photoOutput.connection(with: .video) else {
466
497
  print("Cannot capture photo: no video connection available")
467
498
  onError("Camera connection is not available")
468
499
  return
469
500
  }
470
-
501
+
471
502
  // Verify the connection is active and enabled
472
503
  guard photoOutputConnection.isActive && photoOutputConnection.isEnabled else {
473
504
  print("Cannot capture photo: video connection is not active or enabled")
@@ -480,6 +511,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
480
511
  photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
481
512
  }
482
513
 
514
+ // Pause OCR + barcode work on the session queue, right before capture.
515
+ self.pauseNonEssentialOutputs()
516
+
483
517
  let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
484
518
  if #available(iOS 13.0, *) {
485
519
  settings.photoQualityPrioritization = self.photoOutput.maxPhotoQualityPrioritization
@@ -493,32 +527,30 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
493
527
  with: settings,
494
528
  onWillCapture: onWillCapture,
495
529
  onCaptureSuccess: { [weak self] uniqueID, imageData, thumbnailData, dimensions in
496
- // Use weak self to prevent crash if camera is deallocated during capture
497
- self?.inProgressPhotoCaptureDelegates[uniqueID] = nil
530
+ self?.sessionQueue.async { self?.inProgressPhotoCaptureDelegates[uniqueID] = nil }
498
531
  onSuccess(imageData, thumbnailData, dimensions)
499
532
  self?.resumeNonEssentialOutputs()
500
533
  },
501
534
  onCaptureError: { [weak self] uniqueID, errorMessage in
502
- // Use weak self to prevent crash if camera is deallocated during capture
503
- self?.inProgressPhotoCaptureDelegates[uniqueID] = nil
535
+ self?.sessionQueue.async { self?.inProgressPhotoCaptureDelegates[uniqueID] = nil }
504
536
  onError(errorMessage)
505
537
  self?.resumeNonEssentialOutputs()
506
538
  }
507
539
  )
508
-
540
+
509
541
  self.inProgressPhotoCaptureDelegates[photoCaptureDelegate.requestedPhotoSettings.uniqueID] = photoCaptureDelegate
510
542
  self.photoOutput.capturePhoto(with: settings, delegate: photoCaptureDelegate)
511
543
  }
512
544
  }
513
545
  }
514
-
546
+
515
547
  // MARK: - Barcode scanning
516
548
  func isBarcodeScannerEnabled(_ isEnabled: Bool,
517
549
  supportedBarcodeTypes supportedBarcodeType: [CodeFormat],
518
550
  onBarcodeRead: ((_ barcode: String,_ codeFormat:CodeFormat) -> Void)?) {
519
551
  sessionQueue.async {
520
552
  self.onBarcodeRead = onBarcodeRead
521
-
553
+
522
554
  let availableTypes = self.metadataOutput.availableMetadataObjectTypes
523
555
  let newTypes: [AVMetadataObject.ObjectType]
524
556
  if isEnabled && onBarcodeRead != nil {
@@ -535,11 +567,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
535
567
  }
536
568
  }
537
569
  }
538
-
570
+
539
571
  func update(barcodeFrameSize: CGSize?) {
540
572
  self.barcodeFrameSize = barcodeFrameSize
541
573
  }
542
-
574
+
543
575
  func update(scannerFrameSize: CGRect?) {
544
576
  guard self.scannerFrameSize != scannerFrameSize else { return }
545
577
  self.sessionQueue.async {
@@ -547,11 +579,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
547
579
  if !self.session.isRunning {
548
580
  return
549
581
  }
550
-
582
+
551
583
  DispatchQueue.main.async {
552
584
  var visibleRect: CGRect?
553
- if scannerFrameSize != nil && scannerFrameSize != .zero {
554
- visibleRect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scannerFrameSize!)
585
+ if let scannerFrameSize, scannerFrameSize != .zero {
586
+ visibleRect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scannerFrameSize)
555
587
  }
556
588
 
557
589
  self.sessionQueue.async {
@@ -566,7 +598,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
566
598
  }
567
599
  }
568
600
  }
569
-
601
+
570
602
 
571
603
  func isTextDetectionEnabled(_ isEnabled: Bool, onTextRead: ((String) -> Void)?) {
572
604
  sessionQueue.async {
@@ -581,26 +613,26 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
581
613
  self.textRequest?.recognitionLanguages = ["en", "fr", "de", "es", "vi"]
582
614
  self.textRequest?.recognitionLevel = .accurate
583
615
  self.textRequest?.usesLanguageCorrection = false
584
-
616
+
585
617
  self.videoDataOutput.alwaysDiscardsLateVideoFrames = true
586
618
  self.videoDataOutput.setSampleBufferDelegate(self, queue: globalOCRQueue)
587
619
  self.videoDataOutput.videoSettings = [
588
620
  kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
589
621
  ]
590
-
622
+
591
623
  } else {
592
624
  self.textRequest = nil
593
625
  }
594
626
  }
595
627
  }
596
-
628
+
597
629
  // AVCaptureVideoDataOutputSampleBufferDelegate
598
630
  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
599
631
  guard textDetectionEnabled, let request = textRequest else { return }
600
632
  let now = Date()
601
633
  if now.timeIntervalSince(lastTextProcess) < textThrottle { return }
602
634
  lastTextProcess = now
603
-
635
+
604
636
  globalOCRQueue.async {
605
637
  guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
606
638
  var requestOptions: [VNImageOption: Any] = [:]
@@ -613,7 +645,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
613
645
  }
614
646
  }
615
647
  }
616
-
648
+
617
649
  // MARK: - AVCaptureMetadataOutputObjectsDelegate
618
650
 
619
651
  func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
@@ -627,7 +659,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
627
659
 
628
660
  onBarcodeRead?(codeStringValue,barcodeType)
629
661
  }
630
-
662
+
631
663
  // MARK: - Private
632
664
 
633
665
  private func videoOrientation(from deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? {
@@ -646,7 +678,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
646
678
  @unknown default: return nil
647
679
  }
648
680
  }
649
-
681
+
650
682
  private func videoOrientation(from interfaceOrientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation {
651
683
  switch interfaceOrientation {
652
684
  case .portrait:
@@ -661,14 +693,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
661
693
  @unknown default: return .portrait
662
694
  }
663
695
  }
664
-
696
+
665
697
  private func getBestDevice(for cameraType: CameraType) -> AVCaptureDevice? {
666
698
  if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraType.avPosition) {
667
699
  return device // single-lens/physical device
668
700
  }
669
701
  return nil
670
702
  }
671
-
703
+
672
704
  private func defaultZoomFactor(for videoDevice: AVCaptureDevice) -> CGFloat {
673
705
  let fallback = 1.0
674
706
  guard #available(iOS 13.0, *) else { return fallback }
@@ -729,7 +761,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
729
761
  motionManager = CMMotionManager()
730
762
  motionManager?.accelerometerUpdateInterval = 0.2
731
763
  motionManager?.gyroUpdateInterval = 0.2
732
- motionManager?.startAccelerometerUpdates(to: OperationQueue(), withHandler: { (accelerometerData, error) -> Void in
764
+
765
+ motionManager?.startAccelerometerUpdates(to: OperationQueue(), withHandler: { [weak self] (accelerometerData, error) -> Void in
766
+ guard let self else { return }
733
767
  guard error == nil else {
734
768
  print("\(error!)")
735
769
  return
@@ -740,12 +774,13 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
740
774
  }
741
775
 
742
776
  guard let newOrientation = self.deviceOrientation(from: accelerometerData.acceleration),
743
- newOrientation != self.deviceOrientation else {
777
+ newOrientation != self.deviceOrientation,
778
+ let orientation = Orientation(from: newOrientation) else {
744
779
  return
745
780
  }
746
781
 
747
782
  self.deviceOrientation = newOrientation
748
- self.onOrientationChange?(["orientation": Orientation.init(from: newOrientation)!.rawValue])
783
+ self.onOrientationChange?(["orientation": orientation.rawValue])
749
784
  })
750
785
  #endif
751
786
  }
@@ -770,30 +805,34 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
770
805
  // MARK: Private observers
771
806
 
772
807
  private func addObservers() {
773
- guard adjustingFocusObservation == nil else { return }
808
+ if adjustingFocusObservation == nil {
809
+ adjustingFocusObservation = videoDeviceInput?.device.observe(\.isAdjustingFocus,
810
+ options: .new,
811
+ changeHandler: { [weak self] _, change in
812
+ guard let self, let isFocusing = change.newValue else { return }
774
813
 
775
- adjustingFocusObservation = videoDeviceInput?.device.observe(\.isAdjustingFocus,
776
- options: .new,
777
- changeHandler: { [weak self] _, change in
778
- guard let self, let isFocusing = change.newValue else { return }
779
-
780
- self.isAdjustingFocus(isFocusing: isFocusing)
781
- })
814
+ self.isAdjustingFocus(isFocusing: isFocusing)
815
+ })
816
+ }
782
817
 
783
- NotificationCenter.default.addObserver(forName: .AVCaptureDeviceSubjectAreaDidChange,
784
- object: videoDeviceInput?.device,
785
- queue: nil,
786
- using: { [weak self] notification in self?.subjectAreaDidChange(notification: notification) })
787
- NotificationCenter.default.addObserver(forName: .AVCaptureSessionRuntimeError,
788
- object: session,
789
- queue: nil,
790
- using: { [weak self] notification in self?.sessionRuntimeError(notification: notification) })
818
+ guard notificationObservers.isEmpty else { return }
791
819
 
820
+ let subjectAreaToken = NotificationCenter.default.addObserver(
821
+ forName: .AVCaptureDeviceSubjectAreaDidChange,
822
+ object: videoDeviceInput?.device,
823
+ queue: nil,
824
+ using: { [weak self] notification in self?.subjectAreaDidChange(notification: notification) })
825
+ let runtimeErrorToken = NotificationCenter.default.addObserver(
826
+ forName: .AVCaptureSessionRuntimeError,
827
+ object: session,
828
+ queue: nil,
829
+ using: { [weak self] notification in self?.sessionRuntimeError(notification: notification) })
830
+ notificationObservers = [subjectAreaToken, runtimeErrorToken]
792
831
  }
793
832
 
794
833
  private func removeObservers() {
795
- // swiftlint:disable:next notification_center_detachment
796
- NotificationCenter.default.removeObserver(self)
834
+ notificationObservers.forEach { NotificationCenter.default.removeObserver($0) }
835
+ notificationObservers.removeAll()
797
836
 
798
837
  adjustingFocusObservation?.invalidate()
799
838
  adjustingFocusObservation = nil
@@ -829,39 +868,99 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
829
868
 
830
869
  print("Capture session runtime error: \(error)")
831
870
 
832
- // Automatically try to restart the session running if media services were reset
871
+ // Automatically try to restart the session if media services were reset.
872
+ // (After a reset the session is NOT running, so the restart is routed
873
+ // through the guarded helper, which only starts when it is safe to.)
833
874
  if error.code == .mediaServicesWereReset {
834
- sessionQueue.async {
835
- if self.session.isRunning {
836
- self.session.startRunning()
837
- }
875
+ sessionQueue.async { [weak self] in
876
+ self?.startSessionIfNeeded()
838
877
  }
839
878
  }
840
879
  }
841
880
 
842
881
  func startCamera() {
843
- self.sessionQueue.async {
844
- if !self.session.isRunning {
845
- self.session.startRunning()
846
- }
882
+ sessionQueue.async { [weak self] in
883
+ self?.startSessionIfNeeded()
847
884
  }
848
885
  }
849
886
 
850
887
  func stopCamera() {
851
- self.sessionQueue.async {
852
- if self.session.isRunning {
853
- self.session.stopRunning()
854
- }
888
+ sessionQueue.async { [weak self] in
889
+ self?.stopSessionIfNeeded()
890
+ }
891
+ }
892
+
893
+ // MARK: - Session configuration transaction helpers
894
+
895
+ /// Debug-only tripwire enforcing the core invariant of this class: EVERY
896
+ /// AVCaptureSession mutation and start/stop happens on `sessionQueue`. The whole
897
+ /// "startRunning may not be called between beginConfiguration and
898
+ /// commitConfiguration" crash class is prevented precisely because nothing
899
+ /// touches the session off this serial queue. If a future change — or an
900
+ /// external caller such as a mini-app native bridge — ever reaches one of these
901
+ /// methods on the wrong queue, this fails loudly in DEBUG/QA instead of
902
+ /// crashing randomly in production. Compiled out of release builds, so it can
903
+ /// never add a production crash.
904
+ private func assertOnSessionQueue() {
905
+ #if DEBUG
906
+ dispatchPrecondition(condition: .onQueue(sessionQueue))
907
+ #endif
908
+ }
909
+
910
+ /// Opens a session configuration transaction and tracks nesting depth.
911
+ /// MUST be balanced by `commitConfiguration()` (use `defer`) and MUST run on
912
+ /// `sessionQueue`.
913
+ private func beginConfiguration() {
914
+ assertOnSessionQueue()
915
+ configurationDepth += 1
916
+ session.beginConfiguration()
917
+ }
918
+
919
+ /// Closes a session configuration transaction and flushes a stop that was
920
+ /// requested while the transaction was open. MUST run on `sessionQueue`.
921
+ private func commitConfiguration() {
922
+ assertOnSessionQueue()
923
+ session.commitConfiguration()
924
+ configurationDepth -= 1
925
+ handlePendingStopIfNeeded()
926
+ }
927
+
928
+ /// Starts the session only when it is safe to do so. MUST run on `sessionQueue`.
929
+ /// Because every begin/commit pair is synchronous on the serial session queue,
930
+ /// `configurationDepth` is always 0 at the start of a fresh queue block — the
931
+ /// depth check is therefore defence-in-depth against ever calling this from
932
+ /// inside an open transaction.
933
+ private func startSessionIfNeeded() {
934
+ assertOnSessionQueue()
935
+ guard setupResult == .success,
936
+ configurationDepth == 0,
937
+ !pendingStop,
938
+ !session.isRunning else {
939
+ return
940
+ }
941
+ session.startRunning()
942
+ }
943
+
944
+ /// Stops the session, deferring until the current transaction commits if one
945
+ /// is open. MUST run on `sessionQueue`.
946
+ private func stopSessionIfNeeded() {
947
+ assertOnSessionQueue()
948
+ guard session.isRunning else { return }
949
+ if configurationDepth > 0 {
950
+ pendingStop = true
951
+ return
855
952
  }
953
+ session.stopRunning()
856
954
  }
857
955
 
858
956
  // MARK: - Private helper for safe session stop
859
-
957
+
860
958
  private func handlePendingStopIfNeeded() {
861
959
  // Must be called on sessionQueue after configuration completes
960
+ assertOnSessionQueue()
862
961
  // Only execute if all configurations are done (depth == 0)
863
962
  guard configurationDepth == 0 && pendingStop else { return }
864
-
963
+
865
964
  pendingStop = false
866
965
  if session.isRunning {
867
966
  session.stopRunning()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/camerakit",
3
- "version": "0.161.2-beta.5",
3
+ "version": "0.161.2-beta.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/teslamotors/react-native-camera-kit.git"