From 1fb3f1687555f9a1dc26c7450dbef09d3db66edc Mon Sep 17 00:00:00 2001 From: Martin Mitrevski Date: Thu, 16 Jul 2026 16:43:58 +0200 Subject: [PATCH] Implemented LiveCommunicationKit as an alternative to CallKit --- .../AudioTrackPlayer/AudioTrackPlayer.swift | 11 +- .../DebugMenu+FeatureFlagsView.swift | 2 +- .../StreamVideo/CallKit/CallKitAdapter.swift | 73 +- .../CallKitPushNotificationAdapter.swift | 47 +- .../CallKit/LiveCommunicationKitService.swift | 1147 +++++++++++++++++ .../CallKit/SystemCallingService.swift | 36 + .../AudioDeviceModule/AudioDeviceModule.swift | 10 + .../AudioEngineLevelNodeAdapter.swift | 7 + .../Components/AudioBufferRenderer.swift | 9 + Sources/StreamVideo/VideoConfig.swift | 13 +- .../Client/ConnectionRecoveryHandler.swift | 7 +- .../LocalParticipantViewModifier.swift | 68 - .../CallView/PermissionsPromptView.swift | 18 +- .../CallView/SampleBufferVideoCallView.swift | 2 +- .../ViewModifiers/CallEndedViewModifier.swift | 93 +- .../iOS13/BackportStateObject.swift | 153 --- .../iOS13/IncomingCallView_iOS13.swift | 45 - .../iOS13/PreJoiningView_iOS13.swift | 61 - .../CallingViews/iOS13/VideoView_iOS13.swift | 79 -- .../Utils/CallSoundsPlayer.swift | 38 +- Sources/StreamVideoSwiftUI/ViewFactory.swift | 82 +- StreamVideo.xcodeproj/project.pbxproj | 68 +- .../xcshareddata/xcschemes/DemoApp.xcscheme | 3 + .../xcschemes/StreamVideo.xcscheme | 3 + .../xcschemes/StreamVideoSwiftUI.xcscheme | 3 + .../CallKit/CallKitAdapterTests.swift | 47 + .../CallKitPushNotificationAdapterTests.swift | 111 +- .../LiveCommunicationKitServiceTests.swift | 113 ++ StreamVideoTests/Mock/VideoConfig+Dummy.swift | 8 +- .../AudioDeviceModule_Tests.swift | 37 +- .../AudioEngineLevelNodeAdapter_Tests.swift | 23 +- .../AudioBufferRenderer_Tests.swift | 22 + StreamVideoTests/VideoConfig_Tests.swift | 21 + .../WebRTC/SFU/SFUAdapter_Tests.swift | 2 +- .../WebRTC/SFU/SFUEventAdapter_Tests.swift | 9 +- .../WebRTC/v2/WebRTCStateAdapter_Tests.swift | 114 +- 36 files changed, 1897 insertions(+), 688 deletions(-) create mode 100644 Sources/StreamVideo/CallKit/LiveCommunicationKitService.swift create mode 100644 Sources/StreamVideo/CallKit/SystemCallingService.swift delete mode 100644 Sources/StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift delete mode 100644 Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift delete mode 100644 Sources/StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift delete mode 100644 Sources/StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift create mode 100644 StreamVideoTests/CallKit/LiveCommunicationKitServiceTests.swift create mode 100644 StreamVideoTests/VideoConfig_Tests.swift diff --git a/DemoApp/Sources/Components/AudioTrackPlayer/AudioTrackPlayer.swift b/DemoApp/Sources/Components/AudioTrackPlayer/AudioTrackPlayer.swift index ae7f45938..ef6ac30d8 100644 --- a/DemoApp/Sources/Components/AudioTrackPlayer/AudioTrackPlayer.swift +++ b/DemoApp/Sources/Components/AudioTrackPlayer/AudioTrackPlayer.swift @@ -6,7 +6,7 @@ import AVFoundation import Foundation import StreamVideo -final class AudioTrackPlayer: NSObject, AVAudioPlayerDelegate, @unchecked Sendable { +final class AudioTrackPlayer: NSObject, @unchecked Sendable { enum Track: String, Equatable, CaseIterable { case track1 = "track_1" case track2 = "track_2" @@ -60,15 +60,6 @@ final class AudioTrackPlayer: NSObject, AVAudioPlayerDelegate, @unchecked Sendab track = nil } } - - // MARK: - AVAudioPlayerDelegate - - func audioPlayerDidFinishPlaying( - _ player: AVAudioPlayer, - successfully flag: Bool - ) { - stop() - } } extension AudioTrackPlayer: InjectionKey { diff --git a/DemoApp/Sources/Components/Debug/Items/FeatureFlags/DebugMenu+FeatureFlagsView.swift b/DemoApp/Sources/Components/Debug/Items/FeatureFlags/DebugMenu+FeatureFlagsView.swift index d65c1dfbf..7162a5626 100644 --- a/DemoApp/Sources/Components/Debug/Items/FeatureFlags/DebugMenu+FeatureFlagsView.swift +++ b/DemoApp/Sources/Components/Debug/Items/FeatureFlags/DebugMenu+FeatureFlagsView.swift @@ -13,7 +13,7 @@ extension AppEnvironment { var viewProvider: () -> AnyView } - nonisolated(unsafe) static var featureFlags: [FeatureFlag] = [ + @MainActor static var featureFlags: [FeatureFlag] = [ .init { .init(DebugMenu.VideoProcessingPipelineToggleView()) }, .init { .init(DebugMenu.CapturingPipelineToggleView()) }, .init { .init(DebugMenu.VideoRenderingMenuView()) }, diff --git a/Sources/StreamVideo/CallKit/CallKitAdapter.swift b/Sources/StreamVideo/CallKit/CallKitAdapter.swift index fbf0143f2..79c4f17df 100644 --- a/Sources/StreamVideo/CallKit/CallKitAdapter.swift +++ b/Sources/StreamVideo/CallKit/CallKitAdapter.swift @@ -17,36 +17,36 @@ open class CallKitAdapter { /// The icon data used as the template for CallKit. open var iconTemplateImageData: Data? { - get { callKitService.iconTemplateImageData } - set { callKitService.iconTemplateImageData = newValue } + get { activeSystemCallingService.iconTemplateImageData } + set { updateSystemCallingServices { $0.iconTemplateImageData = newValue } } } /// The ringtone sound to use for CallKit ringing calls. open var ringtoneSound: String? { - get { callKitService.ringtoneSound } - set { callKitService.ringtoneSound = newValue } + get { activeSystemCallingService.ringtoneSound } + set { updateSystemCallingServices { $0.ringtoneSound = newValue } } } /// Configure whether calls should appear in the Recents app. open var includesCallsInRecents: Bool { - get { callKitService.includesCallsInRecents } - set { callKitService.includesCallsInRecents = newValue } + get { activeSystemCallingService.includesCallsInRecents } + set { updateSystemCallingServices { $0.includesCallsInRecents = newValue } } } - /// The callSettings to use when joining a call (after accepting it on CallKit) - /// default: nil + /// The callSettings to use when joining a call after accepting it on the + /// system calling UI. Default: `nil`. open var callSettings: CallSettings? { - didSet { callKitService.callSettings = callSettings } + didSet { updateSystemCallingServices { $0.callSettings = callSettings } } } - /// The policy that decides if a CallKit-managed call should leave + /// The policy that decides if a system-managed call should leave /// automatically when participant state changes. open var participantAutoLeavePolicy: ParticipantAutoLeavePolicy { - get { callKitService.participantAutoLeavePolicy } - set { callKitService.participantAutoLeavePolicy = newValue } + get { activeSystemCallingService.participantAutoLeavePolicy } + set { updateSystemCallingServices { $0.participantAutoLeavePolicy = newValue } } } - /// The policy defining the availability of CallKit services. + /// The policy defining the availability of system calling services. /// /// - Default: `.regionBased` public var availabilityPolicy: CallKitAvailabilityPolicy = .regionBased @@ -64,8 +64,8 @@ open class CallKitAdapter { /// join flow, such as waiting for another participant or validating an /// external precondition. Throw from the interceptor to fail the join. public var callJoinInterceptor: CallJoinIntercepting? { - get { callKitService.callJoinInterceptor } - set { callKitService.callJoinInterceptor = newValue } + get { activeSystemCallingService.callJoinInterceptor } + set { updateSystemCallingServices { $0.callJoinInterceptor = newValue } } } /// Initializes the `CallKitAdapter`. @@ -87,6 +87,47 @@ open class CallKitAdapter { callKitPushNotificationAdapter.unregister() } + private var activeSystemCallingService: SystemCallingService { + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *), shouldUseLiveCommunicationKit { + return InjectedValues[\.liveCommunicationKitService] + } + #endif + return callKitService + } + + private var shouldUseLiveCommunicationKit: Bool { + streamVideo?.videoConfig.useLiveCommunicationKit ?? true + } + + private func updateSystemCallingServices( + _ update: (SystemCallingService) -> Void + ) { + update(callKitService) + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *) { + update(InjectedValues[\.liveCommunicationKitService]) + } + #endif + } + + private func updateSystemCallingServices(with streamVideo: StreamVideo?) { + guard let streamVideo else { + updateSystemCallingServices { $0.streamVideo = nil } + return + } + + let useLiveCommunicationKit = streamVideo.videoConfig.useLiveCommunicationKit + callKitService.streamVideo = useLiveCommunicationKit ? nil : streamVideo + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *) { + InjectedValues[\.liveCommunicationKitService].streamVideo = useLiveCommunicationKit + ? streamVideo + : nil + } + #endif + } + private func didUpdate(_ streamVideo: StreamVideo?) { guard availabilityPolicy.policy.isAvailable else { log @@ -96,7 +137,7 @@ open class CallKitAdapter { return } - callKitService.streamVideo = streamVideo + updateSystemCallingServices(with: streamVideo) guard streamVideo != nil else { unregisterForIncomingCalls() diff --git a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift index 4034e6bd2..fbedb8bbd 100644 --- a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift +++ b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift @@ -41,6 +41,43 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs @Injected(\.callKitService) private var callKitService + private final class SendableCompletion: @unchecked Sendable { + private let completion: () -> Void + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func callAsFunction() { + completion() + } + } + + private var activeSystemCallingService: SystemCallingService { + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *), shouldUseLiveCommunicationKit { + return InjectedValues[\.liveCommunicationKitService] + } + #endif + return callKitService + } + + private var configuredStreamVideo: StreamVideo? { + if let streamVideo = callKitService.streamVideo { + return streamVideo + } + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *) { + return InjectedValues[\.liveCommunicationKitService].streamVideo + } + #endif + return nil + } + + private var shouldUseLiveCommunicationKit: Bool { + configuredStreamVideo?.videoConfig.useLiveCommunicationKit ?? true + } + /// The push registry used for VoIP push notifications. open private(set) lazy var registry: PKPushRegistry = .init(queue: .init(label: "io.getstream.voip")) @@ -106,8 +143,11 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs for type: PKPushType, completion: @escaping () -> Void ) { - defer { completion() } - guard type == .voIP else { return } + let pushCompletion = SendableCompletion(completion) + guard type == .voIP else { + pushCompletion() + return + } let content = decodePayload(payload) @@ -116,7 +156,7 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs "Received VoIP push notification with cid:\(content.cid) callerId:\(content.callerId) callerName:\(content.localizedCallerName)." ) - callKitService.reportIncomingCall( + activeSystemCallingService.reportIncomingCall( content.cid, localizedCallerName: content.localizedCallerName, callerId: content.callerId, @@ -125,6 +165,7 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs if let error { log.error(error, subsystems: .callKit) } + pushCompletion() } ) } diff --git a/Sources/StreamVideo/CallKit/LiveCommunicationKitService.swift b/Sources/StreamVideo/CallKit/LiveCommunicationKitService.swift new file mode 100644 index 000000000..a3762023f --- /dev/null +++ b/Sources/StreamVideo/CallKit/LiveCommunicationKitService.swift @@ -0,0 +1,1147 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +#if canImport(LiveCommunicationKit) +import AVFoundation +import Combine +import Foundation +import LiveCommunicationKit +import StreamWebRTC + +/// Manages LiveCommunicationKit integration for VoIP calls on iOS 27 and newer. +@available(iOS 27.0, *) +open class LiveCommunicationKitService: NSObject, ConversationManagerDelegate, SystemCallingService, @unchecked Sendable { + + struct MuteRequest: Equatable { + var callUUID: UUID + var isMuted: Bool + } + + /// LiveCommunicationKit actions are completed asynchronously by design, + /// but the action classes don't currently declare `Sendable`. + /// This wrapper keeps the non-Sendable reference private and only forwards + /// one-shot completion calls from the join flow. + private final class SendableJoinConversationAction: @unchecked Sendable { + private let action: JoinConversationAction + + init(_ action: JoinConversationAction) { + self.action = action + } + + var conversationUUID: UUID { action.conversationUUID } + + func fail() { + action.fail() + } + + func fulfill(dateConnected: Date) { + action.fulfill(dateConnected: dateConnected) + } + } + + @Injected(\.callCache) private var callCache + @Injected(\.uuidFactory) private var uuidFactory + @Injected(\.currentDevice) private var currentDevice + @Injected(\.audioStore) private var audioStore + @Injected(\.permissions) private var permissions + @Injected(\.applicationStateAdapter) private var applicationStateAdapter + @Injected(\.callKitService) private var callKitService + private let disposableBag = DisposableBag() + + /// Represents a call that is being managed by the service. + final class CallEntry: Equatable, @unchecked Sendable { + var call: Call + var callUUID: UUID + var createdBy: User? + var isActive: Bool = false + var ringingTimedOut: Bool = false + var isEndedElsewhere: Bool = false + var leaveReason: String? + + init( + call: Call, + callUUID: UUID = .init() + ) { + self.call = call + self.callUUID = callUUID + } + + static func == ( + lhs: LiveCommunicationKitService.CallEntry, + rhs: LiveCommunicationKitService.CallEntry + ) -> Bool { + lhs.call.cId == rhs.call.cId + && lhs.callUUID == rhs.callUUID + } + } + + /// Current `StreamVideo` client. Update when user logs in. + public var streamVideo: StreamVideo? { + didSet { didUpdate(streamVideo) } + } + + /// Stores the latest system-calling lifecycle event. + let eventPipelineSubject: CurrentValueSubject + + /// Publishes lifecycle events that temporarily drive UI state while the SDK + /// restores its regular call state. + public let eventPipeline: AnyPublisher + + /// The unique identifier for the call. + open var callId: String { + if let active, let callEntry = callEntry(for: active) { + return callEntry.call.callId + } else { + return "" + } + } + + /// The type of call. + open var callType: String { + if let active, let callEntry = callEntry(for: active) { + return callEntry.call.callType + } else { + return "" + } + } + + /// The icon data for the call template. + open var iconTemplateImageData: Data? + /// The ringtone sound to use for ringing calls. + open var ringtoneSound: String? + /// Whether the call can be held on its own or swapped with another call. + /// - Important: Holding a call isn't supported yet! + open var supportsHolding: Bool = false + /// Whether video is supported. + open var supportsVideo: Bool = false + /// Whether calls received will be showing in Recents app. + open var includesCallsInRecents: Bool = true + + /// Policy for handling calls when mic permission is missing while the app + /// runs in the background. See `CallKitMissingPermissionPolicy`. + open var missingPermissionPolicy: CallKitMissingPermissionPolicy = .none + + /// The policy that decides whether managed calls should leave automatically + /// when participant state changes. + open var participantAutoLeavePolicy: ParticipantAutoLeavePolicy = LastParticipantAutoLeavePolicy() { + didSet { + var oldValue = oldValue + oldValue.onPolicyTriggered = nil + participantAutoLeavePolicy.onPolicyTriggered = { [weak self] in + self?.participantAutoLeavePolicyTriggered() + } + } + } + + /// Optional interceptor invoked after the call join response has been + /// applied locally but before the SDK treats the call as fully entered. + public var callJoinInterceptor: CallJoinIntercepting? + + var callSettings: CallSettings? + + /// The conversation manager used for LiveCommunicationKit actions. + open internal(set) lazy var conversationManager = buildConversationManager() + + private var _storage: [UUID: CallEntry] = [:] + private let storageAccessQueue: UnfairQueue = .init() + private var active: UUID? + + var callCount: Int { storageAccessQueue.sync { _storage.count } } + + private var callEndedNotificationCancellable: AnyCancellable? + private var ringingTimerCancellable: AnyCancellable? + + private let muteActionSubject = PassthroughSubject() + private var muteActionCancellable: AnyCancellable? + private let muteProcessingQueue = OperationQueue(maxConcurrentOperationCount: 1) + private var isMuted: Bool? + + /// Initialize. + override public init() { + let eventPipelineSubject = CurrentValueSubject(.idle) + self.eventPipelineSubject = eventPipelineSubject + self.eventPipeline = eventPipelineSubject.eraseToAnyPublisher() + + super.init() + + callEndedNotificationCancellable = NotificationCenter + .default + .publisher(for: Notification.Name(CallNotification.callEnded)) + .compactMap { $0.object as? Call } + .sink { + [weak self] in self?.callEnded( + $0.cId, + ringingTimedOut: false, + leaveReason: StreamRejectionReasonProvider + .HandledCallReason + .callEndedLocally + .rawValue + ) + } + + muteActionCancellable = muteActionSubject + .removeDuplicates() + .filter { [weak self] _ in self?.applicationStateAdapter.state != .foreground } + .debounce(for: 0.5, scheduler: DispatchQueue.global(qos: .userInteractive)) + .sink { [weak self] in self?.performMuteRequest($0) } + + participantAutoLeavePolicy.onPolicyTriggered = { [weak self] in + self?.participantAutoLeavePolicyTriggered() + } + } + + /// Report an incoming call to LiveCommunicationKit. + open func reportIncomingCall( + _ cid: String, + localizedCallerName: String, + callerId: String, + hasVideo: Bool = false, + completion: @Sendable @escaping (Error?) -> Void + ) { + let (callUUID, update) = buildConversationUpdate( + cid: cid, + localizedCallerName: localizedCallerName, + callerId: callerId, + hasVideo: hasVideo + ) + + log.debug( + """ + Reporting LiveCommunicationKit incoming call with + callUUID:\(callUUID) + cid:\(cid) + callerId:\(callerId) + callerName:\(localizedCallerName) + hasVideo: \(hasVideo) + """, + subsystems: .callKit + ) + + Task(disposableBag: disposableBag) { [weak self] in + guard let self else { + return + } + + do { + try await conversationManager.reportNewIncomingConversation( + uuid: callUUID, + update: update + ) + completion(nil) + } catch { + completion(error) + log.error( + """ + Failed to report LiveCommunicationKit incoming call with + cid: \(cid) + localizedCallerName: \(localizedCallerName) + hasVideo: \(hasVideo) + """, + subsystems: .callKit, + error: error + ) + set(nil, for: callUUID) + return + } + + await prepareIncomingCall( + callUUID: callUUID, + cid: cid, + localizedCallerName: localizedCallerName, + callerId: callerId, + hasVideo: hasVideo + ) + } + } + + /// Handle acceptance by the same user on another device. + open func callAccepted(_ response: CallAcceptedEvent) { + guard + let newCallEntry = callEntry(for: response.callCid), + newCallEntry.callUUID != active, + response.user.id == streamVideo?.user.id + else { + return + } + log.debug( + """ + Call accepted + callId:\(newCallEntry.call.callId) + callType:\(newCallEntry.call.callType) + callerId:\(newCallEntry.createdBy?.id) + ringingTimedOut:\(newCallEntry.ringingTimedOut) + isEndedElsewhere:\(newCallEntry.isEndedElsewhere) + """, + subsystems: .callKit + ) + + reportConversationEnded(for: newCallEntry, reason: .joinedElsewhere) + ringingTimerCancellable?.cancel() + ringingTimerCancellable = nil + set(nil, for: newCallEntry.callUUID) + callCache.remove(for: newCallEntry.call.cId) + } + + /// Handle a rejection from the same user or the call creator elsewhere. + open func callRejected(_ response: CallRejectedEvent) { + guard + let newCallEntry = callEntry(for: response.callCid), + newCallEntry.callUUID != active + else { + return + } + + let isCurrentUserRejection = response.user.id == streamVideo?.user.id + let isCallCreatorRejection = response.user.id == newCallEntry.createdBy?.id + + guard isCurrentUserRejection || isCallCreatorRejection else { + return + } + log.debug( + """ + Call rejected + callId:\(newCallEntry.call.callId) + callType:\(newCallEntry.call.callType) + callerId:\(newCallEntry.createdBy?.id) + ringingTimedOut:\(newCallEntry.ringingTimedOut) + isEndedElsewhere:\(newCallEntry.isEndedElsewhere) + isCurrentUserRejection:\(isCurrentUserRejection) + isCallCreatorRejection:\(isCallCreatorRejection) + """, + subsystems: .callKit + ) + + reportConversationEnded(for: newCallEntry, reason: .declinedElsewhere) + ringingTimerCancellable?.cancel() + ringingTimerCancellable = nil + set(nil, for: newCallEntry.callUUID) + callCache.remove(for: newCallEntry.call.cId) + } + + /// Handles a ringing or active LiveCommunicationKit call ending. + open func callEnded( + _ cId: String, + ringingTimedOut: Bool, + leaveReason: String? = nil + ) { + endCall( + cId, + ringingTimedOut: ringingTimedOut, + leaveReason: leaveReason + ) + } + + /// Called when a participant leaves the call. + open func callParticipantLeft( + _ response: CallSessionParticipantLeftEvent + ) { + _ = response + } + + // MARK: - ConversationManagerDelegate + + open func conversationManagerDidBegin(_ manager: ConversationManager) { + log.debug("LiveCommunicationKit ConversationManager didBegin.", subsystems: .callKit) + } + + open func conversationManagerDidReset(_ manager: ConversationManager) { + log.debug("LiveCommunicationKit ConversationManager didReset.", subsystems: .callKit) + storageAccessQueue.sync { + for (_, entry) in _storage { + entry.call.didPerform(.didReset) + entry.call.leave() + } + } + sendEvent(.idle) + } + + open func conversationManager( + _ manager: ConversationManager, + conversationChanged conversation: Conversation + ) { + log.debug( + "LiveCommunicationKit conversation changed uuid:\(conversation.uuid).", + subsystems: .callKit + ) + } + + open func conversationManager( + _ manager: ConversationManager, + didActivate audioSession: AVAudioSession + ) { + log.debug( + """ + LiveCommunicationKit audioSession was activated: + category: \(audioSession.category) + mode: \(audioSession.mode) + options: \(audioSession.categoryOptions) + route: \(audioSession.currentRoute) + + CallSettings: \(callSettings) + """, + subsystems: .callKit + ) + + audioStore.dispatch(.callKit(.activate(audioSession))) + observeCallSettings(active) + } + + public func conversationManager( + _ manager: ConversationManager, + didDeactivate audioSession: AVAudioSession + ) { + log.debug( + """ + LiveCommunicationKit audioSession was deactivated: + category: \(audioSession.category) + mode: \(audioSession.mode) + options: \(audioSession.categoryOptions) + route: \(audioSession.currentRoute) + + CallSettings: \(callSettings) + """, + subsystems: .callKit + ) + + audioStore.dispatch(.callKit(.deactivate(audioSession))) + } + + open func conversationManager( + _ manager: ConversationManager, + perform action: ConversationAction + ) { + switch action { + case let action as JoinConversationAction: + performJoinConversationAction(action) + case let action as EndConversationAction: + performEndConversationAction(action) + case let action as MuteConversationAction: + performMuteConversationAction(action) + default: + log.warning( + "Unsupported LiveCommunicationKit action:\(action).", + subsystems: .callKit + ) + action.fail() + } + } + + open func conversationManager( + _ manager: ConversationManager, + timedOutPerforming action: ConversationAction + ) { + guard + let joinAction = action as? JoinConversationAction, + let callToJoinEntry = callEntry(for: joinAction.conversationUUID) + else { + log.warning( + "LiveCommunicationKit timed out performing action:\(action).", + subsystems: .callKit + ) + return + } + + log.warning( + """ + LiveCommunicationKit timed out performing the join action while joining + callId:\(callToJoinEntry.call.callId) + callType:\(callToJoinEntry.call.callType) + callerId:\(callToJoinEntry.createdBy?.id) + """, + subsystems: .callKit + ) + + callToJoinEntry.call.leave(reason: "callkit.join.timeout") + set(nil, for: joinAction.conversationUUID) + sendEvent(.idle) + } + + // MARK: - Helpers + + /// Request a LiveCommunicationKit action. + open func requestTransaction( + _ action: ConversationAction + ) async throws { + try await conversationManager.perform([action]) + } + + /// Checks whether the incoming ringing call was already handled before + /// this device finished presenting LiveCommunicationKit. + open func checkIfCallWasHandled(callState: GetCallResponse) -> String? { + guard let streamVideo else { + log.warning( + "LiveCommunicationKit operation:\(#function) cannot be fulfilled because StreamVideo is nil.", + subsystems: .callKit + ) + return StreamRejectionReasonProvider + .HandledCallReason + .notConfigured + .rawValue + } + + return streamVideo + .rejectionReasonProvider + .reason(callState: callState) + } + + /// Start the ringing timeout timer for the call. + open func setUpRingingTimer(for callState: GetCallResponse) { + let timeout = TimeInterval(callState.call.settings.ring.autoCancelTimeoutMs / 1000) + ringingTimerCancellable = DefaultTimer + .publish(every: timeout) + .sink { [weak self] _ in + log.debug( + "Detected ringing timeout, hanging up...", + subsystems: .callKit + ) + self?.callEnded(callState.call.cid, ringingTimedOut: true) + self?.ringingTimerCancellable = nil + } + } + + /// Called when `StreamVideo` changes. Subscribes to events on real devices. + open func didUpdate(_ streamVideo: StreamVideo?) { + guard currentDevice.deviceType != .simulator else { + return + } + + subscribeToCallEvents() + } + + // MARK: - Private helpers + + private func prepareIncomingCall( + callUUID: UUID, + cid: String, + localizedCallerName: String, + callerId: String, + hasVideo: Bool + ) async { + guard let streamVideo, let callEntry = callEntry(for: callUUID) else { + log.warning( + """ + LiveCommunicationKit operation:reportIncomingCall cannot be fulfilled because + StreamVideo is nil. + """, + subsystems: .callKit + ) + callEnded( + cid, + ringingTimedOut: false, + leaveReason: StreamRejectionReasonProvider + .HandledCallReason + .notConfigured + .rawValue + ) + return + } + + do { + if streamVideo.state.connection != .connected { + let result = await Task(disposableBag: disposableBag) { [weak self] in + try await self?.streamVideo?.connect() + }.result + + switch result { + case .success: + break + case let .failure(failure): + throw failure + } + } + + if streamVideo.state.ringingCall?.cId != callEntry.call.cId { + Task(disposableBag: disposableBag) { @MainActor [weak self] in + self?.streamVideo?.state.ringingCall = callEntry.call + } + } + + try missingPermissionPolicy + .policy + .reportCall() + + let callState = try await callEntry.call.get() + if let leaveReason = checkIfCallWasHandled(callState: callState) { + log.debug( + "Ending call with reason:\(leaveReason) { uuid:\(callUUID), cid:\(cid), callerId:\(callerId), callerName:\(localizedCallerName) }", + subsystems: .callKit + ) + callEnded( + cid, + ringingTimedOut: false, + leaveReason: leaveReason + ) + } else { + callEntry.createdBy = callState.call.createdBy.toUser + setUpRingingTimer(for: callState) + } + } catch { + log.error( + """ + Failed to report incoming call with + cid: \(cid) + localizedCallerName: \(localizedCallerName) + hasVideo: \(hasVideo) + """, + subsystems: .callKit, + error: error + ) + callEnded( + cid, + ringingTimedOut: false, + leaveReason: StreamRejectionReasonProvider + .HandledCallReason + .reportCallFailed + .rawValue + ) + } + } + + private func performJoinConversationAction( + _ action: JoinConversationAction + ) { + guard + action.conversationUUID != active, + let callToJoinEntry = callEntry(for: action.conversationUUID) + else { + return action.fail() + } + + let conversationUUID = action.conversationUUID + let action = SendableJoinConversationAction(action) + + ringingTimerCancellable?.cancel() + ringingTimerCancellable = nil + active = conversationUUID + callToJoinEntry.call.didPerform(.performAnswerCall) + + let hasCompletedAction = Atomic(wrappedValue: false) + @discardableResult @Sendable func completeActionOnce( + _ completion: @escaping () -> Void + ) -> Bool { + var shouldComplete = false + hasCompletedAction.mutate { hasCompleted in + shouldComplete = !hasCompleted + return true + } + guard shouldComplete else { + return false + } + completion() + return true + } + + Task(disposableBag: disposableBag) { @MainActor [weak self] in + guard let self else { + return + } + log.debug( + "Answering LiveCommunicationKit incoming call with callId:\(callToJoinEntry.call.callId) callType:\(callToJoinEntry.call.callType) callerId:\(callToJoinEntry.createdBy?.id)." + ) + + do { + sendEvent(.accept) + reportConversationStartedConnecting(for: callToJoinEntry) + try await callToJoinEntry.call.accept() + } catch { + log.error(error, subsystems: .callKit) + completeActionOnce { action.fail() } + } + + do { + sendEvent(.joining(callToJoinEntry.call)) + callToJoinEntry.call.state.joinSource = .callKit(.init { + completeActionOnce { action.fulfill(dateConnected: Date()) } + }) + + try await callToJoinEntry.call.join( + callSettings: callSettings, + policy: .peerConnectionReadinessAware, + joinInterceptor: callJoinInterceptor + ) + sendEvent(.joined) + reportConversationConnected(for: callToJoinEntry) + } catch { + let didFail = completeActionOnce { action.fail() } + if !didFail { + reportConversationEnded(for: callToJoinEntry, reason: .failed) + } + if !(error is CallJoinInterceptionError), !(error is TimeOutError) { + callToJoinEntry.call.leave() + } + set(nil, for: conversationUUID) + log.error(error, subsystems: .callKit) + sendEvent(.idle) + } + } + } + + private func performEndConversationAction( + _ action: EndConversationAction + ) { + ringingTimerCancellable?.cancel() + ringingTimerCancellable = nil + let currentCallWasEnded = action.conversationUUID == active + + guard let stackEntry = callEntry(for: action.conversationUUID) else { + action.fail() + return + } + let actionCallUUID = action.conversationUUID + + Task(disposableBag: disposableBag) { [weak self] in + guard let self else { + return + } + await performEnd( + stackEntry, + currentCallWasEnded: currentCallWasEnded + ) + set(nil, for: actionCallUUID) + } + + sendEvent(.idle) + action.fulfill() + } + + private func performMuteConversationAction( + _ action: MuteConversationAction + ) { + guard + let stackEntry = callEntry(for: action.conversationUUID) + else { + action.fail() + return + } + + guard permissions.hasMicrophonePermission else { + if action.isMuted { + action.fulfill() + } else { + action.fail() + } + return + } + + muteActionSubject.send( + .init( + callUUID: stackEntry.callUUID, + isMuted: action.isMuted + ) + ) + action.fulfill() + } + + private func performEnd( + _ stackEntry: CallEntry, + currentCallWasEnded: Bool + ) async { + log.debug( + """ + Ending VoIP call with + callId:\(stackEntry.call.callId) + callType:\(stackEntry.call.callType) + callerId:\(stackEntry.createdBy?.id) + """, + subsystems: .callKit + ) + if currentCallWasEnded { + sendEvent(.reject) + stackEntry.call.didPerform(.performEndCall) + stackEntry.call.leave(reason: stackEntry.leaveReason) + } else { + do { + let rejectionReason = if let leaveReason = stackEntry.leaveReason { + leaveReason + } else { + await streamVideo? + .rejectionReasonProvider + .reason( + for: stackEntry.call.cId, + ringTimeout: stackEntry.ringingTimedOut + ) + } + log.debug( + """ + Rejecting with reason: \(rejectionReason ?? "nil") + call:\(stackEntry.call.callId) + callType: \(stackEntry.call.callType) + """, + subsystems: .callKit + ) + sendEvent(.reject) + stackEntry.call.didPerform(.performRejectCall) + try await stackEntry.call.reject(reason: rejectionReason) + } catch { + log.error(error, subsystems: .callKit) + } + } + } + + private func subscribeToCallEvents() { + disposableBag.removeAll() + + guard let streamVideo else { + log.warning( + """ + LiveCommunicationKit operation:\(#function) cannot be fulfilled because + StreamVideo is nil. + """, + subsystems: .callKit + ) + return + } + + streamVideo + .eventPublisher() + .sink { [weak self] event in + guard let self else { return } + switch event { + case let .typeCallEndedEvent(response): + callEnded( + response.callCid, + ringingTimedOut: false, + leaveReason: StreamRejectionReasonProvider + .HandledCallReason + .callEventReceived + .rawValue + ) + case let .typeCallAcceptedEvent(response): + callAccepted(response) + case let .typeCallRejectedEvent(response): + callRejected(response) + case let .typeCallSessionParticipantLeftEvent(response): + callParticipantLeft(response) + default: + break + } + } + .store(in: disposableBag) + + log.debug( + "\(type(of: self)) is now subscribed to CallEvent updates.", + subsystems: .callKit + ) + } + + private func endCall( + _ cId: String, + ringingTimedOut: Bool, + leaveReason: String? = nil + ) { + let result: (CallEntry, Bool)? = storageAccessQueue.sync { + guard + let callEndedEntry = _storage.first(where: { $0.value.call.cId == cId })?.value + else { + return nil + } + + let wasAlreadyMarkedEnded = callEndedEntry.leaveReason != nil + || callEndedEntry.isEndedElsewhere + || callEndedEntry.ringingTimedOut + + guard wasAlreadyMarkedEnded == false else { + return (callEndedEntry, false) + } + + if ringingTimedOut { + callEndedEntry.ringingTimedOut = true + } else { + callEndedEntry.isEndedElsewhere = true + } + + if let leaveReason { + callEndedEntry.leaveReason = leaveReason + } + + _storage[callEndedEntry.callUUID] = callEndedEntry + + return (callEndedEntry, true) + } + + guard let result else { + return + } + + let (callEndedEntry, shouldEnd) = result + + log.debug( + """ + CallEnded + callId:\(callEndedEntry.call.callId) + callType:\(callEndedEntry.call.callType) + callerId:\(callEndedEntry.createdBy?.id) + ringingTimedOut:\(callEndedEntry.ringingTimedOut) + isEndedElsewhere:\(callEndedEntry.isEndedElsewhere) + leaveReason:\(callEndedEntry.leaveReason ?? "nil") + """, + subsystems: .callKit + ) + guard shouldEnd else { + return + } + + Task(disposableBag: disposableBag) { [weak self] in + guard let self else { return } + reportConversationEnded( + for: callEndedEntry, + reason: conversationEndedReason(for: callEndedEntry) + ) + await performEnd( + callEndedEntry, + currentCallWasEnded: callEndedEntry.callUUID == active + ) + set(nil, for: callEndedEntry.callUUID) + sendEvent(.idle) + } + } + + private func participantAutoLeavePolicyTriggered() { + guard + let active, + let activeCallEntry = callEntry(for: active) + else { + return + } + + callEnded( + activeCallEntry.call.cId, + ringingTimedOut: false, + leaveReason: StreamRejectionReasonProvider + .HandledCallReason + .autoLeave + .rawValue + ) + } + + private func buildConversationManager( + supportedHandleTypes: Set = [.generic] + ) -> ConversationManager { + if supportsHolding { + log.warning( + "LiveCommunicationKit hold isn't supported.", + subsystems: .callKit + ) + } + + let configuration = ConversationManager.Configuration( + ringtoneName: ringtoneSound, + iconTemplateImageData: iconTemplateImageData, + maximumConversationGroups: 1, + maximumConversationsPerConversationGroup: 1, + includesConversationInRecents: includesCallsInRecents, + supportsVideo: supportsVideo, + supportedHandleTypes: supportedHandleTypes + ) + let manager = ConversationManager(configuration: configuration) + manager.delegate = self + return manager + } + + private func buildConversationUpdate( + cid: String, + localizedCallerName: String, + callerId: String, + hasVideo: Bool + ) -> (UUID, Conversation.Update) { + let idComponents = cid.components(separatedBy: ":") + let uuid = uuidFactory.get() + if + idComponents.count >= 2, + let call = streamVideo?.call( + callType: idComponents[0], + callId: idComponents[1] + ) { + set(.init(call: call, callUUID: uuid), for: uuid) + } + + let remoteHandle = Handle( + type: .generic, + value: callerId, + displayName: localizedCallerName + ) + let localHandle = streamVideo.map { + Handle( + type: .generic, + value: $0.user.id, + displayName: $0.user.name + ) + } + let capabilities: Conversation.Capabilities? = hasVideo ? .video : nil + let update = Conversation.Update( + localMember: localHandle, + members: [remoteHandle], + activeRemoteMembers: nil, + capabilities: capabilities + ) + + return (uuid, update) + } + + // MARK: - Storage Access + + private func set(_ value: CallEntry?, for key: UUID) { + storageAccessQueue.sync { + _storage[key] = value + } + } + + private func callEntry(for cId: String) -> CallEntry? { + storageAccessQueue.sync { + _storage + .first { $0.value.call.cId == cId }? + .value + } + } + + private func callEntry(for uuid: UUID) -> CallEntry? { + storageAccessQueue.sync { _storage[uuid] } + } + + private func observeCallSettings( + _ callUUID: UUID? + ) { + let key = "livecommunicationkit-call-settings-observation" + guard + let callUUID, + let callEntry = callEntry(for: callUUID) + else { + disposableBag.remove(key) + return + } + + Task { @MainActor [weak self, callEntry, callUUID] in + guard let disposableBag = self?.disposableBag else { return } + callEntry + .call + .state + .$callSettings + .map { $0.audioOn == false } + .removeDuplicates() + .log(.debug, subsystems: .callKit) { "Will perform MuteConversationAction with muted:\($0). " } + .sink { [weak self] in self?.performCallSettingMuteRequest($0, callUUID: callUUID) } + .store(in: disposableBag, key: key) + } + } + + private func performCallSettingMuteRequest( + _ muted: Bool, + callUUID: UUID + ) { + muteProcessingQueue.addTaskOperation { [weak self] in + guard + let self, + callUUID == active, + isMuted != muted + else { + return + } + do { + try await requestTransaction( + MuteConversationAction(conversationUUID: callUUID, isMuted: muted) + ) + isMuted = muted + } catch { + log.warning("Unable to apply CallSettings.audioOn:\(!muted).", subsystems: .callKit) + } + } + } + + private func performMuteRequest(_ request: MuteRequest) { + muteProcessingQueue.addTaskOperation { [weak self] in + guard + let self, + request.callUUID == active, + isMuted != request.isMuted, + let stackEntry = callEntry(for: request.callUUID) + else { + return + } + + do { + if request.isMuted { + stackEntry.call.didPerform(.performSetMutedCall) + try await stackEntry.call.microphone.disable() + } else { + stackEntry.call.didPerform(.performSetMutedCall) + try await stackEntry.call.microphone.enable() + } + isMuted = request.isMuted + } catch { + log.error( + "Unable to set call uuid:\(request.callUUID) muted:\(request.isMuted) state.", + error: error + ) + } + } + } + + private func conversation(for uuid: UUID) -> Conversation? { + conversationManager.conversations.first { $0.uuid == uuid } + } + + private func reportConversationStartedConnecting(for entry: CallEntry) { + guard let conversation = conversation(for: entry.callUUID) else { return } + conversationManager.reportConversationEvent( + .conversationStartedConnecting(Date()), + for: conversation + ) + } + + private func reportConversationConnected(for entry: CallEntry) { + guard let conversation = conversation(for: entry.callUUID) else { return } + conversationManager.reportConversationEvent( + .conversationConnected(Date()), + for: conversation + ) + } + + private func reportConversationEnded( + for entry: CallEntry, + reason: Conversation.EndedReason + ) { + guard let conversation = conversation(for: entry.callUUID) else { return } + conversationManager.reportConversationEvent( + .conversationEnded(Date(), reason), + for: conversation + ) + } + + private func conversationEndedReason( + for entry: CallEntry + ) -> Conversation.EndedReason { + if entry.ringingTimedOut { + return .unanswered + } + + switch entry.leaveReason { + case StreamRejectionReasonProvider.HandledCallReason.reportCallFailed.rawValue, + StreamRejectionReasonProvider.HandledCallReason.notConfigured.rawValue: + return .failed + default: + return .remoteEnded + } + } + + private func sendEvent(_ event: CallKitService.Event) { + eventPipelineSubject.send(event) + callKitService.eventPipelineSubject.send(event) + } +} + +@available(iOS 27.0, *) +extension LiveCommunicationKitService: InjectionKey { + /// Current `LiveCommunicationKitService` instance. + public nonisolated(unsafe) static var currentValue: LiveCommunicationKitService = .init() +} + +extension InjectedValues { + /// Accessor for `LiveCommunicationKitService`. + @available(iOS 27.0, *) + public var liveCommunicationKitService: LiveCommunicationKitService { + get { Self[LiveCommunicationKitService.self] } + set { Self[LiveCommunicationKitService.self] = newValue } + } +} +#endif diff --git a/Sources/StreamVideo/CallKit/SystemCallingService.swift b/Sources/StreamVideo/CallKit/SystemCallingService.swift new file mode 100644 index 000000000..d771b514b --- /dev/null +++ b/Sources/StreamVideo/CallKit/SystemCallingService.swift @@ -0,0 +1,36 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +protocol SystemCallingService: AnyObject { + var streamVideo: StreamVideo? { get set } + var iconTemplateImageData: Data? { get set } + var ringtoneSound: String? { get set } + var supportsHolding: Bool { get set } + var supportsVideo: Bool { get set } + var includesCallsInRecents: Bool { get set } + var missingPermissionPolicy: CallKitMissingPermissionPolicy { get set } + var participantAutoLeavePolicy: ParticipantAutoLeavePolicy { get set } + var callJoinInterceptor: CallJoinIntercepting? { get set } + var callSettings: CallSettings? { get set } + var eventPipeline: AnyPublisher { get } + var callCount: Int { get } + + func reportIncomingCall( + _ cid: String, + localizedCallerName: String, + callerId: String, + hasVideo: Bool, + completion: @Sendable @escaping (Error?) -> Void + ) + + func callAccepted(_ response: CallAcceptedEvent) + func callRejected(_ response: CallRejectedEvent) + func callEnded(_ cId: String, ringingTimedOut: Bool, leaveReason: String?) + func callParticipantLeft(_ response: CallSessionParticipantLeftEvent) +} + +extension CallKitService: SystemCallingService {} diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule.swift b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule.swift index 1bd2f7b3d..b7f120f1c 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule.swift @@ -557,6 +557,16 @@ final class AudioDeviceModule: NSObject, RTCAudioDeviceModuleDelegate, Encodable format: AVAudioFormat, context: [AnyHashable: Any] ) -> Int { + guard format.sampleRate > 0, format.channelCount > 0 else { + log.warning( + "Skipping input graph configuration because the format is invalid: \(format).", + subsystems: .audioRecording + ) + engineInputContext = nil + audioLevelsAdapter.uninstall(on: 0) + return Constant.successResult + } + engineInputContext = .init( engine: engine, source: source, diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift index fd2288b4f..437afb835 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift @@ -47,6 +47,13 @@ final class AudioEngineLevelNodeAdapter: AudioEngineNodeAdapting { bufferSize: UInt32 = 1024 ) { guard let mixer = node as? AVAudioMixerNode, inputTap == nil else { return } + guard format.sampleRate > 0, format.channelCount > 0 else { + log.warning( + "Skipping input tap installation because the format is invalid: \(format).", + subsystems: .audioRecording + ) + return + } mixer.installTap( onBus: bus, diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer.swift b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer.swift index fae80ab7f..a6b98801d 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer.swift @@ -69,6 +69,15 @@ final class AudioBufferRenderer { return } + guard context.format.sampleRate > 0, context.format.channelCount > 0 else { + log.warning( + "Skipping renderer configuration because the format is invalid: \(context.format).", + subsystems: .audioRecording + ) + self.context = nil + return + } + #if STREAM_TESTS // Avoid making changes to AVAudioEngine instances during tests as they // cause crashes. diff --git a/Sources/StreamVideo/VideoConfig.swift b/Sources/StreamVideo/VideoConfig.swift index 265f36611..e8c7a723a 100644 --- a/Sources/StreamVideo/VideoConfig.swift +++ b/Sources/StreamVideo/VideoConfig.swift @@ -21,6 +21,12 @@ public final class VideoConfig: Sendable { public let usesProcessingPipeline: Bool public let usesNewCapturingPipeline: Bool + /// Enables LiveCommunicationKit instead of CallKit when available. + /// + /// LiveCommunicationKit is only used on supported OS versions. Older OS + /// versions continue to use CallKit regardless of this value. + public let useLiveCommunicationKit: Bool + /// Initializes a new instance of `VideoConfig` with the specified parameters. /// - Parameters: /// - videoFilters: An array of `VideoFilter` objects representing the filters to apply to the video. @@ -30,18 +36,23 @@ public final class VideoConfig: Sendable { /// - audioProcessingModule: Provide your own audio processing or fallback to the /// default one. /// - usesProcessingPipeline: Enables capture-time processing for camera frames. + /// - usesNewCapturingPipeline: Enables the newer camera capture pipeline. + /// - useLiveCommunicationKit: Enables LiveCommunicationKit on supported OS + /// versions. Defaults to `true`. /// - Returns: A new instance of `VideoConfig`. public init( videoFilters: [VideoFilter] = [], noiseCancellationFilter: NoiseCancellationFilter? = nil, audioProcessingModule: AudioProcessingModule? = nil, usesProcessingPipeline: Bool = true, - usesNewCapturingPipeline: Bool = true + usesNewCapturingPipeline: Bool = true, + useLiveCommunicationKit: Bool = true ) { self.videoFilters = videoFilters self.noiseCancellationFilter = noiseCancellationFilter self.audioProcessingModule = audioProcessingModule ?? InjectedValues[\.audioFilterProcessingModule] self.usesProcessingPipeline = usesProcessingPipeline self.usesNewCapturingPipeline = usesNewCapturingPipeline + self.useLiveCommunicationKit = useLiveCommunicationKit } } diff --git a/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift b/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift index e564aaf70..ab141aa6c 100644 --- a/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift +++ b/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift @@ -320,7 +320,12 @@ struct CallKitReconnectionPolicy: AutomaticReconnectionPolicy { init() {} func canBeReconnected() -> Bool { - callKitService.callCount > 0 + #if canImport(LiveCommunicationKit) + if #available(iOS 27.0, *) { + return InjectedValues[\.liveCommunicationKitService].callCount > 0 + } + #endif + return callKitService.callCount > 0 } } diff --git a/Sources/StreamVideoSwiftUI/CallView/LocalParticipantViewModifier.swift b/Sources/StreamVideoSwiftUI/CallView/LocalParticipantViewModifier.swift index 058827007..f50222a1d 100644 --- a/Sources/StreamVideoSwiftUI/CallView/LocalParticipantViewModifier.swift +++ b/Sources/StreamVideoSwiftUI/CallView/LocalParticipantViewModifier.swift @@ -73,74 +73,6 @@ public struct LocalParticipantViewModifier: ViewModifier { } } -@available(iOS, introduced: 13, obsoleted: 14) -public struct LocalParticipantViewModifier_iOS13: ViewModifier { - - private let localParticipant: CallParticipant - private var call: Call? - private var showAllInfo: Bool - @BackportStateObject private var microphoneChecker: MicrophoneChecker - @Binding private var callSettings: CallSettings - private var decorations: Set - - init( - localParticipant: CallParticipant, - call: Call?, - callSettings: Binding, - showAllInfo: Bool = false, - decorations: [VideoCallParticipantDecoration] = VideoCallParticipantDecoration.allCases - ) { - self.localParticipant = localParticipant - self.call = call - _microphoneChecker = .init(wrappedValue: .init()) - _callSettings = callSettings - self.showAllInfo = showAllInfo - self.decorations = .init(decorations) - } - - public func body(content: Content) -> some View { - content - .overlay( - BottomView { - HStack { - ParticipantMicrophoneCheckView( - audioLevels: microphoneChecker.audioLevels, - microphoneOn: callSettings.audioOn, - isSilent: microphoneChecker.isSilent, - isPinned: localParticipant.isPinned - ) - - if showAllInfo { - Spacer() - ConnectionQualityIndicator( - connectionQuality: localParticipant.connectionQuality - ) - } - } - .padding(.bottom, 2) - } - .padding(.all, showAllInfo ? 16 : 8) - ) - .applyDecorationModifierIfRequired( - VideoCallParticipantOptionsModifier(participant: localParticipant, call: call), - decoration: .options, - availableDecorations: decorations - ) - .applyDecorationModifierIfRequired( - VideoCallParticipantSpeakingModifier(participant: localParticipant, participantCount: participantCount), - decoration: .speaking, - availableDecorations: decorations - ) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .clipped() - } - - @MainActor - private var participantCount: Int { - call?.state.participants.count ?? 0 - } -} - internal struct ParticipantMicrophoneCheckView: View { var audioLevels: [Float] diff --git a/Sources/StreamVideoSwiftUI/CallView/PermissionsPromptView.swift b/Sources/StreamVideoSwiftUI/CallView/PermissionsPromptView.swift index 7025f5adb..9e7add65d 100644 --- a/Sources/StreamVideoSwiftUI/CallView/PermissionsPromptView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/PermissionsPromptView.swift @@ -91,20 +91,12 @@ public struct PermissionsPromptView: View { Button { presentNavigationPopup = true } label: { - if #available(iOS 14.0, *) { - Label { - Text(L10n.Call.Permissions.Missing.Cta.title) - } icon: { - Image(systemName: "gear") - } - .minimumScaleFactor(0.7) - } else { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "gear") - Text(L10n.Call.Permissions.Missing.Cta.title) - } - .minimumScaleFactor(0.7) + Label { + Text(L10n.Call.Permissions.Missing.Cta.title) + } icon: { + Image(systemName: "gear") } + .minimumScaleFactor(0.7) } .padding(.vertical, 4) .padding(.horizontal, 8) diff --git a/Sources/StreamVideoSwiftUI/CallView/SampleBufferVideoCallView.swift b/Sources/StreamVideoSwiftUI/CallView/SampleBufferVideoCallView.swift index c790f143a..647baf7d2 100644 --- a/Sources/StreamVideoSwiftUI/CallView/SampleBufferVideoCallView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/SampleBufferVideoCallView.swift @@ -45,7 +45,7 @@ protocol SampleBufferVideoRendering { func enqueue(_ sampleBuffer: CMSampleBuffer) } -extension AVSampleBufferDisplayLayer: SampleBufferVideoRendering {} +extension AVSampleBufferDisplayLayer: @preconcurrency SampleBufferVideoRendering {} #if swift(>=5.9) @available(iOS 17.0, *) extension AVSampleBufferVideoRenderer: SampleBufferVideoRendering {} diff --git a/Sources/StreamVideoSwiftUI/CallView/ViewModifiers/CallEndedViewModifier.swift b/Sources/StreamVideoSwiftUI/CallView/ViewModifiers/CallEndedViewModifier.swift index bfc0e24be..0dbfae24c 100644 --- a/Sources/StreamVideoSwiftUI/CallView/ViewModifiers/CallEndedViewModifier.swift +++ b/Sources/StreamVideoSwiftUI/CallView/ViewModifiers/CallEndedViewModifier.swift @@ -131,80 +131,6 @@ private struct CallEndedViewModifier: ViewModifier { } } -@available(iOS, introduced: 13, obsoleted: 14) -private struct CallEndedViewModifier_iOS13: ViewModifier { - - private var presentationValidator: (Call?) -> Bool - private var subviewProvider: (Call?, @escaping () -> Void) -> Subview - - @BackportStateObject private var viewModel: CallEndedViewModifierViewModel - - init( - presentationValidator: @escaping (Call?) -> Bool, - @ViewBuilder subviewProvider: @escaping (Call?, @escaping () -> Void) -> Subview - ) { - self.presentationValidator = presentationValidator - self.subviewProvider = subviewProvider - _viewModel = .init(wrappedValue: .init()) - } - - func body(content: Content) -> some View { - content - .sheet(isPresented: $viewModel.isPresentingSubview) { - subviewProvider(viewModel.lastCall) { - viewModel.lastCall = nil - viewModel.isPresentingSubview = false - } - } - .onReceive(viewModel.$activeCall) { call in - log - .debug( - "CallEnded view modifier received newValue:\(call?.cId ?? "nil") oldValue:\(viewModel.lastCall?.cId ?? "nil") isPresentingSubview:\(viewModel.isPresentingSubview) maxParticipantsCount:\(viewModel.maxParticipantsCount)." - ) - - switch (call, viewModel.lastCall, viewModel.isPresentingSubview) { - case (nil, let activeCall, false) - where activeCall != nil && viewModel - .maxParticipantsCount > 1 && presentationValidator(viewModel.lastCall): - /// The following presentation criteria are required: - /// - The activeCall was ended. - /// - Participants, during call's duration, grew to more than one. - viewModel.isPresentingSubview = true - - case let (newActiveCall, activeCall, _) where newActiveCall != nil && activeCall != nil: - /// The activeCall was replaced with another call. We should not present the - /// subview. We will also hide any modals if any is visible. - viewModel.lastCall = newActiveCall - viewModel.isPresentingSubview = false - viewModel.maxParticipantsCount = 0 - - case (let newActiveCall, nil, _) where newActiveCall != nil: - /// The activeCall was replaced with another call. We should not present the - /// subview. We will also hide any modals if any is visible. - viewModel.lastCall = newActiveCall - viewModel.isPresentingSubview = false - viewModel.maxParticipantsCount = 0 - - default: - /// For every other case we won't perform any action. - break - } - } - .onReceive(viewModel.activeCall?.state.$participants) { - /// Every time participants update, we store the maximum number of participants in - /// the call (during call's duration). - let newMaxParticipantsCount = max(viewModel.maxParticipantsCount, $0.count) - if newMaxParticipantsCount != viewModel.maxParticipantsCount { - log - .debug( - "CallEnded view modifier updated maxParticipantsCount:\(viewModel.maxParticipantsCount) → \(newMaxParticipantsCount)" - ) - viewModel.maxParticipantsCount = newMaxParticipantsCount - } - } - } -} - extension View { /// A viewModifier that observes callState from StreamVideo. Once the following criteria are being @@ -224,20 +150,11 @@ extension View { presentationValidator: @escaping (Call?) -> Bool = { _ in true }, @ViewBuilder _ content: @escaping (Call?, @escaping () -> Void) -> some View ) -> some View { - if #available(iOS 14.0, *) { - modifier( - CallEndedViewModifier( - presentationValidator: presentationValidator, - subviewProvider: content - ) - ) - } else { - modifier( - CallEndedViewModifier_iOS13( - presentationValidator: presentationValidator, - subviewProvider: content - ) + modifier( + CallEndedViewModifier( + presentationValidator: presentationValidator, + subviewProvider: content ) - } + ) } } diff --git a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift deleted file mode 100644 index 4f8fccc92..000000000 --- a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import SwiftUI - -/// A property wrapper type that instantiates an observable object. -@MainActor -@propertyWrapper @available(iOS, introduced: 13, obsoleted: 14) -public final class BackportStateObject: DynamicProperty, @unchecked Sendable - where ObjectType.ObjectWillChangePublisher == ObservableObjectPublisher { - - /// Wrapper that helps with initialising without actually having an ObservableObject yet - private class ObservedObjectWrapper: ObservableObject, @unchecked Sendable { - @PublishedObject var wrappedObject: ObjectType? = nil - init() {} - } - - private var thunk: () -> ObjectType - @ObservedObject private var observedObject = ObservedObjectWrapper() - @State private var state = ObservedObjectWrapper() - - public var wrappedValue: ObjectType { - if state.wrappedObject == nil { - // There is no State yet so we need to initialise the object - state.wrappedObject = thunk() - // and start observing it - observedObject.wrappedObject = state.wrappedObject - } else if observedObject.wrappedObject == nil { - // Retrieve the object from State and observe it in ObservedObject - observedObject.wrappedObject = state.wrappedObject - } - return state.wrappedObject! - } - - public var projectedValue: ObservedObject.Wrapper { - ObservedObject(wrappedValue: wrappedValue).projectedValue - } - - public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) { - self.thunk = thunk - } - - public nonisolated func update() { - Task { @MainActor in - // Not sure what this does but we'll just forward it - _state.update() - _observedObject.update() - } - } -} - -/// Just like @Published this sends willSet events to the enclosing ObservableObject's ObjectWillChangePublisher -/// but unlike @Published it also sends the wrapped value's published changes on to the enclosing ObservableObject -@propertyWrapper @available(iOS, introduced: 13, obsoleted: 14) -public struct PublishedObject { - - public init(wrappedValue: Value) where Value: ObservableObject & Sendable, - Value.ObjectWillChangePublisher == ObservableObjectPublisher { - self.wrappedValue = wrappedValue - cancellable = nil - _startListening = { futureSelf, wrappedValue in - let publisher = futureSelf._projectedValue - let parent = futureSelf.parent - futureSelf.cancellable = wrappedValue.objectWillChange.sink { [parent] in - parent.objectWillChange?() - DispatchQueue.main.async { - publisher.send(wrappedValue) - } - } - publisher.send(wrappedValue) - } - startListening(to: wrappedValue) - } - - public init(wrappedValue: V?) where V? == Value, V: ObservableObject & Sendable, - V.ObjectWillChangePublisher == ObservableObjectPublisher { - self.wrappedValue = wrappedValue - cancellable = nil - _startListening = { futureSelf, wrappedValue in - let publisher = futureSelf._projectedValue - let parent = futureSelf.parent - futureSelf.cancellable = wrappedValue?.objectWillChange.sink { [parent] in - parent.objectWillChange?() - DispatchQueue.main.async { - publisher.send(wrappedValue) - } - } - publisher.send(wrappedValue) - } - startListening(to: wrappedValue) - } - - public var wrappedValue: Value { - willSet { parent.objectWillChange?() } - didSet { startListening(to: wrappedValue) } - } - - public static subscript( - _enclosingInstance observed: EnclosingSelf, - wrapped wrappedKeyPath: ReferenceWritableKeyPath, - storage storageKeyPath: ReferenceWritableKeyPath - ) -> Value where EnclosingSelf.ObjectWillChangePublisher == ObservableObjectPublisher { - get { - observed[keyPath: storageKeyPath].setParent(observed) - return observed[keyPath: storageKeyPath].wrappedValue - } - set { - observed[keyPath: storageKeyPath].setParent(observed) - observed[keyPath: storageKeyPath].wrappedValue = newValue - } - } - - public static subscript( - _enclosingInstance observed: EnclosingSelf, - projected wrappedKeyPath: KeyPath, - storage storageKeyPath: ReferenceWritableKeyPath - ) -> Publisher where EnclosingSelf.ObjectWillChangePublisher == ObservableObjectPublisher { - observed[keyPath: storageKeyPath].setParent(observed) - return observed[keyPath: storageKeyPath].projectedValue - } - - private let parent = Holder() - private var cancellable: AnyCancellable? - private class Holder { - var objectWillChange: (() -> Void)? - init() {} - } - - private func setParent(_ parentObject: Parent) - where Parent.ObjectWillChangePublisher == ObservableObjectPublisher { - guard parent.objectWillChange == nil else { return } - parent.objectWillChange = { [weak parentObject] in - Task { @MainActor [weak parentObject] in - try? await Task.sleep(nanoseconds: 10_000_000) - parentObject?.objectWillChange.send() - } - } - } - - private var _startListening: (inout Self, _ toValue: Value) -> Void - private mutating func startListening(to wrappedValue: Value) { - _startListening(&self, wrappedValue) - } - - public typealias Publisher = AnyPublisher - - private lazy var _projectedValue = CurrentValueSubject(wrappedValue) - public var projectedValue: Publisher { - mutating get { _projectedValue.eraseToAnyPublisher() } - } -} diff --git a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift deleted file mode 100644 index 32551fb1a..000000000 --- a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import StreamVideo -import SwiftUI - -@available(iOS, introduced: 13, obsoleted: 14) -public struct IncomingCallView_iOS13: View { - @Injected(\.streamVideo) var streamVideo - @Injected(\.fonts) var fonts - @Injected(\.colors) var colors - @Injected(\.images) var images - @Injected(\.utils) var utils - - var viewFactory: Factory - @BackportStateObject var viewModel: IncomingViewModel - - var onCallAccepted: (String) -> Void - var onCallRejected: (String) -> Void - - public init( - viewFactory: Factory = DefaultViewFactory.shared, - callInfo: IncomingCall, - onCallAccepted: @escaping (String) -> Void, - onCallRejected: @escaping (String) -> Void - ) { - _viewModel = BackportStateObject( - wrappedValue: IncomingViewModel(callInfo: callInfo) - ) - self.viewFactory = viewFactory - self.onCallAccepted = onCallAccepted - self.onCallRejected = onCallRejected - } - - public var body: some View { - IncomingCallViewContent( - viewFactory: viewFactory, - callParticipants: viewModel.callParticipants, - callInfo: viewModel.callInfo, - onCallAccepted: onCallAccepted, - onCallRejected: onCallRejected - ) - } -} diff --git a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift deleted file mode 100644 index 7a3b0ae1c..000000000 --- a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import StreamVideo -import SwiftUI - -@available(iOS, introduced: 13, obsoleted: 14) -public struct LobbyView_iOS13: View { - - @ObservedObject var callViewModel: CallViewModel - @BackportStateObject var viewModel: LobbyViewModel - @BackportStateObject var microphoneChecker: MicrophoneChecker - - var viewFactory: Factory - var callId: String - var callType: String - @Binding var callSettings: CallSettings - var onJoinCallTap: () -> Void - var onCloseLobby: () -> Void - - public init( - viewFactory: Factory = DefaultViewFactory.shared, - callViewModel: CallViewModel, - callId: String, - callType: String, - callSettings: Binding, - onJoinCallTap: @escaping () -> Void, - onCloseLobby: @escaping () -> Void - ) { - _callViewModel = ObservedObject(wrappedValue: callViewModel) - _viewModel = BackportStateObject( - wrappedValue: LobbyViewModel( - callType: callType, - callId: callId - ) - ) - let microphoneCheckerInstance = MicrophoneChecker() - _microphoneChecker = BackportStateObject(wrappedValue: microphoneCheckerInstance) - _callSettings = callSettings - self.viewFactory = viewFactory - self.callId = callId - self.callType = callType - self.onJoinCallTap = onJoinCallTap - self.onCloseLobby = onCloseLobby - } - - public var body: some View { - LobbyContentView( - viewModel: viewModel, - microphoneChecker: microphoneChecker, - viewFactory: viewFactory, - callId: callId, - callType: callType, - callSettings: $callSettings, - onJoinCallTap: onJoinCallTap, - onCloseLobby: onCloseLobby - ) - .onChange(of: callSettings) { viewModel.didUpdate(callSettings: $0) } - } -} diff --git a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift b/Sources/StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift deleted file mode 100644 index 84ef740fb..000000000 --- a/Sources/StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import StreamVideo -import SwiftUI - -@available(iOS, introduced: 13, obsoleted: 14) -public struct CallContainer_iOS13: View { - - @Injected(\.utils) var utils - - var viewFactory: Factory - @BackportStateObject var viewModel: CallViewModel - - private let padding: CGFloat = 16 - - public init( - viewFactory: Factory = DefaultViewFactory.shared, - viewModel: CallViewModel - ) { - self.viewFactory = viewFactory - _viewModel = BackportStateObject(wrappedValue: viewModel) - } - - public var body: some View { - Group { - if shouldShowCallView { - if viewModel.callParticipants.count > 1 { - if viewModel.isMinimized { - viewFactory.makeMinimizedCallView(viewModel: viewModel) - } else { - viewFactory.makeCallView(viewModel: viewModel) - } - } else { - viewFactory.makeWaitingLocalUserView(viewModel: viewModel) - } - } else if viewModel.callingState == .reconnecting { - viewFactory.makeReconnectionView(viewModel: viewModel) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .toastView(toast: $viewModel.toast) - .overlay(overlayView) - .onReceive(viewModel.$callingState) { _ in - if viewModel.callingState == .idle || viewModel.callingState == .inCall { - utils.callSoundsPlayer.stopOngoingSound() - } - } - } - - @ViewBuilder - private var overlayView: some View { - if case let .incoming(callInfo) = viewModel.callingState { - viewFactory.makeIncomingCallView(viewModel: viewModel, callInfo: callInfo) - } else if viewModel.callingState == .outgoing { - viewFactory.makeOutgoingCallView(viewModel: viewModel) - } else if viewModel.callingState == .joining { - viewFactory.makeJoiningCallView(viewModel: viewModel) - } else if case let .lobby(lobbyInfo) = viewModel.callingState { - viewFactory.makeLobbyView( - viewModel: viewModel, - lobbyInfo: lobbyInfo, - callSettings: $viewModel.callSettings - ) - } else { - EmptyView() - } - } - - private var shouldShowCallView: Bool { - switch viewModel.callingState { - case .outgoing, .incoming(_), .inCall, .joining, .lobby: - return true - default: - return false - } - } -} diff --git a/Sources/StreamVideoSwiftUI/Utils/CallSoundsPlayer.swift b/Sources/StreamVideoSwiftUI/Utils/CallSoundsPlayer.swift index bd1a0791d..38fb93917 100644 --- a/Sources/StreamVideoSwiftUI/Utils/CallSoundsPlayer.swift +++ b/Sources/StreamVideoSwiftUI/Utils/CallSoundsPlayer.swift @@ -11,7 +11,8 @@ open class CallSoundsPlayer { @Injected(\.sounds) private var sounds - private var audioPlayer: AVAudioPlayer? + private let playback = CallSoundsPlayerPlayback() + private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) public init() {} @@ -27,8 +28,10 @@ open class CallSoundsPlayer { /// Stops playing the ongoing sound. open func stopOngoingSound() { - audioPlayer?.stop() - audioPlayer = nil + let playback = playback + processingQueue.addTaskOperation { + await playback.stop() + } } // MARK: - private @@ -39,8 +42,31 @@ open class CallSoundsPlayer { log.warning("There's no sound available") return } - audioPlayer = try? AVAudioPlayer(contentsOf: soundURL) - audioPlayer?.numberOfLoops = 10 - audioPlayer?.play() + + let playback = playback + processingQueue.addTaskOperation { + await playback.play(soundURL) + } + } +} + +private actor CallSoundsPlayerPlayback { + private var audioPlayer: AVAudioPlayer? + + func stop() { + audioPlayer?.stop() + audioPlayer = nil + } + + func play(_ soundURL: URL) { + audioPlayer?.stop() + + guard let audioPlayer = try? AVAudioPlayer(contentsOf: soundURL) else { + return + } + + audioPlayer.numberOfLoops = 10 + self.audioPlayer = audioPlayer + audioPlayer.play() } } diff --git a/Sources/StreamVideoSwiftUI/ViewFactory.swift b/Sources/StreamVideoSwiftUI/ViewFactory.swift index c979f5e36..286cd297a 100644 --- a/Sources/StreamVideoSwiftUI/ViewFactory.swift +++ b/Sources/StreamVideoSwiftUI/ViewFactory.swift @@ -214,29 +214,16 @@ extension ViewFactory { } public func makeIncomingCallView(viewModel: CallViewModel, callInfo: IncomingCall) -> some View { - if #available(iOS 14.0, *) { - return IncomingCallView( - viewFactory: self, - callInfo: callInfo, - onCallAccepted: { _ in - viewModel.acceptCall(callType: callInfo.type, callId: callInfo.id) - }, - onCallRejected: { _ in - viewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) - } - ) - } else { - return IncomingCallView_iOS13( - viewFactory: self, - callInfo: callInfo, - onCallAccepted: { _ in - viewModel.acceptCall(callType: callInfo.type, callId: callInfo.id) - }, - onCallRejected: { _ in - viewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) - } - ) - } + IncomingCallView( + viewFactory: self, + callInfo: callInfo, + onCallAccepted: { _ in + viewModel.acceptCall(callType: callInfo.type, callId: callInfo.id) + }, + onCallRejected: { _ in + viewModel.rejectCall(callType: callInfo.type, callId: callInfo.id) + } + ) } public func makeWaitingLocalUserView(viewModel: CallViewModel) -> some View { @@ -346,26 +333,14 @@ extension ViewFactory { let handleCloseLobby = { viewModel.setCallingState(.idle) } - if #available(iOS 14.0, *) { - return LobbyView( - viewFactory: self, - callId: lobbyInfo.callId, - callType: lobbyInfo.callType, - callSettings: callSettings, - onJoinCallTap: handleJoinCall, - onCloseLobby: handleCloseLobby - ) - } else { - return LobbyView_iOS13( - viewFactory: self, - callViewModel: viewModel, - callId: lobbyInfo.callId, - callType: lobbyInfo.callType, - callSettings: callSettings, - onJoinCallTap: handleJoinCall, - onCloseLobby: handleCloseLobby - ) - } + return LobbyView( + viewFactory: self, + callId: lobbyInfo.callId, + callType: lobbyInfo.callType, + callSettings: callSettings, + onJoinCallTap: handleJoinCall, + onCloseLobby: handleCloseLobby + ) } public func makeReconnectionView(viewModel: CallViewModel) -> some View { @@ -377,21 +352,12 @@ extension ViewFactory { callSettings: Binding, call: Call? ) -> some ViewModifier { - if #available(iOS 14.0, *) { - return LocalParticipantViewModifier( - localParticipant: localParticipant, - call: call, - callSettings: callSettings, - showAllInfo: true - ) - } else { - return LocalParticipantViewModifier_iOS13( - localParticipant: localParticipant, - call: call, - callSettings: callSettings, - showAllInfo: true - ) - } + LocalParticipantViewModifier( + localParticipant: localParticipant, + call: call, + callSettings: callSettings, + showAllInfo: true + ) } public func makeUserAvatar( diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index fd8d61f61..25732f9be 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -549,10 +549,6 @@ StreamVideoSwiftUI/CallingViews/CallingParticipantView.swift, StreamVideoSwiftUI/CallingViews/IncomingCallView.swift, StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift, - StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift, - StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift, - StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift, - StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift, StreamVideoSwiftUI/CallingViews/JoiningCallView.swift, StreamVideoSwiftUI/CallingViews/LobbyViewModel.swift, StreamVideoSwiftUI/CallingViews/MicrophoneChecker.swift, @@ -718,10 +714,6 @@ StreamVideoSwiftUI/CallingViews/CallingParticipantView.swift, StreamVideoSwiftUI/CallingViews/IncomingCallView.swift, StreamVideoSwiftUI/CallingViews/IncomingCallViewModel.swift, - StreamVideoSwiftUI/CallingViews/iOS13/BackportStateObject.swift, - StreamVideoSwiftUI/CallingViews/iOS13/IncomingCallView_iOS13.swift, - StreamVideoSwiftUI/CallingViews/iOS13/PreJoiningView_iOS13.swift, - StreamVideoSwiftUI/CallingViews/iOS13/VideoView_iOS13.swift, StreamVideoSwiftUI/CallingViews/JoiningCallView.swift, StreamVideoSwiftUI/CallingViews/LobbyViewModel.swift, StreamVideoSwiftUI/CallingViews/MicrophoneChecker.swift, @@ -1892,7 +1884,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1923,7 +1915,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideo/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -1961,7 +1953,7 @@ "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamVideoTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1990,7 +1982,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoSwiftUI/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2050,7 +2042,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoUIKit/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2120,7 +2112,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2171,7 +2163,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2203,7 +2195,7 @@ DEVELOPMENT_TEAM = EHV7XZLAHA; GCC_OPTIMIZATION_LEVEL = s; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.SwiftUIDemoAppUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2229,7 +2221,7 @@ INFOPLIST_FILE = "$(SRCROOT)/CallIntent/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = CallIntent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2260,7 +2252,7 @@ INFOPLIST_FILE = "$(SRCROOT)/ScreenSharing/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = ScreenSharing; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2290,7 +2282,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.SwiftUIDemoAppUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2310,7 +2302,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.SwiftUIDemoAppUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2334,7 +2326,7 @@ INFOPLIST_FILE = "$(SRCROOT)/CallIntent/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = CallIntent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2367,7 +2359,7 @@ INFOPLIST_FILE = "$(SRCROOT)/CallIntent/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = CallIntent; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2439,7 +2431,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2500,7 +2492,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2540,7 +2532,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2589,7 +2581,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2626,7 +2618,7 @@ INFOPLIST_FILE = "$(SRCROOT)/ScreenSharing/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = ScreenSharing; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2663,7 +2655,7 @@ INFOPLIST_FILE = "$(SRCROOT)/ScreenSharing/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = ScreenSharing; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2711,7 +2703,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2762,7 +2754,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2803,7 +2795,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideo/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2847,7 +2839,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideo/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2877,7 +2869,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamVideoTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2896,7 +2888,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamVideoTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2925,7 +2917,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoSwiftUI/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2967,7 +2959,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoSwiftUI/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -3045,7 +3037,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoUIKit/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( @@ -3087,7 +3079,7 @@ INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamVideoUIKit/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_GENERATE_MAP_FILE = YES; LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme b/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme index 9980fe7ed..7ae92bfdb 100644 --- a/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme +++ b/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme @@ -118,6 +118,9 @@ isEnabled = "YES"> + + + + + + Void + )? + + override func reportIncomingCall( + _ cid: String, + localizedCallerName: String, + callerId: String, + hasVideo: Bool = false, + completion: @Sendable @escaping (Error?) -> Void + ) { + reportIncomingCallWasCalled = (cid, localizedCallerName, callerId, hasVideo, completion) + } +} +#endif + private final class MockPKPushPayload: PKPushPayload { var stubType: PKPushType = .voIP diff --git a/StreamVideoTests/CallKit/LiveCommunicationKitServiceTests.swift b/StreamVideoTests/CallKit/LiveCommunicationKitServiceTests.swift new file mode 100644 index 000000000..db9ee816d --- /dev/null +++ b/StreamVideoTests/CallKit/LiveCommunicationKitServiceTests.swift @@ -0,0 +1,113 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +#if canImport(LiveCommunicationKit) +import Combine +import Foundation +import LiveCommunicationKit +@testable import StreamVideo +@preconcurrency import XCTest + +@available(iOS 27.0, *) +final class LiveCommunicationKitServiceTests: XCTestCase, @unchecked Sendable { + + private var subject: LiveCommunicationKitService! + private var cancellables: Set! + + override func setUp() { + super.setUp() + subject = .init() + cancellables = [] + } + + override func tearDown() { + cancellables = nil + subject = nil + super.tearDown() + } + + func test_participantAutoLeavePolicy_hasExpectedDefaultValue() { + XCTAssertTrue(subject.participantAutoLeavePolicy is LastParticipantAutoLeavePolicy) + } + + func test_callIdentifierProperties_whenNoCallIsActive_areEmpty() { + XCTAssertEqual(subject.callId, "") + XCTAssertEqual(subject.callType, "") + XCTAssertEqual(subject.callCount, 0) + } + + func test_eventPipeline_whenInitialized_emitsIdle() { + var receivedEvents: [CallKitService.Event] = [] + + subject.eventPipeline + .sink { receivedEvents.append($0) } + .store(in: &cancellables) + + XCTAssertEqual(receivedEvents.count, 1) + guard case .idle = receivedEvents.first else { + return XCTFail("Expected the initial event to be idle.") + } + } + + func test_conversationManager_whenBuilt_usesServiceConfiguration() throws { + let expectedIconData = try XCTUnwrap("icon".data(using: .utf8)) + subject.iconTemplateImageData = expectedIconData + subject.ringtoneSound = "ring.caf" + subject.supportsVideo = true + subject.includesCallsInRecents = false + + let manager = subject.conversationManager + let configuration = manager.configuration + + XCTAssertEqual(configuration.iconTemplateImageData, expectedIconData) + XCTAssertEqual(configuration.ringtoneName, "ring.caf") + XCTAssertEqual(configuration.maximumConversationGroups, 1) + XCTAssertEqual(configuration.maximumConversationsPerConversationGroup, 1) + XCTAssertFalse(configuration.includesConversationInRecents) + XCTAssertTrue(configuration.supportsVideo) + XCTAssertEqual(configuration.supportedHandleTypes, [.generic]) + XCTAssertTrue(manager.delegate === subject) + } + + func test_checkIfCallWasHandled_whenStreamVideoIsNil_returnsNotConfiguredReason() { + let reason = subject.checkIfCallWasHandled(callState: .dummy()) + + XCTAssertEqual( + reason, + StreamRejectionReasonProvider.HandledCallReason.notConfigured.rawValue + ) + } + + func test_callEntry_whenCallAndUUIDMatch_isEqual() { + let call = Call.dummy(callType: .default, callId: "call-1") + let callUUID = UUID() + + let lhs = LiveCommunicationKitService.CallEntry( + call: call, + callUUID: callUUID + ) + let rhs = LiveCommunicationKitService.CallEntry( + call: call, + callUUID: callUUID + ) + + XCTAssertEqual(lhs, rhs) + } + + func test_callEntry_whenCallUUIDDiffers_isNotEqual() { + let call = Call.dummy(callType: .default, callId: "call-1") + + let lhs = LiveCommunicationKitService.CallEntry( + call: call, + callUUID: UUID() + ) + let rhs = LiveCommunicationKitService.CallEntry( + call: call, + callUUID: UUID() + ) + + XCTAssertNotEqual(lhs, rhs) + } +} +#endif diff --git a/StreamVideoTests/Mock/VideoConfig+Dummy.swift b/StreamVideoTests/Mock/VideoConfig+Dummy.swift index 02f7b55ec..f9ce0a644 100644 --- a/StreamVideoTests/Mock/VideoConfig+Dummy.swift +++ b/StreamVideoTests/Mock/VideoConfig+Dummy.swift @@ -8,9 +8,13 @@ import StreamWebRTC extension VideoConfig { static func dummy( - audioProcessingModule: AudioProcessingModule = MockAudioProcessingModule.shared + audioProcessingModule: AudioProcessingModule = MockAudioProcessingModule.shared, + useLiveCommunicationKit: Bool = true ) -> VideoConfig { - .init(audioProcessingModule: audioProcessingModule) + .init( + audioProcessingModule: audioProcessingModule, + useLiveCommunicationKit: useLiveCommunicationKit + ) } } diff --git a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule_Tests.swift b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule_Tests.swift index d6288da9b..3b3293289 100644 --- a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule_Tests.swift +++ b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioDeviceModule_Tests.swift @@ -408,6 +408,26 @@ final class AudioDeviceModule_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(payload?.1, 1024) } + func test_configureInputFromSource_whenFormatIsInvalid_skipsTapAndUninstallsExistingTap() { + makeSubject() + let result = subject.audioDeviceModule( + .init(), + engine: AVAudioEngine(), + configureInputFromSource: nil, + toDestination: AVAudioMixerNode(), + format: makeInvalidAudioFormat(), + context: [:] + ) + + XCTAssertEqual(result, AudioDeviceModule.Constant.successResult) + XCTAssertEqual(audioEngineNodeAdapter.timesCalled(.installInputTap), 0) + XCTAssertEqual(audioEngineNodeAdapter.timesCalled(.uninstall), 1) + XCTAssertEqual( + audioEngineNodeAdapter.recordedInputPayload(Int.self, for: .uninstall)?.first, + 0 + ) + } + func test_configureInputFromSource_emitsEvent() async { makeSubject() let engine = AVAudioEngine() @@ -507,16 +527,26 @@ final class AudioDeviceModule_Tests: XCTestCase, @unchecked Sendable { return module } + private func makeInvalidAudioFormat() -> AVAudioFormat { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 0, + channels: 0, + interleaved: false + )! + } + private func expectEvent( _ expectedEvent: AudioDeviceModule.Event, isPlayoutEnabled: Bool? = nil, isRecordingEnabled: Bool? = nil, operation: (RTCAudioDeviceModule) -> Void, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async { guard subject != nil else { - XCTFail("Subject not initialized", file: file, line: line) + let failureFile = file + XCTFail("Subject not initialized", file: failureFile, line: line) return } @@ -549,7 +579,8 @@ final class AudioDeviceModule_Tests: XCTestCase, @unchecked Sendable { } operation(.init()) - await safeFulfillment(of: expectations, file: file, line: line) + let fulfillmentFile = file + await safeFulfillment(of: expectations, file: fulfillmentFile, line: line) cancellables.removeAll() } } diff --git a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter_Tests.swift b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter_Tests.swift index 97b9963a6..efbb9e8f7 100644 --- a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter_Tests.swift +++ b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/AudioEngineLevelNodeAdapter_Tests.swift @@ -52,6 +52,18 @@ final class AudioEngineLevelNodeAdapter_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(mixer.installTapCount, 1) } + func test_installInputTap_whenFormatIsInvalid_skipsInstallAndAllowsLaterValidInstall() { + let mixer = TestMixerNode() + let invalidFormat = makeInvalidAudioFormat() + let validFormat = makeAudioFormat() + + sut.installInputTap(on: mixer, format: invalidFormat) + sut.installInputTap(on: mixer, format: validFormat) + + XCTAssertEqual(mixer.installTapCount, 1) + XCTAssertTrue(mixer.capturedFormat === validFormat) + } + func test_installInputTap_whenTapReceivesSamples_publishesDecibelValue() { let mixer = TestMixerNode() let format = makeAudioFormat() @@ -101,9 +113,18 @@ final class AudioEngineLevelNodeAdapter_Tests: XCTestCase, @unchecked Sendable { private func makeAudioFormat() -> AVAudioFormat { AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 1, interleaved: false)! } + + private func makeInvalidAudioFormat() -> AVAudioFormat { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 0, + channels: 0, + interleaved: false + )! + } } -private final class TestMixerNode: AVAudioMixerNode { +private final class TestMixerNode: AVAudioMixerNode, @unchecked Sendable { private(set) var installTapCount = 0 private(set) var removeTapCount = 0 diff --git a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer_Tests.swift b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer_Tests.swift index 20da888ca..9ac860627 100644 --- a/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer_Tests.swift +++ b/StreamVideoTests/Utils/AudioSession/AudioDeviceModule/Components/AudioBufferRenderer_Tests.swift @@ -39,6 +39,19 @@ final class AudioBufferRenderer_Tests: XCTestCase, @unchecked Sendable { XCTAssertTrue(storedContext?.destination === destination) } + func test_configure_whenContextFormatIsInvalid_clearsStoredContext() { + let context = AVAudioEngine.InputContext( + engine: AVAudioEngine(), + source: nil, + destination: AVAudioMixerNode(), + format: makeInvalidFormat() + ) + + subject.configure(with: context) + + XCTAssertNil(rendererContext(subject)) + } + func test_reset_clearsStoredContext() { let engine = AVAudioEngine() let destination = AVAudioMixerNode() @@ -66,6 +79,15 @@ final class AudioBufferRenderer_Tests: XCTestCase, @unchecked Sendable { )! } + private func makeInvalidFormat() -> AVAudioFormat { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 0, + channels: 0, + interleaved: false + )! + } + private func rendererContext( _ renderer: AudioBufferRenderer ) -> AVAudioEngine.InputContext? { diff --git a/StreamVideoTests/VideoConfig_Tests.swift b/StreamVideoTests/VideoConfig_Tests.swift new file mode 100644 index 000000000..8bc932111 --- /dev/null +++ b/StreamVideoTests/VideoConfig_Tests.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamVideo +import XCTest + +final class VideoConfig_Tests: XCTestCase, @unchecked Sendable { + + func test_init_whenUseLiveCommunicationKitIsNotProvided_defaultsToTrue() { + let subject = VideoConfig() + + XCTAssertTrue(subject.useLiveCommunicationKit) + } + + func test_init_whenUseLiveCommunicationKitIsFalse_setsValue() { + let subject = VideoConfig(useLiveCommunicationKit: false) + + XCTAssertFalse(subject.useLiveCommunicationKit) + } +} diff --git a/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift index 541c86f5e..bb4caf582 100644 --- a/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift @@ -542,7 +542,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - consume func test_subscriberOfferAndSubscriberICETrickle_bufferedOnSharedPublisher_replayedAcrossSeparateConsumes() async throws { - _ = subject + let subject = try XCTUnwrap(subject) let bucket = ConsumableBucket( subject.publisher.eraseToAnyPublisher() diff --git a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift index 1505cd9c5..a7519cb9a 100644 --- a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift @@ -121,8 +121,12 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { // MARK: publishQualityChanged func test_handleChangePublishQuality_givenEvent_whenPublished_thenUpdatesPublisherQuality() async throws { + let stateAdapter = try XCTUnwrap(stateAdapter) try await stateAdapter.configurePeerConnections() let publisher = await stateAdapter.publisher + let mockPublisher = try XCTUnwrap( + publisher as? MockRTCPeerConnectionCoordinator + ) let participantA = CallParticipant.dummy() let participantB = CallParticipant.dummy() @@ -138,9 +142,8 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { event, wrappedEvent: .sfuEvent(.changePublishQuality(event)), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } - ) { [event] _ in - let mockPublisher = try XCTUnwrap(publisher as? MockRTCPeerConnectionCoordinator) - return mockPublisher + ) { [event, mockPublisher] _ in + mockPublisher .recordedInputPayload(Stream_Video_Sfu_Event_ChangePublishQuality.self, for: .changePublishQuality)? .first == event } diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index 295e85ec4..2c1f95c31 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -309,7 +309,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.set(sfuAdapter: sfuStack.adapter) await subject.enqueueOwnCapabilities { [.sendAudio, .sendVideo, .screenshare] } try await subject.configurePeerConnections() - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() let screenShareSessionProvider = await subject.screenShareSessionProvider screenShareSessionProvider.activeSession = .init( @@ -345,7 +345,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { let sfuStack = MockSFUStack() await subject.set(sfuAdapter: sfuStack.adapter) try await subject.configurePeerConnections() - let publisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let publisher = try await currentMockPublisher() publisher.stubEventSubject.send( StreamRTCPeerConnection.CreateOfferEvent( sessionDescription: .init(type: .offer, sdp: "") @@ -455,7 +455,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.set(videoFilter: expected) - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() XCTAssertEqual( mockPublisher.recordedInputPayload(VideoFilter.self, for: .setVideoFilter)?.first?.id, expected.id @@ -528,8 +528,8 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { try await subject.configurePeerConnections() - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) - let mockSubscriber = try await XCTAsyncUnwrap(await subject.subscriber as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() + let mockSubscriber = try await currentMockSubscriber() await fulfillment { mockPublisher.timesCalled(.setUp) == 1 @@ -600,6 +600,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { } func test_configurePeerConnections_withExistingStatsAdapter_updatesStatsAdapterWithPeerConnections() async throws { + let subject = try XCTUnwrap(subject) let mockStatsAdapter = MockWebRTCStatsAdapter() await subject.set(statsAdapter: mockStatsAdapter) let sfuStack = MockSFUStack() @@ -607,13 +608,14 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { try await subject.configurePeerConnections() - let publisher = try await XCTAsyncUnwrap(await subject.publisher) - let subscriber = try await XCTAsyncUnwrap(await subject.subscriber) + let publisher = try await requiredPublisher() + let subscriber = try await requiredSubscriber() XCTAssertTrue(mockStatsAdapter.publisher === publisher) XCTAssertTrue(mockStatsAdapter.subscriber === subscriber) } func test_configurePeerConnections_withSFU_completesSetUp() async throws { + let subject = try XCTUnwrap(subject) let sfuStack = MockSFUStack() await subject.set(sfuAdapter: sfuStack.adapter) let videoFilter = VideoFilter( @@ -629,12 +631,10 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { try await subject.configurePeerConnections() - await fulfillment { await self.subject.publisher != nil } + await fulfillment { await self.currentPublisher() != nil } - let _publisher = await subject.publisher - let publisher = try XCTUnwrap(_publisher) - let _subscriber = await subject.subscriber - let subscriber = try XCTUnwrap(_subscriber) + let publisher = try await requiredPublisher() + let subscriber = try await requiredSubscriber() _ = await Task(timeoutInSeconds: 1) { try await publisher.ensureSetUpHasBeenCompleted() @@ -660,7 +660,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.enqueueOwnCapabilities { ownCapabilities } try await subject.configurePeerConnections() - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() XCTAssertEqual( mockPublisher.recordedInputPayload( @@ -692,7 +692,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.enqueueOwnCapabilities { ownCapabilities } try await subject.configurePeerConnections() - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() XCTAssertEqual(mockPublisher.timesCalled(.beginScreenSharing), 0) } @@ -769,8 +769,8 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { func test_cleanUp_shouldResetProperties() async throws { let sfuStack = MockSFUStack() try await prepare(sfuStack: sfuStack) - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) - let mockSubscriber = try await XCTAsyncUnwrap(await subject.subscriber as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() + let mockSubscriber = try await currentMockSubscriber() await subject.cleanUp() @@ -778,9 +778,9 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(mockSubscriber.timesCalled(.close), 1) XCTAssertEqual(sfuStack.webSocket.timesCalled(.disconnectAsync), 1) - await fulfillment { await self.subject.publisher == nil } - await assertNilAsync(await subject.publisher) - await assertNilAsync(await subject.subscriber) + await fulfillment { await self.currentPublisher() == nil } + await assertNilAsync(await currentPublisher()) + await assertNilAsync(await currentSubscriber()) await assertNilAsync(await subject.statsAdapter) await assertNilAsync(await subject.sfuAdapter) await assertEqualAsync(await subject.token, "") @@ -839,8 +839,8 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { participants: participants, participantPins: pins ) - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) - let mockSubscriber = try await XCTAsyncUnwrap(await subject.subscriber as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() + let mockSubscriber = try await currentMockSubscriber() let sessionId = await subject.sessionID await subject.didAddTrack( await subject.peerConnectionFactory.mockAudioTrack(), @@ -864,8 +864,8 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(mockPublisher.timesCalled(.close), 0) XCTAssertEqual(mockSubscriber.timesCalled(.close), 0) XCTAssertEqual(sfuStack.webSocket.timesCalled(.disconnectAsync), 0) - await assertNilAsync(await subject.publisher) - await assertNilAsync(await subject.subscriber) + await assertNilAsync(await currentPublisher()) + await assertNilAsync(await currentSubscriber()) await assertNilAsync(await subject.statsAdapter) await assertNilAsync(await subject.sfuAdapter) await assertEqualAsync(await subject.token, "") @@ -1149,8 +1149,8 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init())) await subject.set(sfuAdapter: sfuStack.adapter) try await subject.configurePeerConnections() - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) - let mockSubscriber = try await XCTAsyncUnwrap(await subject.subscriber as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() + let mockSubscriber = try await currentMockSubscriber() let newVideoOptions = VideoOptions( preferredCameraPosition: .back ) @@ -1331,7 +1331,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) await subject.enqueueCallSettings { _ in newCallSettings } - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() await fulfillment { mockPublisher.timesCalled(.didUpdateCallSettings) == 1 @@ -1358,9 +1358,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.enqueueCallSettings { _ in .init(videoOn: true) } - let mockPublisher = try await XCTAsyncUnwrap( - await subject.publisher as? MockRTCPeerConnectionCoordinator - ) + let mockPublisher = try await currentMockPublisher() await fulfillment { mockPublisher.timesCalled(.didUpdateCallSettings) == 1 } @@ -1389,9 +1387,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await subject.enqueueCallSettings { _ in .init(audioOn: true) } - let mockPublisher = try await XCTAsyncUnwrap( - await subject.publisher as? MockRTCPeerConnectionCoordinator - ) + let mockPublisher = try await currentMockPublisher() await fulfillment { mockPublisher.timesCalled(.didUpdateCallSettings) == 1 } @@ -1424,7 +1420,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { audioOn: true ) - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() await fulfillment { mockPublisher.timesCalled(.didUpdateCallSettings) == 2 } @@ -1454,7 +1450,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { videoOn: true ) - let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) + let mockPublisher = try await currentMockPublisher() await fulfillment { mockPublisher.timesCalled(.didUpdateCallSettings) == 2 } @@ -1468,6 +1464,56 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { // MARK: - Private helpers + private func requiredPublisher( + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> RTCPeerConnectionCoordinator { + let publisher = await currentPublisher() + return try XCTUnwrap(publisher, file: file, line: line) + } + + private func currentMockPublisher( + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> MockRTCPeerConnectionCoordinator { + let publisher = await currentPublisher() + return try XCTUnwrap( + publisher as? MockRTCPeerConnectionCoordinator, + file: file, + line: line + ) + } + + private func currentPublisher() async -> RTCPeerConnectionCoordinator? { + guard let subject else { return nil } + return await subject.publisher + } + + private func requiredSubscriber( + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> RTCPeerConnectionCoordinator { + let subscriber = await currentSubscriber() + return try XCTUnwrap(subscriber, file: file, line: line) + } + + private func currentMockSubscriber( + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> MockRTCPeerConnectionCoordinator { + let subscriber = await currentSubscriber() + return try XCTUnwrap( + subscriber as? MockRTCPeerConnectionCoordinator, + file: file, + line: line + ) + } + + private func currentSubscriber() async -> RTCPeerConnectionCoordinator? { + guard let subject else { return nil } + return await subject.subscriber + } + private func assertNilAsync( _ expression: @autoclosure () async throws -> T?, file: StaticString = #filePath,