diff --git a/ios/CameraManager.swift b/ios/CameraManager.swift index bd1aa0f..70bc75e 100644 --- a/ios/CameraManager.swift +++ b/ios/CameraManager.swift @@ -6,19 +6,23 @@ // import Foundation -import AVFoundation -import Vision +@preconcurrency import AVFoundation +@preconcurrency import Vision -@objc public class CameraManager: NSObject { +@objc public class CameraManager: NSObject, @unchecked Sendable { private var captureSession: AVCaptureSession? private var videoDevice: AVCaptureDevice? private var videoOutput: AVCaptureVideoDataOutput? - private var previewLayer: AVCaptureVideoPreviewLayer? private let sessionQueue = DispatchQueue(label: "com.pushpendersingh.scanner.sessionQueue") private var isScanning = false private var scanCallback: (([String: Any]) -> Void)? + // Session interruption handling + private var sessionInterruptionObserver: NSObjectProtocol? + private var sessionInterruptionEndedObserver: NSObjectProtocol? + @objc public var onSessionReady: ((AVCaptureSession) -> Void)? + // Barcode types to detect private let supportedBarcodeTypes: [VNBarcodeSymbology] = [ .qr, @@ -46,17 +50,11 @@ import Vision } } - @objc public func getPreviewLayer() -> AVCaptureVideoPreviewLayer? { - return previewLayer - } - - @objc public func bindPreviewLayer(_ layer: AVCaptureVideoPreviewLayer) { - self.previewLayer = layer - if let session = captureSession { - DispatchQueue.main.async { - layer.session = session - } - } + @objc public func currentSession() -> AVCaptureSession? { + // CHANGE: Synchronize the read on sessionQueue to avoid cross-thread access warnings. + // Reason: The session is produced/mutated on sessionQueue; reading it from main without + // synchronization can trigger structural concurrency diagnostics. + return sessionQueue.sync { captureSession } } @objc(startScanningWithCallback:error:) @@ -65,27 +63,54 @@ import Vision throw NSError(domain: "CameraManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Camera permission not granted"]) } - if isScanning { - print("Scanning already in progress") - return - } - - scanCallback = callback + // Execute on sessionQueue for thread safety sessionQueue.async { [weak self] in - self?.setupCaptureSession() + guard let self = self else { return } + + // Check if session is already running + if self.isScanning, let session = self.captureSession, session.isRunning { + print("⚠️ Session already running, updating callback only") + self.scanCallback = callback + return + } + + // Set flag on the same queue where it's checked + self.isScanning = true + self.scanCallback = callback + + self.setupCaptureSession() } } private func setupCaptureSession() { - captureSession = AVCaptureSession() - guard let captureSession = captureSession else { return } + // Reuse existing session if available + if let existingSession = captureSession { + // If session exists but not running, just restart it + if !existingSession.isRunning { + print("♻️ Restarting existing session") + existingSession.startRunning() + print("✅ Camera session restarted - Running: \(existingSession.isRunning)") + } else { + print("⚠️ Session already running") + } + if let onSessionReady = self.onSessionReady { + DispatchQueue.main.async { + onSessionReady(existingSession) + } + } + return + } + + // Create new session only if none exists + let newSession = AVCaptureSession() - captureSession.beginConfiguration() - captureSession.sessionPreset = .high + newSession.beginConfiguration() + newSession.sessionPreset = .high // Setup video device guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - print("Failed to get video device") + print("❌ Failed to get video device") + isScanning = false return } self.videoDevice = videoDevice @@ -93,11 +118,16 @@ import Vision // Setup video input do { let videoInput = try AVCaptureDeviceInput(device: videoDevice) - if captureSession.canAddInput(videoInput) { - captureSession.addInput(videoInput) + if newSession.canAddInput(videoInput) { + newSession.addInput(videoInput) + } else { + print("❌ Cannot add video input") + isScanning = false + return } } catch { - print("Error creating video input: \(error.localizedDescription)") + print("❌ Error creating video input: \(error.localizedDescription)") + isScanning = false return } @@ -106,102 +136,223 @@ import Vision videoOutput.setSampleBufferDelegate(self, queue: sessionQueue) videoOutput.alwaysDiscardsLateVideoFrames = true - if captureSession.canAddOutput(videoOutput) { - captureSession.addOutput(videoOutput) + if newSession.canAddOutput(videoOutput) { + newSession.addOutput(videoOutput) self.videoOutput = videoOutput + } else { + print("❌ Cannot add video output") + isScanning = false + return } - captureSession.commitConfiguration() + newSession.commitConfiguration() + + // Assign to property AFTER configuration + self.captureSession = newSession + + // Start session (already on sessionQueue) + newSession.startRunning() + print("✅ Camera session started - Running: \(newSession.isRunning)") + print("✅ Video output delegate set: \(self.videoOutput?.sampleBufferDelegate != nil)") - // Setup preview layer if needed - if let previewLayer = self.previewLayer { + // Notify UI after the session is running to avoid startRunning during configuration + if let onSessionReady = self.onSessionReady { DispatchQueue.main.async { - previewLayer.session = captureSession + onSessionReady(newSession) } } - - // Start the session - sessionQueue.async { [weak self] in - guard let self = self else { return } - self.captureSession?.startRunning() - self.isScanning = true - print("✅ Camera session started - Running: \(self.captureSession?.isRunning ?? false)") - print("✅ Video output delegate set: \(self.videoOutput?.sampleBufferDelegate != nil)") - } } @objc public func stopScanning() { sessionQueue.async { [weak self] in guard let self = self else { return } + + // Set flag first, then stop session self.isScanning = false self.scanCallback = nil - self.captureSession?.stopRunning() - print("Scanning stopped") + + if let session = self.captureSession, session.isRunning { + session.stopRunning() + print("✅ Scanning stopped") + } else { + print("⚠️ No active session to stop") + } } } @objc public func enableFlashlight() { - guard let device = videoDevice, device.hasTorch else { - print("Torch not available") - return - } - - do { - try device.lockForConfiguration() - device.torchMode = .on - device.unlockForConfiguration() - print("Flashlight enabled") - } catch { - print("Failed to enable torch: \(error.localizedDescription)") + // Execute on sessionQueue for thread safety + sessionQueue.async { [weak self] in + guard let self = self else { return } + + // Check if session is running + guard let session = self.captureSession, session.isRunning else { + print("⚠️ Cannot enable flashlight - camera not running") + return + } + + guard let device = self.videoDevice, device.hasTorch else { + print("⚠️ Torch not available") + return + } + + do { + try device.lockForConfiguration() + device.torchMode = .on + device.unlockForConfiguration() + print("✅ Flashlight enabled") + } catch { + print("❌ Failed to enable torch: \(error.localizedDescription)") + } } } @objc public func disableFlashlight() { - guard let device = videoDevice, device.hasTorch else { - print("Torch not available") - return + // Execute on sessionQueue for thread safety + sessionQueue.async { [weak self] in + guard let self = self else { return } + + // Check if session is running + guard let session = self.captureSession, session.isRunning else { + print("⚠️ Cannot disable flashlight - camera not running") + return + } + + guard let device = self.videoDevice, device.hasTorch else { + print("⚠️ Torch not available") + return + } + + do { + try device.lockForConfiguration() + device.torchMode = .off + device.unlockForConfiguration() + print("✅ Flashlight disabled") + } catch { + print("❌ Failed to disable torch: \(error.localizedDescription)") + } + } + } + + // Setup interruption observers in init + public override init() { + super.init() + // Ensure observers are set up on the main actor without synchronously crossing isolation boundaries. + Task { @MainActor in + self.setupInterruptionObservers() + } + } + + @MainActor + private func setupInterruptionObservers() { + // Handle session interruption (e.g., incoming call, alarm) + sessionInterruptionObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: nil, + queue: nil + ) { [weak self] notification in + guard let self = self else { return } + + if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int, + let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue) { + print("⚠️ Session interrupted: \(reason)") + + // Optionally pause scanning during interruption + sessionQueue.async { + self.isScanning = false + } + } } - do { - try device.lockForConfiguration() - device.torchMode = .off - device.unlockForConfiguration() - print("Flashlight disabled") - } catch { - print("Failed to disable torch: \(error.localizedDescription)") + // Handle session interruption ended + sessionInterruptionEndedObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionInterruptionEnded, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self = self else { return } + print("✅ Session interruption ended") + + // Resume scanning only if we had a callback (means scanning was active) + self.sessionQueue.async { + if self.scanCallback != nil, let session = self.captureSession, !session.isRunning { + session.startRunning() + self.isScanning = true + print("✅ Scanning resumed after interruption") + } + } } } @objc public func releaseCamera() { + // Remove observers on main thread to keep lifecycle consistent + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let observer = self.sessionInterruptionObserver { + NotificationCenter.default.removeObserver(observer) + self.sessionInterruptionObserver = nil + } + if let observer = self.sessionInterruptionEndedObserver { + NotificationCenter.default.removeObserver(observer) + self.sessionInterruptionEndedObserver = nil + } + } + sessionQueue.async { [weak self] in guard let self = self else { return } + + // Stop scanning first self.isScanning = false - self.captureSession?.stopRunning() + self.scanCallback = nil - // Remove inputs and outputs + // Stop session before modifying it if let session = self.captureSession { + if session.isRunning { + session.stopRunning() + } + + // Use beginConfiguration when removing inputs/outputs + session.beginConfiguration() + for input in session.inputs { session.removeInput(input) } for output in session.outputs { session.removeOutput(output) } + + session.commitConfiguration() } self.captureSession = nil self.videoDevice = nil self.videoOutput = nil - self.previewLayer = nil - self.scanCallback = nil - print("Camera resources released") + + print("✅ Camera resources released") + } + + Task { @MainActor in + self.setupInterruptionObservers() } } + + // Deinit to cleanup observers + deinit { + if let observer = sessionInterruptionObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = sessionInterruptionEndedObserver { + NotificationCenter.default.removeObserver(observer) + } + print("🗑️ CameraManager deallocated") + } } // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + // Early exit if not scanning guard isScanning else { return } guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { @@ -212,7 +363,13 @@ extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:]) let barcodeRequest = VNDetectBarcodesRequest { [weak self] request, error in - guard let self = self, self.isScanning else { return } + guard let self = self else { return } + + // Double-check isScanning inside async callback + guard self.isScanning else { + print("⚠️ Barcode detected but scanning stopped, ignoring") + return + } if let error = error { print("Barcode detection error: \(error.localizedDescription)") @@ -230,11 +387,20 @@ extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { // Create result dictionary let result = self.createBarcodeResult(barcode: firstBarcode, payloadString: payloadString) - // Call the callback on main thread with thread-safe copy - let callback = self.scanCallback + // Capture callback safely + guard let callback = self.scanCallback else { + print("⚠️ No callback available") + return + } + + // Call the callback on main thread DispatchQueue.main.async { - callback?(result) - print("📤 Callback invoked with barcode data") + if self.isScanning { + callback(result) + print("📤 Callback invoked with barcode data") + } else { + print("⚠️ Scanning stopped before callback, ignoring") + } } } @@ -302,3 +468,4 @@ extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { } } } + diff --git a/ios/CameraView.swift b/ios/CameraView.swift index 5c1e8bb..91f8cc6 100644 --- a/ios/CameraView.swift +++ b/ios/CameraView.swift @@ -38,9 +38,35 @@ public class CameraView: UIView { @objc public func setCameraManager(_ manager: CameraManager) { self.cameraManager = manager - if let previewLayer = previewLayer { - manager.bindPreviewLayer(previewLayer) + + // CHANGE: Subscribe to CameraManager's onSessionReady callback. + // Reason: CameraView owns the preview layer and binds it to the session on the main thread, + // keeping UI work on main and avoiding concurrency violations. + manager.onSessionReady = { [weak self] session in + guard let self = self else { return } + DispatchQueue.main.async { + if let previewLayer = self.previewLayer { + previewLayer.session = session + previewLayer.connection?.videoOrientation = .portrait + print("✅ Preview layer bound to session from onSessionReady callback") + } else { + print("⚠️ Preview layer missing when session became ready") + } + } + } + + // CHANGE: If a session already exists, bind it immediately on the main thread. + // Reason: Ensures the preview shows even if the session was created before the view. + if let existingSession = manager.currentSession() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let previewLayer = self.previewLayer else { return } + previewLayer.session = existingSession + previewLayer.connection?.videoOrientation = .portrait + print("✅ Preview layer bound to existing session") + } } + + print("✅ Camera manager set on CameraView") } public override func layoutSubviews() { @@ -49,7 +75,10 @@ public class CameraView: UIView { } deinit { + // CHANGE: Clear session on teardown to avoid retaining references. + previewLayer?.session = nil previewLayer?.removeFromSuperlayer() previewLayer = nil } } + diff --git a/ios/CameraViewManager.mm b/ios/CameraViewManager.mm index ad1abad..1598df5 100644 --- a/ios/CameraViewManager.mm +++ b/ios/CameraViewManager.mm @@ -30,13 +30,15 @@ - (UIView *)view { CameraView *cameraView = [[CameraView alloc] init]; - // Get the camera manager from the module and bind it to the view - dispatch_async(dispatch_get_main_queue(), ^{ - ReactNativeScanner *scannerModule = [self.bridge moduleForClass:[ReactNativeScanner class]]; - if (scannerModule && scannerModule.cameraManager) { - [cameraView setCameraManager:scannerModule.cameraManager]; - } - }); + // Bind immediately on the current thread (main queue) + // Since requiresMainQueueSetup returns YES, we're already on main queue + ReactNativeScanner *scannerModule = [self.bridge moduleForClass:[ReactNativeScanner class]]; + if (scannerModule && scannerModule.cameraManager) { + [cameraView setCameraManager:scannerModule.cameraManager]; + NSLog(@"✅ Camera manager bound to view"); + } else { + NSLog(@"⚠️ Scanner module or camera manager not available"); + } return cameraView; } diff --git a/ios/ReactNativeScanner.mm b/ios/ReactNativeScanner.mm index 68a5b9f..e1dffac 100644 --- a/ios/ReactNativeScanner.mm +++ b/ios/ReactNativeScanner.mm @@ -120,7 +120,13 @@ - (void)removeListeners:(double)count { } - (void)invalidate { - [_cameraManager releaseCamera]; + // Capture strong reference to camera manager first to ensure it stays alive during cleanup + CameraManager *cameraManager = _cameraManager; + + if (cameraManager) { + [cameraManager releaseCamera]; + } + [super invalidate]; }