From 5c1f47a41574bb39abb0cc07839d68663156390a Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 7 Aug 2025 18:33:33 +0300 Subject: [PATCH 1/2] [Enhancement]Handle camera session interruptions --- CHANGELOG.md | 3 +- .../DispatchQueueExecutor.swift | 2 +- .../Camera/CameraInterruptionsHandler.swift | 133 ++++++++++++++++++ .../VideoCapturing/StreamVideoCapturer.swift | 3 +- StreamVideo.xcodeproj/project.pbxproj | 4 + 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d96cc2fc1..428a678f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- The SDK now handles the interruptions produced from AVCaptureSession to ensure video capturing is active when needed. [#907](https://github.com/GetStream/stream-video-swift/pull/907) # [1.29.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.29.1) _July 25, 2025_ diff --git a/Sources/StreamVideo/Utils/SerialActorQueue/DispatchQueueExecutor.swift b/Sources/StreamVideo/Utils/SerialActorQueue/DispatchQueueExecutor.swift index f2fdd8b20..b8eb8d2d0 100644 --- a/Sources/StreamVideo/Utils/SerialActorQueue/DispatchQueueExecutor.swift +++ b/Sources/StreamVideo/Utils/SerialActorQueue/DispatchQueueExecutor.swift @@ -98,6 +98,6 @@ final class DispatchQueueExecutor: SerialExecutor, @unchecked Sendable { } } -#if swift(>=6.0) +#if compiler(>=6.0) extension DispatchQueueExecutor: TaskExecutor {} #endif diff --git a/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift new file mode 100644 index 000000000..fa269a8b7 --- /dev/null +++ b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift @@ -0,0 +1,133 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import AVFoundation +import Combine +import Foundation +import StreamWebRTC + +/// Handles camera-related interruptions by observing `AVCaptureSession` interruption notifications. +final class CameraInterruptionsHandler: StreamVideoCapturerActionHandler, @unchecked Sendable { + + /// Represents the current camera session state (idle or running). + private enum State { + /// No active camera session. + case idle + /// An active camera session with a disposable bag for cleanup. + case running(session: AVCaptureSession, disposableBag: DisposableBag) + } + + private var state: State = .idle + /// Ensures serialized handling of interruption events. + private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) + + // MARK: - StreamVideoCapturerActionHandler + + /// Handles camera-related actions triggered by the video capturer. + func handle(_ action: StreamVideoCapturer.Action) async throws { + switch action { + /// Handle start capture event and register for interruption notifications. + case let .startCapture(_, _, _, _, videoCapturer, _): + if let cameraCapturer = videoCapturer as? RTCCameraVideoCapturer { + didStartCapture(session: cameraCapturer.captureSession) + } else { + didStopCapture() + } + /// Handle stop capture event and cleanup. + case .stopCapture: + didStopCapture() + default: + break + } + } + + // MARK: - Private + + /// Sets up observers and state when camera capture starts. + private func didStartCapture(session: AVCaptureSession) { + let disposableBag = DisposableBag() + + /// Observe AVCaptureSession interruptions and log reasons. + NotificationCenter + .default + .publisher(for: AVCaptureSession.wasInterruptedNotification) + .compactMap { (notification: Notification) -> String? in + guard + let userInfo = notification.userInfo, + let reasonRawValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? NSNumber, + let reason = AVCaptureSession.InterruptionReason(rawValue: reasonRawValue.intValue) + else { + return nil + } + return reason.description + } + .compactMap { $0 } + .log(.debug, subsystems: .webRTC) { "CameraCapture session was interrupted with reason: \($0)." } + .receive(on: processingQueue) + .sink { _ in } + .store(in: disposableBag) + + /// Observe end of AVCaptureSession interruptions and restart session if needed. + NotificationCenter + .default + .publisher(for: .AVCaptureSessionInterruptionEnded) + .log(.debug, subsystems: .webRTC) { _ in "CameraCapture session interruption ended." } + .receive(on: processingQueue) + .sink { [weak self] _ in self?.handleInterruptionEnded() } + .store(in: disposableBag) + + state = .running(session: session, disposableBag: disposableBag) + } + + /// Cleans up resources and resets state when camera capture stops. + private func didStopCapture() { + switch state { + case .idle: + break + case let .running(_, disposableBag): + disposableBag.removeAll() + processingQueue.cancelAllOperations() + } + state = .idle + } + + /// Restarts the session if it was interrupted and not running. + private func handleInterruptionEnded() { + switch state { + case .idle: + break + case let .running(session, _): + guard !session.isRunning else { + return + } + session.startRunning() + } + } +} + +#if compiler(>=6.0) +extension AVCaptureSession.InterruptionReason: @retroactive CustomStringConvertible {} +#else +extension AVCaptureSession.InterruptionReason: CustomStringConvertible {} +#endif + +extension AVCaptureSession.InterruptionReason { + /// Provides a readable description for each interruption reason. + public var description: String { + switch self { + case .videoDeviceNotAvailableInBackground: + return ".videoDeviceNotAvailableInBackground" + case .audioDeviceInUseByAnotherClient: + return ".audioDeviceInUseByAnotherClient" + case .videoDeviceInUseByAnotherClient: + return ".videoDeviceInUseByAnotherClient" + case .videoDeviceNotAvailableWithMultipleForegroundApps: + return ".videoDeviceNotAvailableWithMultipleForegroundApps" + case .videoDeviceNotAvailableDueToSystemPressure: + return ".videoDeviceNotAvailableDueToSystemPressure" + @unknown default: + return "\(self)" + } + } +} diff --git a/Sources/StreamVideo/WebRTC/v2/VideoCapturing/StreamVideoCapturer.swift b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/StreamVideoCapturer.swift index 4fe45f61b..53aad8b6e 100644 --- a/Sources/StreamVideo/WebRTC/v2/VideoCapturing/StreamVideoCapturer.swift +++ b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/StreamVideoCapturer.swift @@ -52,7 +52,8 @@ final class StreamVideoCapturer: StreamVideoCapturing { CameraFocusHandler(), CameraCapturePhotoHandler(), CameraVideoOutputHandler(), - CameraZoomHandler() + CameraZoomHandler(), + CameraInterruptionsHandler() ] ) #endif diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index f922f377c..5bf837d9d 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -699,6 +699,7 @@ 40D36AE22DDE023800972D75 /* WebRTCStatsCollecting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D36AE12DDE023800972D75 /* WebRTCStatsCollecting.swift */; }; 40D36AE42DDE02D100972D75 /* MockWebRTCStatsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D36AE32DDE02D100972D75 /* MockWebRTCStatsCollector.swift */; }; 40D6ADDD2ACDB51C00EF5336 /* VideoRenderer_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */; }; + 40D75C652E44F5CE000E0438 /* CameraInterruptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */; }; 40D946412AA5ECEF00C8861B /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D946402AA5ECEF00C8861B /* CodeScanner.swift */; }; 40D946432AA5F65300C8861B /* DemoQRCodeScannerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D946422AA5F65300C8861B /* DemoQRCodeScannerButton.swift */; }; 40D946452AA5F67E00C8861B /* DemoCallingTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D946442AA5F67E00C8861B /* DemoCallingTopView.swift */; }; @@ -2213,6 +2214,7 @@ 40D36AE12DDE023800972D75 /* WebRTCStatsCollecting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCStatsCollecting.swift; sourceTree = ""; }; 40D36AE32DDE02D100972D75 /* MockWebRTCStatsCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWebRTCStatsCollector.swift; sourceTree = ""; }; 40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRenderer_Tests.swift; sourceTree = ""; }; + 40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraInterruptionsHandler.swift; sourceTree = ""; }; 40D946402AA5ECEF00C8861B /* CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = ""; }; 40D946422AA5F65300C8861B /* DemoQRCodeScannerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoQRCodeScannerButton.swift; sourceTree = ""; }; 40D946442AA5F67E00C8861B /* DemoCallingTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoCallingTopView.swift; sourceTree = ""; }; @@ -5074,6 +5076,7 @@ 40E3635E2D0A18B10028C52A /* CameraZoomHandler.swift */, 40E3635A2D0A15E40028C52A /* CameraCapturePhotoHandler.swift */, 40E3635C2D0A17C10028C52A /* CameraVideoOutputHandler.swift */, + 40D75C642E44F5CE000E0438 /* CameraInterruptionsHandler.swift */, ); path = Camera; sourceTree = ""; @@ -8030,6 +8033,7 @@ 40AD64C42DC269E60077AE15 /* ProximityMonitor.swift in Sources */, 40AD64C52DC269E60077AE15 /* VideoProximityPolicy.swift in Sources */, 40AD64C62DC269E60077AE15 /* ProximityPolicy.swift in Sources */, + 40D75C652E44F5CE000E0438 /* CameraInterruptionsHandler.swift in Sources */, 40AD64C72DC269E60077AE15 /* SpeakerProximityPolicy.swift in Sources */, 841BAA3D2BD15CDE000C73E4 /* GetCallStatsResponse.swift in Sources */, 842E70D92B91BE1700D2D68B /* CallClosedCaption.swift in Sources */, From 40f7908f44b2dc85dbc30a37ca0840642c91bc95 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Fri, 8 Aug 2025 11:00:54 +0300 Subject: [PATCH 2/2] Fix compilation error on Xcode 15 --- .../Camera/CameraInterruptionsHandler.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift index fa269a8b7..26dc1d399 100644 --- a/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift +++ b/Sources/StreamVideo/WebRTC/v2/VideoCapturing/ActionHandlers/Camera/CameraInterruptionsHandler.swift @@ -48,10 +48,18 @@ final class CameraInterruptionsHandler: StreamVideoCapturerActionHandler, @unche private func didStartCapture(session: AVCaptureSession) { let disposableBag = DisposableBag() + let interruptedNotification: Notification.Name = { + #if compiler(>=6.0) + return AVCaptureSession.wasInterruptedNotification + #else + return .AVCaptureSessionWasInterrupted + #endif + }() + /// Observe AVCaptureSession interruptions and log reasons. NotificationCenter .default - .publisher(for: AVCaptureSession.wasInterruptedNotification) + .publisher(for: interruptedNotification) .compactMap { (notification: Notification) -> String? in guard let userInfo = notification.userInfo,