diff --git a/AGENTS.md b/AGENTS.md index 7de0f53d1..a839f05d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,61 @@ Agents should optimize for media quality, API stability, backwards compatibility - Run both `StreamVideo` and `StreamVideoSwiftUI` tests locally; keep/raise coverage. - Only add tests for .swift files. - Do not test private methods or add test-only hooks to expose them; test through public or internal behavior instead. +- Integration tests in `StreamVideoTests/IntegrationTests/Call_IntegrationTests`: + - Purpose: end-to-end call-path validation using real Stream API responses. + - Layout: + - `Call_IntegrationTests.swift`: scenarios. + - `Components/Call_IntegrationTests+CallFlow.swift`: flow DSL. + - `Components/Call_IntegrationTests+Assertions.swift`: async and eventual assertions. + - `Components/Helpers/*`: auth, client setup, permissions, users, configuration. + - Base flow: + - Keep `private var helpers: Call_IntegrationTests.Helpers! = .init()`. + - Build each scenario from `helpers.callFlow(...)`. + - Chain with `.perform`, `.performWithoutValueOverride`, + `.performWithErrorExpectation`, `.map`, `.tryMap`, `.assert`, + `.assertEventually`, and actor-specific variants. + - `defaultTimeout` is defined in `StreamVideoTests/TestUtils/AssertAsync.swift` and is used + by eventual assertions. + - Assertions: + - Use `.assert` for immediate checks. + - Use `.assertEventually` for event/state propagation and async streams. + - For expected failures, use `performWithErrorExpectation`, + cast through `APIError`, then check `code`/`message`. + - IDs and payloads: + - Use `String.unique` for call IDs, users, call types, and random values. + - Use `helpers.users.knownUser*` only when test logic requires stable identities. + - Permissions: + - Use `helpers.permissions.setMicrophonePermission(...)` and + `setCameraPermission(...)` for permission-gated flow setup. + - Concurrency: + - When testing multi-participant flows, use separate `callFlow` instances and + `withThrowingTaskGroup` to keep participant behavior explicit. + - For `memberIds` that use generated users (`String.unique`), first create each + participant `callFlow` first so their users are initialized before call creation: + - `let user1Flow = try await helpers.callFlow(..., userId: user1)` + - `let user2Flow = try await helpers.callFlow(..., userId: user2)` + - `let callFlowAfterCreate = try await user1Flow.perform { try await $0.call.create(memberIds: [user1, user2]) }` + - Prefer `.perform { ... }` for operations when the returned value should stay in + the chain for downstream assertions; use + `.performWithoutValueOverride` only when the returned value is intentionally + discarded. + - Event streams: + - Prefer `subscribe(for:)` + `.assertEventually` for event assertions. + - For end-to-end teardown coverage, you can also assert `call.streamVideo.state.activeCall == nil` + to confirm the participant instance has left when the call is ended by creator. + - Avoid arbitrary fixed sleeps except when explicitly stabilizing UI/test timing. + - Cleanup: + - Keep `helpers.dismantle()` in async `tearDown`. + - This disconnects clients and waits for call termination/audiostore cleanup. + - Environment/auth: + - `TestsAuthenticationProvider` calls `https://pronto.getstream.io/api/auth/create-token`. + - Default environment is `pronto`. + - Use `environment: "demo"` for livestream and audio room scenarios that are + fixture-backed. + - Execution: + - Target only this suite: + `xcodebuild -project StreamVideo.xcodeproj -scheme StreamVideo -testPlan StreamVideo test -only-testing:StreamVideoTests/Call_IntegrationTests` + - Full suite remains `bundle exec fastlane test`. ## Comments - Use docC for non-private APIs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bad8fd2..4b3368f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [1.44.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.44.0) +_March 16, 2026_ + +### ✅ Added +- Added `WebRTCJoinPolicy` to `Call.join()` so applications can delay join + completion until publisher and subscriber peer connections are ready. + +### 🔄 Changed +- Propagated publish/unpublish failures from local video and screen-share capture + sessions instead of swallowing them after logging. [#1072](https://github.com/GetStream/stream-video-swift/pull/1072) +- The SDK will now end an outgoing call if the app moves to background while ringing. [#1078](https://github.com/GetStream/stream-video-swift/pull/1078) +- `CallViewModel` now waits briefly for peer-connection readiness before an accepted ringing call is surfaced as joined. [#1080](https://github.com/GetStream/stream-video-swift/pull/1080) + +### 🐞 Fixed +- Fix call teardown ordering by posting `callEnded` only after active/ringing cleanup + and keep `CallSession` token values in sync with `StreamVideo` token updates. [#1071](https://github.com/GetStream/stream-video-swift/pull/1071) +- Fix local mediaAdapters not reacting to changed own capabilities. [#1070](https://github.com/GetStream/stream-video-swift/pull/1070) +- Fix label color when presenting. [#1077](https://github.com/GetStream/stream-video-swift/pull/1077) +- Ensure CallKit push token updates and invalidation mutate `deviceToken` on the main actor to avoid Swift concurrency/actor-isolation issues. [#1076](https://github.com/GetStream/stream-video-swift/pull/1076) +- Ensure CallKit joins keep the answer action completion alive until WebRTC has configured the audio device module. [#1081](https://github.com/GetStream/stream-video-swift/pull/1081) +- Update incoming call acceptance to move `CallViewModel` into `.joining` before the call finishes entering, so the joining UI appears immediately. [#1079](https://github.com/GetStream/stream-video-swift/pull/1079) + # [1.43.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.43.0) _February 27, 2026_ @@ -21,6 +43,12 @@ _February 10, 2026_ ### ✅ Added - Added support for raw and individual recording. [#1043](https://github.com/GetStream/stream-video-swift/pull/1043) +### 🔄 Changed +- Join flow now is aligned with the internal SFU connection state. [#1059](https://github.com/GetStream/stream-video-swift/pull/1059) + +### 🐞 Fixed +- Speaker will now be disabled while ringing during the ringAndJoin flow. [#1059](https://github.com/GetStream/stream-video-swift/pull/1059) + # [1.41.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.41.0) _February 02, 2026_ diff --git a/Package.swift b/Package.swift index e2c933d74..e1e68457e 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-protobuf.git", exact: "1.30.0"), - .package(url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "137.0.67") + .package(url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "137.0.71") ], targets: [ .target( diff --git a/README.md b/README.md index 33a1f7fec..a235f8fe9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- StreamVideo + StreamVideo StreamVideoSwiftUI StreamVideoUIKit StreamWebRTC diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 245d1c52d..a2110c0f0 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -16,7 +16,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { private lazy var stateMachine: StateMachine = .init(self) @MainActor - public internal(set) var state = CallState() + public internal(set) var state: CallState /// The call id. public let callId: String @@ -59,6 +59,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { private let disposableBag = DisposableBag() internal let callController: CallController internal let coordinatorClient: DefaultAPIEndpoints + private var outgoingRingingController: OutgoingRingingController? /// This adapter is used to manage closed captions for the /// call. @@ -73,6 +74,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { callController: CallController, callSettings: CallSettings? = nil ) { + self.state = .init(InjectedValues[\.streamVideo].callSession) self.callId = callId self.callType = callType self.coordinatorClient = coordinatorClient @@ -143,6 +145,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { /// - ring: whether the call should ring, `false` by default. /// - notify: whether the participants should be notified about the call. /// - callSettings: optional call settings. + /// - policy: controls when the join request is considered complete. /// - Throws: An error if the call could not be joined. @discardableResult public func join( @@ -150,7 +153,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { options: CreateCallOptions? = nil, ring: Bool = false, notify: Bool = false, - callSettings: CallSettings? = nil + callSettings: CallSettings? = nil, + policy: WebRTCJoinPolicy = .default ) async throws -> JoinCallResponse { /// Determines the source from which the join action was initiated. /// @@ -218,7 +222,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { ring: ring, notify: notify, source: joinSource, - deliverySubject: deliverySubject + deliverySubject: deliverySubject, + policy: policy ) ) ) @@ -262,6 +267,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { ) await state.update(from: response) if ring { + configureOutgoingRingingController() + Task(disposableBag: disposableBag) { @MainActor [weak self] in self?.streamVideo.state.ringingCall = self } @@ -269,7 +276,12 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { return response } - /// Rings the call (sends call notification to members). + /// Rings the call and marks it as `StreamVideo.State.ringingCall`. + /// + /// The call stays in the ringing state until it is accepted, + /// rejected, ended, or joined. If the app moves to the background + /// before the ring completes, the SDK ends the outgoing ringing + /// call automatically. /// - Returns: The call's data. @discardableResult public func ring() async throws -> CallResponse { @@ -294,7 +306,10 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { /// - custom: An optional dictionary of custom data to include in the call request. /// - startsAt: An optional `Date` indicating when the call should start. /// - team: An optional string representing the team for the call. - /// - ring: A boolean indicating whether to ring the call. Default is `false`. + /// - ring: A boolean indicating whether to ring the call. When + /// `true`, the call is exposed through + /// `StreamVideo.State.ringingCall` until it is accepted, + /// rejected, ended, or joined. Default is `false`. /// - notify: A boolean indicating whether to send notifications. Default is `false`. /// - maxDuration: An optional integer representing the maximum duration of the call in seconds. /// - maxParticipants: An optional integer representing the maximum number of participants allowed in the call. @@ -364,15 +379,17 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { ) await state.update(from: response) if ring { - Task(disposableBag: disposableBag) { @MainActor [weak self] in - self?.streamVideo.state.ringingCall = self - } + configureOutgoingRingingController() + await MainActor.run { streamVideo.state.ringingCall = self } } + return response.call } /// Initiates a ring action for the current call. - /// - Parameter request: The `RingCallRequest` containing ring configuration, such as member ids and whether it's a video call. + /// - Parameter request: The `RingCallRequest` containing ring + /// configuration, such as member ids and whether it's a video + /// call. /// - Returns: A `RingCallResponse` with information about the ring operation. /// - Throws: An error if the coordinator request fails or the call cannot be rung. @discardableResult @@ -455,16 +472,21 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { case let .rejecting(input) = currentStage.context.input { return try await input .deliverySubject + .compactMap { $0 } .nextValue(timeout: CallConfiguration.timeout.reject) } else { - let deliverySubject = PassthroughSubject() + let deliverySubject = CurrentValueSubject(nil) + stateMachine.transition( .rejecting( self, input: .rejecting(.init(reason: reason, deliverySubject: deliverySubject)) ) ) - return try await deliverySubject.nextValue(timeout: CallConfiguration.timeout.reject) + + return try await deliverySubject + .compactMap { $0 } + .nextValue(timeout: CallConfiguration.timeout.reject) } } @@ -590,11 +612,10 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { } /// Leave the current call. + /// + /// The cleanup sequence clears active/ringing call references from + /// `StreamVideo` state before emitting `CallNotification.callEnded`. public func leave() { - Task { @MainActor [weak self] in - postNotification(with: CallNotification.callEnded, object: self) - } - disposableBag.removeAll() callController.leave() closedCaptionsAdapter.stop() @@ -603,20 +624,22 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { /// to happen on the call object (e.g. rejoin) will need to fetch a new instance from `StreamVideo` /// client. callCache.remove(for: cId) + outgoingRingingController = nil // Reset the activeAudioFilter setAudioFilter(nil) - Task(disposableBag: disposableBag) { @MainActor [weak self] in - guard let self else { - return - } + let strongSelf = self + let cId = self.cId + Task(disposableBag: disposableBag) { @MainActor [strongSelf, streamVideo, cId] in if streamVideo.state.ringingCall?.cId == cId { streamVideo.state.ringingCall = nil } if streamVideo.state.activeCall?.cId == cId { streamVideo.state.activeCall = nil } + + postNotification(with: CallNotification.callEnded, object: strongSelf) } } @@ -1764,4 +1787,11 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { } } } + + private func configureOutgoingRingingController() { + outgoingRingingController = .init( + streamVideo: streamVideo, + callCiD: cId + ) { [weak self] in try await self?.end() } + } } diff --git a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift index dbb04c723..4034e6bd2 100644 --- a/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift +++ b/Sources/StreamVideo/CallKit/CallKitPushNotificationAdapter.swift @@ -67,7 +67,7 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs #else registry.delegate = nil registry.desiredPushTypes = [] - deviceToken = "" + Task { @MainActor [weak self] in self?.deviceToken = "" } #endif } @@ -77,9 +77,15 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs didUpdate pushCredentials: PKPushCredentials, for type: PKPushType ) { - let deviceToken = pushCredentials.token.map { String(format: "%02x", $0) }.joined() - log.debug("Device token updated to: \(deviceToken)", subsystems: .callKit) - self.deviceToken = deviceToken + let deviceToken = pushCredentials + .token + .map { String(format: "%02x", $0) } + .joined() + + Task { @MainActor [weak self] in + self?.deviceToken = deviceToken + log.debug("Device token updated to: \(deviceToken)", subsystems: .callKit) + } } /// Delegate method called when the push token becomes invalid for VoIP push notifications. @@ -87,8 +93,10 @@ open class CallKitPushNotificationAdapter: NSObject, PKPushRegistryDelegate, Obs _ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType ) { - log.debug("Device token invalidated.", subsystems: .callKit) - deviceToken = "" + Task { @MainActor [weak self] in + self?.deviceToken = "" + log.debug("Device token invalidated.", subsystems: .callKit) + } } /// Delegate method called when the device receives a VoIP push notification. diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index c1e4ee379..1e04d566a 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -468,22 +468,33 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { do { try await callToJoinEntry.call.accept() + // We need to fulfil here the action to allow CallKit to release + // the audioSession to the app, for activation. + action.fulfill() } catch { log.error(error, subsystems: .callKit) + action.fail() } do { - /// Mark join source as `.callKit` for audio session. - /// - callToJoinEntry.call.state.joinSource = .callKit - + // Pass a CallKit completion hook through the join flow so the + // WebRTC layer can release CallKit's audio session ownership as + // soon as it has configured the audio device module. + callToJoinEntry.call.state.joinSource = .callKit(.init { + // Allow CallKit to hand audio session activation back to + // the app before we continue configuring audio locally. + action.fulfill() + }) + + // Before reach here we should fulfil the action to allow + // CallKit to release the audioSession, so that the SDK + // can configure and activate it and complete successfully the + // peerConnection configuration. try await callToJoinEntry.call.join(callSettings: callSettings) - action.fulfill() } catch { callToJoinEntry.call.leave() set(nil, for: action.callUUID) log.error(error, subsystems: .callKit) - action.fail() } } } diff --git a/Sources/StreamVideo/CallState.swift b/Sources/StreamVideo/CallState.swift index f053ae1a9..7c29a7c1f 100644 --- a/Sources/StreamVideo/CallState.swift +++ b/Sources/StreamVideo/CallState.swift @@ -5,34 +5,13 @@ import Combine import Foundation -public struct PermissionRequest: @unchecked Sendable, Identifiable { - public let id: UUID = .init() - public let permission: String - public let user: User - public let requestedAt: Date - let onReject: (PermissionRequest) -> Void - - public init( - permission: String, - user: User, - requestedAt: Date, - onReject: @escaping (PermissionRequest) -> Void = { _ in } - ) { - self.permission = permission - self.user = user - self.requestedAt = requestedAt - self.onReject = onReject - } - - public func reject() { - onReject(self) - } -} - @MainActor public class CallState: ObservableObject { - @Injected(\.streamVideo) var streamVideo + /// Captured `StreamVideo` session details used for call-state operations. + /// The call user is captured for stability, while token changes are kept in + /// sync via the session token publisher. + private let streamVideoSession: StreamVideo.CallSession /// The id of the current session. /// When a call is started, a unique session identifier is assigned to the user in the call. @@ -166,14 +145,20 @@ public class CallState: ObservableObject { /// help customize logic, analytics, and UI based on how the call was started. var joinSource: JoinSource? - private var localCallSettingsUpdate = false private var durationCancellable: AnyCancellable? private nonisolated let disposableBag = DisposableBag() - /// We mark this one as `nonisolated` to allow us to initialise a state instance without isolation. - /// That's a safe operation because `MainActor` is only required to ensure that all `@Published` - /// properties, will publish changes on the main thread. - nonisolated init() {} + /// We mark this one as `nonisolated` to allow us to initialise a state + /// instance without isolation. That's a safe operation because `MainActor` + /// is only required to ensure that all `@Published` properties publish on + /// the main thread. + /// - Parameter callSession: Cached session context containing user and token values + /// used by permission and stream-key updates. + nonisolated init( + _ callSession: StreamVideo.CallSession + ) { + self.streamVideoSession = callSession + } internal func updateState(from event: VideoEvent) { switch event { @@ -447,23 +432,18 @@ public class CallState: ObservableObject { let rtmp = RTMP( address: response.ingress.rtmp.address, - streamKey: streamVideo.token.rawValue + streamKey: streamVideoSession.token.rawValue ) ingress = Ingress(rtmp: rtmp) - - if !localCallSettingsUpdate { - // All CallSettings updates go through the update method to ensure - // proper propagation. - update(callSettings: .init(response.settings)) - } } + /// Updates the current `CallSettings` if they differ from the stored value. + /// - Parameter callSettings: The new `CallSettings` value to apply. internal func update(callSettings: CallSettings) { guard callSettings != self.callSettings else { return } self.callSettings = callSettings - localCallSettingsUpdate = true } internal func update(statsReport: CallStatsReport?) { @@ -476,7 +456,7 @@ public class CallState: ObservableObject { private func updateOwnCapabilities(_ event: UpdatedCallPermissionsEvent) { guard - event.user.id == streamVideo.user.id + event.user.id == streamVideoSession.user.id else { return } diff --git a/Sources/StreamVideo/CallStateMachine/Stages/Call+JoiningStage.swift b/Sources/StreamVideo/CallStateMachine/Stages/Call+JoiningStage.swift index c2e815e00..e8938043e 100644 --- a/Sources/StreamVideo/CallStateMachine/Stages/Call+JoiningStage.swift +++ b/Sources/StreamVideo/CallStateMachine/Stages/Call+JoiningStage.swift @@ -106,11 +106,13 @@ extension Call.StateMachine.Stage { } /// Executes the join call operation with retry logic. + /// The call result is returned both as a stage-local response and through the + /// shared `join` completion channel. /// /// - Parameters: - /// - call: The call to join - /// - input: The join parameters - /// - Returns: The join call response + /// - call: The call to join. + /// - input: The join parameters. + /// - Returns: The join call response. private func executeJoin( call: Call, input: Context.JoinInput @@ -123,15 +125,10 @@ extension Call.StateMachine.Stage { options: input.options, ring: input.ring, notify: input.notify, - source: input.source + source: input.source, + policy: input.policy ) - if let callSettings = input.callSettings { - try Task.checkCancellation() - - await call.state.update(callSettings: callSettings) - } - try Task.checkCancellation() await call.state.update(from: response) diff --git a/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift b/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift index f3d844b82..1aa4cd982 100644 --- a/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift +++ b/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift @@ -32,6 +32,7 @@ extension Call.StateMachine { var notify: Bool var source: JoinSource var deliverySubject: CurrentValueSubject + var policy: WebRTCJoinPolicy = .default var currentNumberOfRetries = 0 var retryPolicy: RetryPolicy = .fastAndSimple @@ -39,7 +40,7 @@ extension Call.StateMachine { struct RejectingInput { var reason: String? - var deliverySubject: PassthroughSubject + var deliverySubject: CurrentValueSubject } weak var call: Call? diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index f07b996fe..ac0371040 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -57,7 +57,6 @@ class CallController: @unchecked Sendable { private var cachedLocation: String? private let initialCallSettings: CallSettings - private var joinCallResponseSubject = CurrentValueSubject(nil) private var joinCallResponseFetchObserver: AnyCancellable? private var webRTCClientSessionIDObserver: AnyCancellable? private var webRTCClientStateObserver: AnyCancellable? @@ -103,13 +102,11 @@ class CallController: @unchecked Sendable { await observeStatsReporterUpdates() await observeCallSettingsUpdates() } - - joinCallResponseFetchObserver = joinCallResponseSubject - .compactMap { $0 } - .sinkTask(storeIn: disposableBag) { [weak self] in await self?.didFetch($0) } } /// Joins a call with the provided information and join source. + /// The returned `JoinCallResponse` is emitted only after the call flow has + /// reached the connected state. /// /// - Parameters: /// - callType: The type of the call. @@ -124,6 +121,7 @@ class CallController: @unchecked Sendable { /// Use this to indicate if the call was joined from in-app UI or /// via CallKit. /// - Returns: A newly created `JoinCallResponse`. + /// - Throws: If authentication, API call, or SFU connection fails. @discardableResult func joinCall( create: Bool = true, @@ -131,9 +129,23 @@ class CallController: @unchecked Sendable { options: CreateCallOptions? = nil, ring: Bool = false, notify: Bool = false, - source: JoinSource + source: JoinSource, + policy: WebRTCJoinPolicy = .default ) async throws -> JoinCallResponse { - joinCallResponseSubject = .init(nil) + joinCallResponseFetchObserver?.cancel() + joinCallResponseFetchObserver = nil + + // Each join attempt uses a fresh delivery subject so a failed attempt + // cannot complete future retries on the same controller. + let joinCallResponseSubject = PassthroughSubject() + joinCallResponseFetchObserver = joinCallResponseSubject + .compactMap { $0 } + .sinkTask(storeIn: disposableBag) { [weak self] in await self?.didFetch($0) } + + defer { + joinCallResponseFetchObserver?.cancel() + joinCallResponseFetchObserver = nil + } try await webRTCCoordinator.connect( create: create, @@ -141,17 +153,18 @@ class CallController: @unchecked Sendable { options: options, ring: ring, notify: notify, - source: source + source: source, + joinResponseHandler: joinCallResponseSubject, + policy: policy ) - - guard - let response = try await joinCallResponseSubject - .nextValue(dropFirst: 1, timeout: WebRTCConfiguration.timeout.join) - else { + + do { + return try await joinCallResponseSubject + .nextValue(timeout: WebRTCConfiguration.timeout.join) + } catch { await webRTCCoordinator.cleanUp() - throw ClientError("Unable to connect to call callId:\(callId).") + throw error } - return response } /// Changes the audio state for the current user. @@ -273,7 +286,9 @@ class CallController: @unchecked Sendable { return } - await webRTCCoordinator.stateAdapter.set(ownCapabilities: .init(ownCapabilities)) + await webRTCCoordinator + .stateAdapter + .enqueueOwnCapabilities { .init(ownCapabilities) } } /// Initiates a focus operation at a specific point on the camera's view. @@ -580,44 +595,37 @@ class CallController: @unchecked Sendable { notify: Bool, options: CreateCallOptions? ) async throws -> JoinCallResponse { - do { - let location = try await getLocation() - var membersRequest = [MemberRequest]() - options?.memberIds?.forEach { - membersRequest.append(.init(userId: $0)) - } - options?.members?.forEach { - membersRequest.append($0) - } - let callRequest = CallRequest( - custom: options?.custom, - members: membersRequest, - settingsOverride: options?.settings, - startsAt: options?.startsAt, - team: options?.team - ) - let joinCall = JoinCallRequest( - create: create, - data: callRequest, - location: location, - migratingFrom: migratingFrom, - notify: notify, - ring: ring - ) - let response = try await defaultAPI.joinCall( - type: callType, - id: callId, - joinCallRequest: joinCall - ) - - // We allow the CallController to manage its state. - joinCallResponseSubject.send(response) - - return response - } catch { - joinCallResponseSubject.send(completion: .failure(error)) - throw error + let location = try await getLocation() + var membersRequest = [MemberRequest]() + + options?.memberIds?.forEach { + membersRequest.append(.init(userId: $0)) + } + options?.members?.forEach { + membersRequest.append($0) } + + let callRequest = CallRequest( + custom: options?.custom, + members: membersRequest, + settingsOverride: options?.settings, + startsAt: options?.startsAt, + team: options?.team + ) + let joinCall = JoinCallRequest( + create: create, + data: callRequest, + location: location, + migratingFrom: migratingFrom, + notify: notify, + ring: ring + ) + + return try await defaultAPI.joinCall( + type: callType, + id: callId, + joinCallRequest: joinCall + ) } private func prefetchLocation() { @@ -761,7 +769,7 @@ class CallController: @unchecked Sendable { .stateAdapter .$callSettings .removeDuplicates() - .sinkTask(storeIn: disposableBag) { @MainActor [weak self] in self?.call?.state.callSettings = $0 } + .sinkTask(storeIn: disposableBag) { @MainActor [weak self] in self?.call?.state.update(callSettings: $0) } .store(in: disposableBag) } } diff --git a/Sources/StreamVideo/Controllers/OutgoingRingingController.swift b/Sources/StreamVideo/Controllers/OutgoingRingingController.swift new file mode 100644 index 000000000..de948b085 --- /dev/null +++ b/Sources/StreamVideo/Controllers/OutgoingRingingController.swift @@ -0,0 +1,84 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +/// Observes an outgoing ringing call and ends it when the app moves to +/// the background. +/// +/// The controller is active only while +/// `StreamVideo.State.ringingCall` matches the provided call CID. +/// When ringing stops or another call becomes the ringing call, the +/// application state observation is cancelled. +final class OutgoingRingingController: @unchecked Sendable { + @Injected(\.applicationStateAdapter) private var applicationStateAdapter + + private let callCiD: String + private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) + private let handler: () async throws -> Void + private var ringingCallCancellable: AnyCancellable? + private var appStateCancellable: AnyCancellable? + private let disposableBag = DisposableBag() + + /// Creates a controller for the outgoing ringing call identified by + /// the provided call CID. + /// + /// - Parameters: + /// - streamVideo: The active `StreamVideo` instance. + /// - callCiD: The call CID to observe in `ringingCall`. + /// - handler: The async operation that ends the ringing call. + init( + streamVideo: StreamVideo, + callCiD: String, + handler: @escaping () async throws -> Void + ) { + self.callCiD = callCiD + self.handler = handler + ringingCallCancellable = streamVideo + .state + .$ringingCall + .receive(on: processingQueue) + .sink { [weak self] in self?.didUpdateRingingCall($0) } + } + + // MARK: - Private Helpers + + private func didUpdateRingingCall(_ call: Call?) { + guard call?.cId == callCiD else { + deactivate() + return + } + activate() + } + + private func activate() { + appStateCancellable = applicationStateAdapter + .statePublisher + /// We ignore .unknown on purpose to cover cases like, starting a call from the Recents app where + /// entering the ringing flow may happen before the AppState has been stabilised + .filter { $0 == .background } + .log(.warning) { [callCiD] in "Application moved to \($0) while ringing cid:\(callCiD). Ending now." } + .receive(on: processingQueue) + .sinkTask(storeIn: disposableBag) { [weak self] _ in await self?.endCall() } + + log.debug("Call cid:\(callCiD) is ringing. Starting application state observation.") + } + + private func deactivate() { + appStateCancellable?.cancel() + appStateCancellable = nil + + log.debug("Application state observation for cid:\(callCiD) has been deactivated.") + } + + private func endCall() async { + do { + try await handler() + } catch { + log.error(error) + } + deactivate() + } +} diff --git a/Sources/StreamVideo/Errors/TimeOutError.swift b/Sources/StreamVideo/Errors/TimeOutError.swift new file mode 100644 index 000000000..d92b5fd20 --- /dev/null +++ b/Sources/StreamVideo/Errors/TimeOutError.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Indicates that an asynchronous operation exceeded its configured timeout. +public final class TimeOutError: ClientError, Sendable { + + convenience init( + file: StaticString = #fileID, + line: UInt = #line + ) { + self.init("Operation timed out", file, line) + } +} diff --git a/Sources/StreamVideo/Generated/SystemEnvironment+Version.swift b/Sources/StreamVideo/Generated/SystemEnvironment+Version.swift index ff90ae1df..01d6c1ecf 100644 --- a/Sources/StreamVideo/Generated/SystemEnvironment+Version.swift +++ b/Sources/StreamVideo/Generated/SystemEnvironment+Version.swift @@ -7,7 +7,7 @@ import Foundation extension SystemEnvironment { /// A Stream Video version. - public static let version: String = "1.43.0" + public static let version: String = "1.44.0" /// The WebRTC version. - public static let webRTCVersion: String = "137.0.67" + public static let webRTCVersion: String = "137.0.71" } diff --git a/Sources/StreamVideo/Info.plist b/Sources/StreamVideo/Info.plist index dc8ece44d..b4b51650f 100644 --- a/Sources/StreamVideo/Info.plist +++ b/Sources/StreamVideo/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.43.0 + 1.44.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/Sources/StreamVideo/Models/JoinSource.swift b/Sources/StreamVideo/Models/JoinSource.swift index c5ea6ce04..7af143f7e 100644 --- a/Sources/StreamVideo/Models/JoinSource.swift +++ b/Sources/StreamVideo/Models/JoinSource.swift @@ -10,10 +10,39 @@ import Foundation /// the app's own UI or through a system-level interface such as CallKit. /// This helps distinguish the user's entry point and can be used to customize /// behavior or analytics based on how the call was initiated. -enum JoinSource { +enum JoinSource: Sendable, Equatable { + /// Carries the completion hook CallKit expects us to invoke once the SDK is + /// ready for CallKit to hand audio session ownership back to the app. + struct ActionCompletion: @unchecked Sendable { + fileprivate let identifier: UUID = .init() + private let completion: () -> Void + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + /// Invokes the stored completion callback. + func complete() { + completion() + } + } + /// Indicates that the call was joined from within the app's UI. case inApp /// Indicates that the call was joined via CallKit integration. - case callKit + case callKit(ActionCompletion) + + /// Compares `JoinSource` values while treating CallKit sources as distinct + /// whenever they wrap different completion hooks. + static func == (lhs: JoinSource, rhs: JoinSource) -> Bool { + switch (lhs, rhs) { + case (.inApp, .inApp): + return true + case (.callKit(let lhs), .callKit(let rhs)): + return lhs.identifier == rhs.identifier + default: + return false + } + } } diff --git a/Sources/StreamVideo/Models/PermissionRequest.swift b/Sources/StreamVideo/Models/PermissionRequest.swift new file mode 100644 index 000000000..72dc1deb2 --- /dev/null +++ b/Sources/StreamVideo/Models/PermissionRequest.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public struct PermissionRequest: @unchecked Sendable, Identifiable { + public let id: UUID = .init() + public let permission: String + public let user: User + public let requestedAt: Date + let onReject: (PermissionRequest) -> Void + + public init( + permission: String, + user: User, + requestedAt: Date, + onReject: @escaping (PermissionRequest) -> Void = { _ in } + ) { + self.permission = permission + self.user = user + self.requestedAt = requestedAt + self.onReject = onReject + } + + public func reject() { + onReject(self) + } +} diff --git a/Sources/StreamVideo/StreamVideo+CallSession.swift b/Sources/StreamVideo/StreamVideo+CallSession.swift new file mode 100644 index 000000000..ac8122b75 --- /dev/null +++ b/Sources/StreamVideo/StreamVideo+CallSession.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +extension StreamVideo { + /// Lightweight session context exposing `StreamVideo`-backed user and token + /// values for call-state and media operations without retaining a strong + /// reference to the client. + var callSession: CallSession { + .init(self) + } +} + +extension StreamVideo { + + /// Session context used by call and media state. + final class CallSession: @unchecked Sendable { + /// The authenticated user for the current SDK session. + let user: User + + /// The auth token used when exposing secure stream metadata. + /// This token stays in sync with the latest `StreamVideo` token values. + private(set) var token: UserToken + + private var tokenCancellable: AnyCancellable? + + convenience init(_ streamVideo: StreamVideo) { + self.init( + user: streamVideo.user, + token: streamVideo.token, + tokenPublisher: streamVideo.tokenPublisher.eraseToAnyPublisher() + ) + } + + internal init( + user: User, + token: UserToken, + tokenPublisher: AnyPublisher? = nil + ) { + self.user = user + self.token = token + self.tokenCancellable = tokenPublisher? + .assign(to: \.token, onWeak: self) + } + } +} diff --git a/Sources/StreamVideo/StreamVideo.swift b/Sources/StreamVideo/StreamVideo.swift index 0db995fbf..33c94c0d4 100644 --- a/Sources/StreamVideo/StreamVideo.swift +++ b/Sources/StreamVideo/StreamVideo.swift @@ -28,6 +28,11 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { didSet { didUpdateActiveCall(activeCall, oldValue: oldValue) } } + /// The call that is currently ringing. + /// + /// This is set for both incoming calls and outgoing calls started + /// with `Call.ring()`. The value is cleared after the call is + /// joined, rejected, ended, or promoted to `activeCall`. @Published public internal(set) var ringingCall: Call? private nonisolated let disposableBag = DisposableBag() @@ -70,7 +75,9 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { private let eventSubject: PassthroughSubject = .init() - var token: UserToken + private let tokenSubject: CurrentValueSubject + var tokenPublisher: AnyPublisher { tokenSubject.eraseToAnyPublisher() } + var token: UserToken { tokenSubject.value } private var tokenProvider: UserTokenProvider private static let endpointConfig: EndpointConfig = .production @@ -175,7 +182,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { ) { self.apiKey = APIKey(apiKey) state = State(user: user) - self.token = token + self.tokenSubject = .init(token) self.tokenProvider = tokenProvider self.videoConfig = videoConfig self.environment = environment @@ -198,7 +205,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { callCache.removeAll() (apiTransport as? URLSessionTransport)?.setTokenUpdater { [weak self] userToken in - self?.token = userToken + self?.tokenSubject.send(userToken) } // Warm up @@ -478,7 +485,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { let guestInfo = try await loadGuestUserInfo(for: user, apiKey: apiKey) self.state.user = guestInfo.user - self.token = guestInfo.token + self.tokenSubject.send(guestInfo.token) self.tokenProvider = guestInfo.tokenProvider try Task.checkCancellation() @@ -774,7 +781,7 @@ extension StreamVideo: ConnectionStateDelegate { } do { guard let apiTransport = apiTransport as? URLSessionTransport else { return } - self.token = try await apiTransport.refreshToken() + self.tokenSubject.send(try await apiTransport.refreshToken()) log.debug("user token updated, will reconnect ws") webSocketClient?.connect() } catch { diff --git a/Sources/StreamVideo/Utils/AudioSession/CallAudioSession.swift b/Sources/StreamVideo/Utils/AudioSession/CallAudioSession.swift index a57fbfe23..a6f4904f8 100644 --- a/Sources/StreamVideo/Utils/AudioSession/CallAudioSession.swift +++ b/Sources/StreamVideo/Utils/AudioSession/CallAudioSession.swift @@ -12,6 +12,8 @@ final class CallAudioSession: @unchecked Sendable { @Injected(\.audioStore) private var audioStore + private enum DisposableKey: String { case deferredActivation } + /// Bundles the reactive inputs we need to evaluate whenever call /// capabilities or settings change, keeping log context attached. private struct Input { @@ -95,18 +97,18 @@ final class CallAudioSession: @unchecked Sendable { self.delegate = delegate self.statsAdapter = statsAdapter - // Expose the policy's stereo preference so the audio device module can - // reconfigure itself before WebRTC starts playout. - audioStore.dispatch(.stereo(.setPlayoutPreferred(policy is LivestreamAudioSessionPolicy))) + guard shouldSetActive else { + scheduleDeferredActivation( + callSettingsPublisher: callSettingsPublisher, + ownCapabilitiesPublisher: ownCapabilitiesPublisher + ) + return + } - configureCallSettingsAndCapabilitiesObservation( + performActivation( callSettingsPublisher: callSettingsPublisher, ownCapabilitiesPublisher: ownCapabilitiesPublisher ) - configureCurrentRouteObservation() - configureCallOptionsObservation() - - statsAdapter?.trace(.init(audioSession: traceRepresentation)) } func deactivate() async { @@ -155,6 +157,49 @@ final class CallAudioSession: @unchecked Sendable { // MARK: - Private Helpers + private func scheduleDeferredActivation( + callSettingsPublisher: AnyPublisher, + ownCapabilitiesPublisher: AnyPublisher, Never> + ) { + disposableBag.remove(DisposableKey.deferredActivation.rawValue) + audioStore + .publisher(\.isActive) + /// We drop the first value in case the AudioSession is already active (e.g. because AVAudioSession + /// has been used for other media playing). + /// We only want to catch the first session activation that will happen **after** our subscription + /// here, + .dropFirst() + .filter { $0 == true } + .receive(on: processingQueue) + .sink { [weak self] _ in + self?.performActivation( + callSettingsPublisher: callSettingsPublisher, + ownCapabilitiesPublisher: ownCapabilitiesPublisher + ) + } + .store(in: disposableBag, key: DisposableKey.deferredActivation.rawValue) + } + + private func performActivation( + callSettingsPublisher: AnyPublisher, + ownCapabilitiesPublisher: AnyPublisher, Never> + ) { + disposableBag.remove(DisposableKey.deferredActivation.rawValue) + + // Expose the policy's stereo preference so the audio device module can + // reconfigure itself before WebRTC starts playout. + audioStore.dispatch(.stereo(.setPlayoutPreferred(policy is LivestreamAudioSessionPolicy))) + + configureCallSettingsAndCapabilitiesObservation( + callSettingsPublisher: callSettingsPublisher, + ownCapabilitiesPublisher: ownCapabilitiesPublisher + ) + configureCurrentRouteObservation() + configureCallOptionsObservation() + + statsAdapter?.trace(.init(audioSession: traceRepresentation)) + } + private func process( _ input: Input ) { diff --git a/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Middleware/RTCAudioStore+AudioDeviceModuleMiddleware.swift b/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Middleware/RTCAudioStore+AudioDeviceModuleMiddleware.swift index 77b36a287..752aab916 100644 --- a/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Middleware/RTCAudioStore+AudioDeviceModuleMiddleware.swift +++ b/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Namespace/Middleware/RTCAudioStore+AudioDeviceModuleMiddleware.swift @@ -127,10 +127,10 @@ extension RTCAudioStore { _ audioDeviceModule: AudioDeviceModule?, state: RTCAudioStore.StoreState ) throws { - state.audioDeviceModule?.reset() - disposableBag.removeAll() + state.audioDeviceModule?.reset() + guard let audioDeviceModule else { return } diff --git a/Sources/StreamVideo/Utils/Logger/Logger.swift b/Sources/StreamVideo/Utils/Logger/Logger.swift index 28ee4f9d9..1d9697ecd 100644 --- a/Sources/StreamVideo/Utils/Logger/Logger.swift +++ b/Sources/StreamVideo/Utils/Logger/Logger.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine import Foundation public var log: Logger { @@ -317,10 +318,10 @@ public class Logger: @unchecked Sendable { /// Destinations for this logger. /// See `LogDestination` protocol for details. - @Atomic private var _destinations: [LogDestination] + private var _destinations: CurrentValueSubject<[LogDestination], Never> public var destinations: [LogDestination] { - get { _destinations } - set { _destinations = newValue } + get { _destinations.value } + set { _destinations.send(newValue) } } private let loggerQueue = DispatchQueue(label: "LoggerQueue \(UUID())") @@ -328,7 +329,7 @@ public class Logger: @unchecked Sendable { /// Init a logger with a given identifier and destinations. public init(identifier: String = "", destinations: [LogDestination] = []) { self.identifier = identifier - _destinations = destinations + _destinations = .init(destinations) } /// Allows logger to be called as function. diff --git a/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift b/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift index a5231982b..75995c088 100644 --- a/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift +++ b/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift @@ -3,6 +3,7 @@ // import Foundation +import protocol SwiftProtobuf.Message /// An enumeration representing rules for skipping properties during reflective /// string conversion. @@ -169,6 +170,25 @@ public extension ReflectiveStringConvertible { /// /// - Returns: A string representation of the object. var description: String { + if let message = self as? Message { + #if STREAM_TESTS + // During tests we allow full logging. + #else + guard LogConfig.level == .debug else { + return "\(type(of: self))" + } + #endif + + let textFormat = message.textFormatString() + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !textFormat.isEmpty else { + return "\(type(of: self))" + } + + return "\(type(of: self)) { \(textFormat) }" + } + #if STREAM_TESTS // During tests we allow full error logging. #else @@ -176,6 +196,10 @@ public extension ReflectiveStringConvertible { return "\(type(of: self))" } #endif + return reflectiveDescription + } + + private var reflectiveDescription: String { let mirror = Mirror(reflecting: self) var result = "\(type(of: self))" var components: [String] = [] diff --git a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Task+Timeout.swift b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Task+Timeout.swift index 678c2f157..48f2528bd 100644 --- a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Task+Timeout.swift +++ b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Task+Timeout.swift @@ -9,7 +9,7 @@ extension Task where Failure == any Error { @discardableResult init( priority: TaskPriority? = nil, - /// New: a timeout property to configure how long a task may perform before failing with a timeout error. + /// The maximum time to wait before failing with `TimeOutError`. timeoutInSeconds: TimeInterval, file: StaticString = #fileID, function: StaticString = #function, @@ -28,11 +28,11 @@ extension Task where Failure == any Error { /// Add another task to trigger the timeout if it finishes earlier than our first task. _ = group.addTaskUnlessCancelled { () -> Success in try await Task.sleep(nanoseconds: UInt64(timeoutInSeconds * 1_000_000_000)) - throw ClientError("Operation timed out", file, line) + throw TimeOutError(file: file, line: line) } } else { log.warning("Invalid timeout:\(timeoutInSeconds) was passed to Task.timeout. Task will timeout immediately.") - throw ClientError("Operation timed out", file, line) + throw TimeOutError(file: file, line: line) } /// We need to deal with an optional, even though we know it's not optional. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/AudioMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/AudioMediaAdapter.swift index 4c3e5e360..56ae0db7f 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/AudioMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/AudioMediaAdapter.swift @@ -135,6 +135,10 @@ final class AudioMediaAdapter: MediaAdapting, @unchecked Sendable { try await localMediaManager.didUpdateCallSettings(settings) } + func didUpdateOwnCapabilities(_ ownCapabilities: Set) { + localMediaManager.didUpdateOwnCapabilities(ownCapabilities) + } + /// Updates the publish options for the audio media adapter. /// /// - Parameter publishOptions: The new publish options to be applied. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter.swift index 56a12b83f..684795f12 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter.swift @@ -123,72 +123,75 @@ final class LocalAudioMediaAdapter: LocalMediaAdapting, @unchecked Sendable { /// /// This enables the primary track and creates additional transceivers based /// on the current publish options. It also starts the audio recorder. - func publish() { - processingQueue.addTaskOperation { @MainActor [weak self] in - guard - let self, - !primaryTrack.isEnabled - else { - return - } + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func publish() async throws { + guard + !primaryTrack.isEnabled + else { + return + } - primaryTrack.isEnabled = true + primaryTrack.isEnabled = true - publishOptions.forEach { - self.addTransceiverIfRequired( - for: $0, - with: self.primaryTrack.clone(from: self.peerConnectionFactory) - ) - } + publishOptions.forEach { + self.addTransceiverIfRequired( + for: $0, + with: self.primaryTrack.clone(from: self.peerConnectionFactory) + ) + } - let activePublishOptions = Set(self.publishOptions) - transceiverStorage - .forEach { - if activePublishOptions.contains($0.key) { - $0.value.track.isEnabled = true - $0.value.transceiver.sender.track = $0.value.track - } else { - $0.value.track.isEnabled = false - $0.value.transceiver.sender.track = nil - } + let activePublishOptions = Set(self.publishOptions) + transceiverStorage + .forEach { + if activePublishOptions.contains($0.key) { + $0.value.track.isEnabled = true + $0.value.transceiver.sender.track = $0.value.track + } else { + $0.value.track.isEnabled = false + $0.value.transceiver.sender.track = nil } + } - audioRecorder.startRecording() + audioRecorder.startRecording() - log.debug( - """ - Local audio tracks are now published: - primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) - clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) - """, - subsystems: .webRTC - ) - } + log.debug( + """ + Local audio tracks are now published: + primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) + clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) + """, + subsystems: .webRTC + ) } /// Stops publishing the local audio track. /// /// This disables the primary track and all associated transceivers. - func unpublish() { - processingQueue.addOperation { [weak self] in - guard let self, primaryTrack.isEnabled else { return } + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func unpublish() async throws { + guard primaryTrack.isEnabled else { return } - primaryTrack.isEnabled = false + primaryTrack.isEnabled = false - transceiverStorage - .forEach { $0.value.track.isEnabled = false } + transceiverStorage + .forEach { $0.value.track.isEnabled = false } - audioRecorder.stopRecording() + audioRecorder.stopRecording() - log.debug( - """ - Local audio tracks are now unpublished: - primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) - clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) - """, - subsystems: .webRTC - ) - } + log.debug( + """ + Local audio tracks are now unpublished: + primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) + clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) + """, + subsystems: .webRTC + ) } /// Updates the local audio media based on new call settings. @@ -197,7 +200,7 @@ final class LocalAudioMediaAdapter: LocalMediaAdapting, @unchecked Sendable { func didUpdateCallSettings( _ settings: CallSettings ) async throws { - processingQueue.addTaskOperation { [weak self] in + try await processingQueue.addSynchronousTaskOperation { [weak self] in guard let self, ownCapabilities.contains(.sendAudio) else { return } registerPrimaryTrackIfPossible(settings) @@ -215,15 +218,27 @@ final class LocalAudioMediaAdapter: LocalMediaAdapting, @unchecked Sendable { } if isMuted, primaryTrack.isEnabled { - unpublish() + try await unpublish() } else if !isMuted { - publish() + try await publish() } lastUpdatedCallSettings = settings.audio } } + /// Updates cached local participant capabilities used by subsequent setting updates. + /// + /// The adapter uses this for media operations that depend on whether audio + /// publishing is currently permitted for the participant. + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + processingQueue.addOperation { [weak self] in + self?.ownCapabilities = Array(ownCapabilities) + } + } + /// Updates the publish options for the local audio track. /// /// - Parameter publishOptions: The new publish options. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalNoOpMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalNoOpMediaAdapter.swift index d5a355fa4..2df9074bc 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalNoOpMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalNoOpMediaAdapter.swift @@ -41,12 +41,16 @@ final class LocalNoOpMediaAdapter: LocalMediaAdapting { } /// A no-op implementation of the publish method. - func publish() { + /// + /// - Throws: No thrown errors are expected. + func publish() async throws { /* No-op */ } /// A no-op implementation of the unpublish method. - func unpublish() { + /// + /// - Throws: No thrown errors are expected. + func unpublish() async throws { /* No-op */ } @@ -62,6 +66,10 @@ final class LocalNoOpMediaAdapter: LocalMediaAdapting { /* No-op */ } + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { /* No-op */ } + /// A no-op implementation of the method to handle updated publish options. /// /// - Parameter settings: Ignored in this implementation. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter.swift index f972e2749..c826c8b84 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter.swift @@ -136,8 +136,12 @@ final class LocalScreenShareMediaAdapter: LocalMediaAdapting, @unchecked Sendabl /// /// This method enables the primary screen sharing track and creates /// transceivers based on the specified publish options. - func publish() { - processingQueue.addTaskOperation { @MainActor [weak self] in + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func publish() async throws { + try await processingQueue.addSynchronousTaskOperation { @MainActor [weak self] in guard let self, !primaryTrack.isEnabled, @@ -184,34 +188,34 @@ final class LocalScreenShareMediaAdapter: LocalMediaAdapting, @unchecked Sendabl /// /// This method disables the primary screen sharing track and all associated /// transceivers, and stops the screen sharing capturing session. - func unpublish() { - processingQueue.addTaskOperation { @MainActor [weak self] in - do { - guard - let self, - primaryTrack.isEnabled, - screenShareSessionProvider.activeSession != nil - else { - return - } + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func unpublish() async throws { + try await processingQueue.addSynchronousTaskOperation { @MainActor [weak self] in + guard + let self, + primaryTrack.isEnabled, + screenShareSessionProvider.activeSession != nil + else { + return + } - primaryTrack.isEnabled = false + primaryTrack.isEnabled = false - transceiverStorage.forEach { $0.value.track.isEnabled = false } + transceiverStorage.forEach { $0.value.track.isEnabled = false } - try await stopScreenShareCapturingSession() + try await stopScreenShareCapturingSession() - log.debug( - """ - Local screenShareTracks are now unpublished: - primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) - clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) - """, - subsystems: .webRTC - ) - } catch { - log.error(error, subsystems: .webRTC) - } + log.debug( + """ + Local screenShareTracks are now unpublished: + primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) + clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) + """, + subsystems: .webRTC + ) } } @@ -224,6 +228,16 @@ final class LocalScreenShareMediaAdapter: LocalMediaAdapting, @unchecked Sendabl /* No-op */ } + /// Updates local capabilities for screen sharing operations. + /// + /// Screen-sharing capabilities are currently checked when starting + /// screen sharing, so no adapter state is cached here. + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + /* No-op */ + } + /// Updates the publishing options for the screen sharing track. /// /// - Parameter publishOptions: The new publishing options to apply. @@ -363,7 +377,7 @@ final class LocalScreenShareMediaAdapter: LocalMediaAdapting, @unchecked Sendabl for: sessionID ) - publish() + try await publish() } /// Stops the current screen sharing session. @@ -374,7 +388,7 @@ final class LocalScreenShareMediaAdapter: LocalMediaAdapting, @unchecked Sendabl for: sessionID ) - unpublish() + try await unpublish() } // MARK: - Private Helpers diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter.swift index 7eb4962c0..c21d60a48 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter.swift @@ -176,7 +176,7 @@ final class LocalVideoMediaAdapter: LocalMediaAdapting, @unchecked Sendable { func didUpdateCallSettings( _ settings: CallSettings ) async throws { - processingQueue.addTaskOperation { [weak self] in + try await processingQueue.addSynchronousTaskOperation { [weak self] in guard let self, ownCapabilities.contains(.sendVideo) else { return } callSettings = settings registerPrimaryTrackIfPossible(settings) @@ -200,96 +200,100 @@ final class LocalVideoMediaAdapter: LocalMediaAdapting, @unchecked Sendable { ) if isMuted, primaryTrack.isEnabled { - unpublish() + try await unpublish() } else if !isMuted { - publish() + try await publish() } } } + /// Updates cached local participant capabilities for the local video adapter. + /// + /// The adapter uses this cache in call-setting updates to gate video + /// registration and publishing when `.sendVideo` is not present. + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + processingQueue.addOperation { [weak self] in + self?.ownCapabilities = Array(ownCapabilities) + } + } + /// Starts publishing the local video track. - func publish() { - processingQueue.addTaskOperation { @MainActor [weak self] in - guard - let self, - !primaryTrack.isEnabled - else { - return - } - primaryTrack.isEnabled = true + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func publish() async throws { + guard + !primaryTrack.isEnabled + else { + return + } + try await startVideoCapturingSession() - do { - try await startVideoCapturingSession() - } catch { - log.error(error) - } + primaryTrack.isEnabled = true - publishOptions - .forEach { - self.addTransceiverIfRequired( - for: $0, - with: self - .primaryTrack - .clone(from: self.peerConnectionFactory) - ) - } + publishOptions + .forEach { + self.addTransceiverIfRequired( + for: $0, + with: self + .primaryTrack + .clone(from: self.peerConnectionFactory) + ) + } - let activePublishOptions = Set(self.publishOptions) + let activePublishOptions = Set(self.publishOptions) - transceiverStorage - .forEach { - if activePublishOptions.contains($0.key) { - $0.value.track.isEnabled = true - $0.value.transceiver.sender.track = $0.value.track - } else { - $0.value.track.isEnabled = false - $0.value.transceiver.sender.track = nil - } + transceiverStorage + .forEach { + if activePublishOptions.contains($0.key) { + $0.value.track.isEnabled = true + $0.value.transceiver.sender.track = $0.value.track + } else { + $0.value.track.isEnabled = false + $0.value.transceiver.sender.track = nil } + } - log.debug( - """ - Local videoTracks are now published - primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) - clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) - """, - subsystems: .webRTC - ) - } + log.debug( + """ + Local videoTracks are now published + primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) + clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) + """, + subsystems: .webRTC + ) } /// Stops publishing the local video track. - func unpublish() { - processingQueue.addTaskOperation { [weak self] in - guard - let self, - primaryTrack.isEnabled - else { - return - } + /// + /// This method is intended to be triggered by local adapter flow (for example, + /// through call settings updates) and not called directly by external + /// consumers. + func unpublish() async throws { + guard + primaryTrack.isEnabled + else { + return + } - primaryTrack.isEnabled = false + try await stopVideoCapturingSession() - transceiverStorage - .forEach { $0.value.track.isEnabled = false } + primaryTrack.isEnabled = false - _ = await Task(disposableBag: disposableBag) { @MainActor [weak self] in - do { - try await self?.stopVideoCapturingSession() - } catch { - log.error(error, subsystems: .webRTC) - } - }.result + transceiverStorage + .forEach { $0.value.track.isEnabled = false } - log.debug( - """ - Local videoTracks are now unpublished: - primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) - clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) - """, - subsystems: .webRTC - ) - } + log.debug( + """ + Local videoTracks are now unpublished: + primary: \(primaryTrack.trackId) isEnabled:\(primaryTrack.isEnabled) + clones: \(transceiverStorage.map(\.value.track.trackId).joined(separator: ",")) + """, + subsystems: .webRTC + ) } /// Updates the publish options for the video track. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/MediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/MediaAdapter.swift index 54ae43f00..1dbbae4c4 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/MediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/MediaAdapter.swift @@ -190,10 +190,18 @@ final class MediaAdapter { try await screenShareMediaAdapter.didUpdateCallSettings(settings) } - while try await group.next() != nil {} + try await group.waitForAll() } } - + + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + audioMediaAdapter.didUpdateOwnCapabilities(ownCapabilities) + videoMediaAdapter.didUpdateOwnCapabilities(ownCapabilities) + screenShareMediaAdapter.didUpdateOwnCapabilities(ownCapabilities) + } + /// Retrieves track information for a specified track type and collection type. /// /// - Parameters: diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/ScreenShareMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/ScreenShareMediaAdapter.swift index 3fd5f68d0..738313284 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/ScreenShareMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/ScreenShareMediaAdapter.swift @@ -135,7 +135,13 @@ final class ScreenShareMediaAdapter: MediaAdapting, @unchecked Sendable { ) async throws { try await localMediaManager.didUpdateCallSettings(settings) } - + + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + localMediaManager.didUpdateOwnCapabilities(ownCapabilities) + } + /// Updates the publish options asynchronously. /// /// - Parameter publishOptions: The new publish options to be applied. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoMediaAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoMediaAdapter.swift index 95c889214..b161f723d 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoMediaAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoMediaAdapter.swift @@ -140,6 +140,12 @@ final class VideoMediaAdapter: MediaAdapting, @unchecked Sendable { try await localMediaManager.didUpdateCallSettings(settings) } + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + localMediaManager.didUpdateOwnCapabilities(ownCapabilities) + } + /// Updates the publish options asynchronously. /// /// - Parameter publishOptions: The new publish options to be applied. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/LocalMediaAdapting.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/LocalMediaAdapting.swift index 025d18085..62c409986 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/LocalMediaAdapting.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/LocalMediaAdapting.swift @@ -28,12 +28,22 @@ protocol LocalMediaAdapting { /// Starts publishing local media tracks. /// /// This method should be called when the local participant wants to share their media with others. - func publish() + /// Implementations are expected to be invoked internally by the owning media + /// adapter flow (for example, via call setting updates), not by external callers. + /// + /// - Throws: Throws when publishing fails, for example if capture setup or track + /// publishing fails. + func publish() async throws /// Stops publishing local media tracks. /// /// This method should be called when the local participant wants to stop sharing their media. - func unpublish() + /// Implementations are expected to be invoked internally by the owning media + /// adapter flow (for example, via call setting updates), not by external callers. + /// + /// - Throws: Throws when unpublishing fails, for example if capture teardown + /// fails. + func unpublish() async throws func trackInfo(for collectionType: RTCPeerConnectionTrackInfoCollectionType) -> [Stream_Video_Sfu_Models_TrackInfo] @@ -44,5 +54,11 @@ protocol LocalMediaAdapting { /// - Throws: An error if the update process fails. func didUpdateCallSettings(_ settings: CallSettings) async throws + /// Updates the adapter with the latest local participant capabilities. + /// + /// - Parameter ownCapabilities: The set of capabilities owned by the local user. + /// + func didUpdateOwnCapabilities(_ ownCapabilities: Set) + func didUpdatePublishOptions(_ publishOptions: PublishOptions) async throws } diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift index 7f0afbf3f..cacc4c118 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift @@ -83,6 +83,9 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { .eraseToAnyPublisher() } + private let connectionStateSubject: CurrentValueSubject = .init(.new) + var connectionStatePublisher: AnyPublisher { connectionStateSubject.eraseToAnyPublisher() } + /// A Boolean value indicating whether the peer connection is in a healthy state. /// /// The peer connection is considered healthy if its ICE connection state is not @@ -249,6 +252,13 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { } .store(in: disposableBag) + peerConnection + .publisher + .compactMap { ($0 as? StreamRTCPeerConnection.PeerConnectionStateChangedEvent)?.state } + .receive(on: dispatchQueue) + .sink { [weak self] in self?.connectionStateSubject.send($0) } + .store(in: disposableBag) + if peerType == .publisher { peerConnection .publisher(eventType: StreamRTCPeerConnection.ShouldNegotiateEvent.self) @@ -382,6 +392,31 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { try await mediaAdapter.didUpdateCallSettings(settings) } + /// Propagates local capability updates to all media adapters. + /// + /// - Parameter ownCapabilities: The latest capabilities of the local + /// participant. + func didUpdateOwnCapabilities( + _ ownCapabilities: Set + ) { + log.debug( + """ + PeerConnection will update ownCapabilities: + Identifier: \(identifier) + Session ID: \(sessionId) + Connection type: \(peerType) + SFU: \(sfuAdapter.hostname) + + ownCapabilities: + hasAudio: \(ownCapabilities.contains(.sendAudio)) + hasVideo: \(ownCapabilities.contains(.sendVideo)) + hasScreenShare: \(ownCapabilities.contains(.screenshare)) + """, + subsystems: subsystem + ) + mediaAdapter.didUpdateOwnCapabilities(ownCapabilities) + } + /// Updates the publish options for the peer connection. /// /// This method applies the new publish options to all media adapters including @@ -392,10 +427,11 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { _ publishOptions: PublishOptions ) { Task(disposableBag: disposableBag) { [weak self] in + guard let self else { return } do { - try await self?.mediaAdapter.didUpdatePublishOptions(publishOptions) + try await mediaAdapter.didUpdatePublishOptions(publishOptions) } catch { - log.error(error) + log.error(error, subsystems: subsystem) } } } diff --git a/Sources/StreamVideo/WebRTC/v2/Policies/JoinPolicy/WebRTCJoinPolicy.swift b/Sources/StreamVideo/WebRTC/v2/Policies/JoinPolicy/WebRTCJoinPolicy.swift new file mode 100644 index 000000000..d07d0a0fe --- /dev/null +++ b/Sources/StreamVideo/WebRTC/v2/Policies/JoinPolicy/WebRTCJoinPolicy.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Controls when ``Call/join(create:options:ring:notify:callSettings:policy:)`` +/// completes relative to the underlying WebRTC transport. +public enum WebRTCJoinPolicy: Sendable { + /// Completes the join request as soon as the SFU join flow succeeds. + case `default` + + /// Waits until both peer connections report `.connected`, or until the + /// timeout elapses, before completing the join request. + case peerConnectionReadinessAware(timeout: TimeInterval) +} diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift b/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift index 4d93e8d4d..d57ea8a11 100644 --- a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift +++ b/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift @@ -2,24 +2,82 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // -// New protocol definition +import Foundation + protocol SignalServerEvent: ReflectiveStringConvertible {} -// Extensions for return types -extension Stream_Video_Sfu_Event_JoinRequest: ReflectiveStringConvertible {} -extension Stream_Video_Sfu_Event_Migration: ReflectiveStringConvertible {} -extension Stream_Video_Sfu_Event_ReconnectDetails: ReflectiveStringConvertible {} -extension Stream_Video_Sfu_Event_SfuRequest: ReflectiveStringConvertible {} +// MARK: - Shared SFU Models + extension Stream_Video_Sfu_Models_ClientDetails: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_CallEndedReason: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_CallGrants: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_CallState: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_ClientCapability: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_Codec: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_ConnectionQuality: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_Device: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_GoAwayReason: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_ICETrickle: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_OS: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_Participant: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_ParticipantCount: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_ParticipantSource: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_PeerType: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_Pin: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_PublishOption: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_Sdk: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_SubscribeOption: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_TrackInfo: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_TrackType: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Models_TrackUnpublishReason: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_VideoDimension: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_VideoLayer: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_VideoQuality: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Models_WebsocketReconnectStrategy: ReflectiveStringConvertible {} +extension Stream_Video_Sfu_Signal_TrackSubscriptionDetails: ReflectiveStringConvertible {} + +// MARK: - Events (events.pb.swift) + +extension Stream_Video_Sfu_Event_SfuEvent: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ChangePublishOptions: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ChangePublishOptionsComplete: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ParticipantMigrationComplete: SignalServerEvent {} +extension Stream_Video_Sfu_Event_PinsChanged: SignalServerEvent {} +extension Stream_Video_Sfu_Event_Error: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ICETrickle: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ICERestart: SignalServerEvent {} +extension Stream_Video_Sfu_Event_SfuRequest: SignalServerEvent {} +extension Stream_Video_Sfu_Event_LeaveCallRequest: SignalServerEvent {} +extension Stream_Video_Sfu_Event_HealthCheckRequest: SignalServerEvent {} +extension Stream_Video_Sfu_Event_HealthCheckResponse: SignalServerEvent {} +extension Stream_Video_Sfu_Event_TrackPublished: SignalServerEvent {} +extension Stream_Video_Sfu_Event_TrackUnpublished: SignalServerEvent {} +extension Stream_Video_Sfu_Event_JoinRequest: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ReconnectDetails: SignalServerEvent {} +extension Stream_Video_Sfu_Event_Migration: SignalServerEvent {} +extension Stream_Video_Sfu_Event_JoinResponse: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ParticipantJoined: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ParticipantLeft: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ParticipantUpdated: SignalServerEvent {} +extension Stream_Video_Sfu_Event_SubscriberOffer: SignalServerEvent {} +extension Stream_Video_Sfu_Event_PublisherAnswer: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ConnectionQualityChanged: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ConnectionQualityInfo: SignalServerEvent {} +extension Stream_Video_Sfu_Event_DominantSpeakerChanged: SignalServerEvent {} +extension Stream_Video_Sfu_Event_AudioLevel: SignalServerEvent {} +extension Stream_Video_Sfu_Event_AudioLevelChanged: SignalServerEvent {} +extension Stream_Video_Sfu_Event_AudioSender: SignalServerEvent {} +extension Stream_Video_Sfu_Event_VideoLayerSetting: SignalServerEvent {} +extension Stream_Video_Sfu_Event_VideoSender: SignalServerEvent {} +extension Stream_Video_Sfu_Event_ChangePublishQuality: SignalServerEvent {} +extension Stream_Video_Sfu_Event_CallGrantsUpdated: SignalServerEvent {} +extension Stream_Video_Sfu_Event_GoAway: SignalServerEvent {} +extension Stream_Video_Sfu_Event_CallEnded: SignalServerEvent {} +extension Stream_Video_Sfu_Event_InboundStateNotification: SignalServerEvent {} +extension Stream_Video_Sfu_Event_InboundVideoState: SignalServerEvent {} + +// MARK: - Signal Service Messages + extension Stream_Video_Sfu_Signal_ICERestartResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_ICETrickleResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_SendAnswerRequest: SignalServerEvent {} @@ -30,8 +88,61 @@ extension Stream_Video_Sfu_Signal_SetPublisherResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_StartNoiseCancellationResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_StopNoiseCancellationResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_TrackMuteState: SignalServerEvent {} -extension Stream_Video_Sfu_Signal_TrackSubscriptionDetails: ReflectiveStringConvertible {} extension Stream_Video_Sfu_Signal_UpdateMuteStatesRequest: SignalServerEvent {} extension Stream_Video_Sfu_Signal_UpdateMuteStatesResponse: SignalServerEvent {} extension Stream_Video_Sfu_Signal_UpdateSubscriptionsRequest: SignalServerEvent {} extension Stream_Video_Sfu_Signal_UpdateSubscriptionsResponse: SignalServerEvent {} + +extension Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload: + CustomStringConvertible { + var description: String { + switch self { + case let .subscriberOffer(payload): + return ".subscriberOffer(\(payload))" + case let .publisherAnswer(payload): + return ".publisherAnswer(\(payload))" + case let .connectionQualityChanged(payload): + return ".connectionQualityChanged(\(payload))" + case let .audioLevelChanged(payload): + return ".audioLevelChanged(\(payload))" + case let .iceTrickle(payload): + return ".iceTrickle(\(payload))" + case let .changePublishQuality(payload): + return ".changePublishQuality(\(payload))" + case let .participantJoined(payload): + return ".participantJoined(\(payload))" + case let .participantLeft(payload): + return ".participantLeft(\(payload))" + case let .dominantSpeakerChanged(payload): + return ".dominantSpeakerChanged(\(payload))" + case let .joinResponse(payload): + return ".joinResponse(\(payload))" + case let .healthCheckResponse(payload): + return ".healthCheckResponse(\(payload))" + case let .trackPublished(payload): + return ".trackPublished(\(payload))" + case let .trackUnpublished(payload): + return ".trackUnpublished(\(payload))" + case let .error(payload): + return ".error(\(payload))" + case let .callGrantsUpdated(payload): + return ".callGrantsUpdated(\(payload))" + case let .goAway(payload): + return ".goAway(\(payload))" + case let .iceRestart(payload): + return ".iceRestart(\(payload))" + case let .pinsUpdated(payload): + return ".pinsUpdated(\(payload))" + case let .callEnded(payload): + return ".callEnded(\(payload))" + case let .participantUpdated(payload): + return ".participantUpdated(\(payload))" + case let .participantMigrationComplete(payload): + return ".participantMigrationComplete(\(payload))" + case let .changePublishOptions(payload): + return ".changePublishOptions(\(payload))" + case let .inboundStateNotification(payload): + return ".inboundStateNotification(\(payload))" + } + } +} diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Connecting.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Connecting.swift index e9a7be7a3..dddb74754 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Connecting.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Connecting.swift @@ -116,6 +116,8 @@ extension WebRTCCoordinator.StateMachine.Stage { /// creating a call. /// - updateSession: A Boolean indicating whether to update the /// existing session. + /// - onErrorDisconnect: If `true`, failures move to the disconnected + /// stage instead of error stage. private func execute( create: Bool, ring: Bool, @@ -143,8 +145,10 @@ extension WebRTCCoordinator.StateMachine.Stage { try Task.checkCancellation() - /// The authenticator will fetch a ``JoinCallResponse`` and will use it to - /// create an ``SFUAdapter`` instance that we can later use in our flow. + /// The authenticator will fetch a ``JoinCallResponse`` and use it to + /// create an ``SFUAdapter`` instance that we can later use in our + /// flow. The initial response is preserved so ``Call.join()`` can be + /// completed only once the handshake reaches the connected state. let (sfuAdapter, response) = try await context .authenticator .authenticate( @@ -156,6 +160,8 @@ extension WebRTCCoordinator.StateMachine.Stage { options: options ) + context.initialJoinCallResponse = response + try Task.checkCancellation() /// We provide the ``SFUAdapter`` to the authenticator which will ensure diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Error.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Error.swift index 311b09ffd..219967863 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Error.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Error.swift @@ -46,8 +46,8 @@ extension WebRTCCoordinator.StateMachine.Stage { } /// Handles the transition from the previous stage to this stage. - /// - /// This method defines valid transitions for the `ErrorStage`. + /// If a join is awaiting completion, this also notifies the caller with a + /// failure before entering cleanup. /// /// - Parameter previousStage: The previous stage. /// - Returns: The new stage if the transition is valid, otherwise `nil`. @@ -60,6 +60,11 @@ extension WebRTCCoordinator.StateMachine.Stage { throw ClientError() } + try Task.checkCancellation() + if let joinResponseHandler = context.joinResponseHandler { + joinResponseHandler.send(completion: .failure(error)) + } + try Task.checkCancellation() log.error(error, subsystems: .webRTC) diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift index 94b3f2b4a..0a6c88cb4 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift @@ -56,7 +56,7 @@ extension WebRTCCoordinator.StateMachine.Stage { from previousStage: WebRTCCoordinator.StateMachine.Stage ) -> Self? { switch previousStage.id { - case .joining: + case .joining, .peerConnectionPreparing: execute() return self default: diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift index be2faf575..7cf9ac27b 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine import Foundation import StreamWebRTC @@ -27,6 +28,9 @@ extension WebRTCCoordinator.StateMachine.Stage { final class JoiningStage: WebRTCCoordinator.StateMachine.Stage, @unchecked Sendable { + + @Injected(\.audioStore) private var audioStore + private enum FlowType { case regular, fast, rejoin, migrate } private let disposableBag = DisposableBag() private let startTime = Date() @@ -131,7 +135,7 @@ extension WebRTCCoordinator.StateMachine.Stage { await coordinator.stateAdapter.publisher?.restartICE() } - transitionOrDisconnect(.joined(context)) + transitionToNextStage(context) } catch { context.reconnectionStrategy = context .reconnectionStrategy @@ -192,7 +196,7 @@ extension WebRTCCoordinator.StateMachine.Stage { isFastReconnecting: false ) - transitionOrDisconnect(.joined(context)) + transitionToNextStage(context) } catch { context.reconnectionStrategy = .rejoin transitionDisconnectOrError(error) @@ -251,7 +255,7 @@ extension WebRTCCoordinator.StateMachine.Stage { isFastReconnecting: false ) - transitionOrDisconnect(.joined(context)) + transitionToNextStage(context) } catch { transitionDisconnectOrError(error) } @@ -359,7 +363,7 @@ extension WebRTCCoordinator.StateMachine.Stage { try Task.checkCancellation() if !isFastReconnecting { - try await withThrowingTaskGroup(of: Void.self) { [context] group in + try await withThrowingTaskGroup(of: Void.self) { [weak self, context] group in group.addTask { [context] in /// Configures the audio session for the current call using the provided /// join source. This ensures the session setup reflects whether the @@ -370,7 +374,13 @@ extension WebRTCCoordinator.StateMachine.Stage { ) } - group.addTask { + group.addTask { [weak self] in + /// Before we move on configuring the PeerConnections we need to ensure + /// that the audioSession has been: + /// - released from CallKit (if our source was CallKit) + /// - activated and configured correctly + try await self?.ensureAudioSessionIsReady() + /// Configures all peer connections after the audio session is ready. /// Ensures signaling, media, and routing are correctly established for /// all tracks as part of the join process. @@ -411,6 +421,8 @@ extension WebRTCCoordinator.StateMachine.Stage { joinResponse.fastReconnectDeadlineSeconds ) + try Task.checkCancellation() + reportTelemetry( sessionId: await coordinator.stateAdapter.sessionID, unifiedSessionId: coordinator.stateAdapter.unifiedSessionId, @@ -418,6 +430,37 @@ extension WebRTCCoordinator.StateMachine.Stage { ) } + /// Waits until the audio session is fully ready for call setup. + /// + /// The function waits in parallel for: + /// - `audioStore` to report `isActive == true` + /// - `audioStore` to report a non-empty `currentRoute` + /// within the provided `timeout`. + /// + /// - Parameter timeout: Maximum number of seconds to wait for both + /// conditions before failing. + /// - Throws: If either readiness condition does not arrive before + /// `timeout`. + private func ensureAudioSessionIsReady() async throws { + try await withThrowingTaskGroup(of: Void.self) { [audioStore] group in + group.addTask { + _ = try await audioStore + .publisher(\.isActive) + .filter { $0 } + .nextValue(timeout: WebRTCConfiguration.timeout.audioSessionConfigurationCompletion) + } + + group.addTask { + _ = try await audioStore + .publisher(\.currentRoute) + .filter { $0 != .empty } + .nextValue(timeout: WebRTCConfiguration.timeout.audioSessionConfigurationCompletion) + } + + try await group.waitForAll() + } + } + /// Reports telemetry data to the SFU (Selective Forwarding Unit) to monitor and analyze the /// connection lifecycle. /// @@ -470,5 +513,15 @@ extension WebRTCCoordinator.StateMachine.Stage { } } } + + private func transitionToNextStage(_ context: Context) { + switch context.joinPolicy { + case .default: + reportJoinCompletion() + transitionOrDisconnect(.joined(self.context)) + case .peerConnectionReadinessAware(let timeout): + transitionOrDisconnect(.peerConnectionPreparing(self.context, timeout: timeout)) + } + } } } diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Leaving.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Leaving.swift index 099b39a89..b418f20f2 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Leaving.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Leaving.swift @@ -46,7 +46,7 @@ extension WebRTCCoordinator.StateMachine.Stage { from previousStage: WebRTCCoordinator.StateMachine.Stage ) -> Self? { switch previousStage.id { - case .joined, .disconnected, .connecting, .connected, .joining: + case .joined, .disconnected, .connecting, .connected, .joining, .peerConnectionPreparing: execute() return self default: diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+PeerConnectionPreparing.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+PeerConnectionPreparing.swift new file mode 100644 index 000000000..13170ad11 --- /dev/null +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+PeerConnectionPreparing.swift @@ -0,0 +1,99 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamWebRTC + +extension WebRTCCoordinator.StateMachine.Stage { + + /// Creates the stage that waits briefly for publisher and subscriber peer + /// connections to report `.connected` before the join call completes. + static func peerConnectionPreparing( + _ context: Context, + timeout: TimeInterval + ) -> WebRTCCoordinator.StateMachine.Stage { + PeerConnectionPreparingStage( + context, + timeout: timeout + ) + } +} + +extension WebRTCCoordinator.StateMachine.Stage { + + /// Delays join completion until both peer connections are ready, or until + /// the timeout is reached. + final class PeerConnectionPreparingStage: + WebRTCCoordinator.StateMachine.Stage, + @unchecked Sendable { + + private let disposableBag = DisposableBag() + private let timeout: TimeInterval + + /// Initializes a new instance of `PeerConnectionPreparingStage`. + init( + _ context: Context, + timeout: TimeInterval + ) { + self.timeout = timeout + super.init(id: .peerConnectionPreparing, context: context) + } + + /// Performs the transition from `joining` into the peer-connection + /// preparation stage. + /// - Parameter previousStage: The stage from which the transition is + /// occurring. + /// - Returns: This `PeerConnectionPreparingStage` instance if the + /// transition is + /// valid, otherwise `nil`. + /// - Note: Valid transition from: `.joining` + override func transition( + from previousStage: WebRTCCoordinator.StateMachine.Stage + ) -> Self? { + switch previousStage.id { + case .joining: + Task(disposableBag: disposableBag) { [weak self] in + await self?.execute() + } + return self + default: + return nil + } + } + + // MARK: - Private Helpers + + private func execute() async { + guard + let publisher = await context.coordinator?.stateAdapter.publisher, + let subscriber = await context.coordinator?.stateAdapter.subscriber + else { + return + } + + async let publisherIsReady = try await publisher + .connectionStatePublisher + .filter { $0 == .connected } + .nextValue(timeout: timeout) + async let subscriberIsReady = try await subscriber + .connectionStatePublisher + .filter { $0 == .connected } + .nextValue(timeout: timeout) + + do { + _ = try await [publisherIsReady, subscriberIsReady] + } catch { + log.warning( + "Publisher or subscriber weren't ready in \(timeout) seconds. We continue joining and the connections should be ready after completing.", + subsystems: .webRTC + ) + } + + reportJoinCompletion() + + transitionOrDisconnect(.joined(context)) + } + } +} diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift index 7364c5368..6c705c440 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine import Foundation extension WebRTCCoordinator.StateMachine { @@ -22,6 +23,7 @@ extension WebRTCCoordinator.StateMachine { var disconnectionSource: WebSocketConnectionState.DisconnectionSource? var flowError: Error? var joinSource: JoinSource? + var joinPolicy: WebRTCJoinPolicy = .default var isRejoiningFromSessionID: String? var migratingFromSFU: String = "" @@ -38,6 +40,14 @@ extension WebRTCCoordinator.StateMachine { var webSocketHealthTimeout: TimeInterval = 15 var lastHealthCheckReceivedAt: Date? + /// Stores the initial join response so the pending ``Call.join()`` + /// completion can be finished once the SFU handshake succeeds. + var initialJoinCallResponse: JoinCallResponse? + + /// Completes the pending ``Call.join()`` continuation with either + /// success or failure depending on stage flow outcome. + var joinResponseHandler: PassthroughSubject? + /// Determines the next reconnection strategy based on the current one. /// - Returns: The next reconnection strategy. func nextReconnectionStrategy() -> ReconnectionStrategy { @@ -54,7 +64,7 @@ extension WebRTCCoordinator.StateMachine { enum ID: Hashable, CaseIterable { case idle, connecting, connected, joining, joined, leaving, cleanUp, disconnected, fastReconnecting, fastReconnected, rejoining, - migrating, migrated, error, blocked + migrating, migrated, error, blocked, peerConnectionPreparing } /// The identifier for the current stage. @@ -137,6 +147,24 @@ extension WebRTCCoordinator.StateMachine { transitionOrError(.disconnected(nextStage.context)) } } + + /// Notifies any pending ``Call.join()`` caller with the initial join + /// response + /// and clears pending completion state. + func reportJoinCompletion() { + guard + let joinCallResponse = context.initialJoinCallResponse, + let joinResponseHandler = context.joinResponseHandler + else { + return + } + + joinResponseHandler.send(joinCallResponse) + + // Clean up + context.initialJoinCallResponse = nil + context.joinResponseHandler = nil + } } /// Represents different strategies for reconnection. diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift index de6bc5f5b..085de311f 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift @@ -68,7 +68,7 @@ struct WebRTCAuthenticator: WebRTCAuthenticating { await coordinator.stateAdapter.set( token: response.credentials.token ) - await coordinator.stateAdapter.set(ownCapabilities: Set(response.ownCapabilities)) + await coordinator.stateAdapter.enqueueOwnCapabilities { Set(response.ownCapabilities) } await coordinator.stateAdapter.set(audioSettings: response.call.settings.audio) await coordinator.stateAdapter.set( connectOptions: ConnectOptions( diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCConfiguration.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCConfiguration.swift index 6cb3a42cc..1318ef760 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCConfiguration.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCConfiguration.swift @@ -13,6 +13,7 @@ enum WebRTCConfiguration { var join: TimeInterval var migrationCompletion: TimeInterval var publisherSetUpBeforeNegotiation: TimeInterval + var audioSessionConfigurationCompletion: TimeInterval /// Timeout for authentication in production environment. static let production = Timeout( @@ -20,7 +21,8 @@ enum WebRTCConfiguration { connect: 30, join: 30, migrationCompletion: 10, - publisherSetUpBeforeNegotiation: 2 + publisherSetUpBeforeNegotiation: 2, + audioSessionConfigurationCompletion: 2 ) #if STREAM_TESTS @@ -30,7 +32,8 @@ enum WebRTCConfiguration { connect: 5, join: 5, migrationCompletion: 5, - publisherSetUpBeforeNegotiation: 5 + publisherSetUpBeforeNegotiation: 5, + audioSessionConfigurationCompletion: 5 ) #endif } diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift index 98f1196be..0a07f4467 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift @@ -87,18 +87,32 @@ final class WebRTCCoordinator: @unchecked Sendable { /// Connects to a call with the specified settings and whether to ring. /// /// - Parameters: - /// - callSettings: Optional call settings. - /// - ring: Boolean flag indicating if a ring tone should be played. + /// - callSettings: Optional initial `CallSettings` to apply on join. + /// - options: Optional settings to pass to the join/create request. + /// - ring: Whether a ring tone should be played. + /// - notify: Whether users should be notified about call join. + /// - source: Source that initiated the join. + /// - joinResponseHandler: A subject that receives the join completion + /// result once the flow finishes. func connect( create: Bool = true, callSettings: CallSettings?, options: CreateCallOptions?, ring: Bool, notify: Bool, - source: JoinSource + source: JoinSource, + joinResponseHandler: PassthroughSubject, + policy: WebRTCJoinPolicy = .default ) async throws { + // We update the initial CallSettings so that we have a reference + // on what CallSettings the caller wants to have after the user joins + // the call. await stateAdapter.set(initialCallSettings: callSettings) + stateMachine.currentStage.context.joinSource = source + stateMachine.currentStage.context.joinResponseHandler = joinResponseHandler + stateMachine.currentStage.context.joinPolicy = policy + stateMachine.transition( .connecting( stateMachine.currentStage.context, diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index e5a31158c..34343d2d0 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -67,6 +67,7 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W @Published private(set) var connectOptions: ConnectOptions = .init(iceServers: []) @Published private(set) var ownCapabilities: Set = [] + @Published private(set) var sfuAdapter: SFUAdapter? @Published private(set) var publisher: RTCPeerConnectionCoordinator? @Published private(set) var subscriber: RTCPeerConnectionCoordinator? @@ -216,7 +217,7 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W func set(connectOptions value: ConnectOptions) { self.connectOptions = value } /// Sets the own capabilities of the current user. - func set(ownCapabilities value: Set) { self.ownCapabilities = value } + private func set(ownCapabilities value: Set) { self.ownCapabilities = value } /// Sets the WebRTC stats reporter. func set(statsAdapter value: WebRTCStatsAdapting?) { @@ -597,6 +598,52 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W } } + /// Enqueues an own capabilities update that is applied on the actor’s + /// serial queue. + /// + /// The provided closure returns the new capability set. The updated set is + /// propagated to media adapters, updates call settings when needed, and + /// stops active screen sharing when the screenshare capability is removed. + func enqueueOwnCapabilities( + functionName: StaticString = #function, + fileName: StaticString = #fileID, + lineNumber: UInt = #line, + _ operation: @Sendable @escaping () -> Set + ) async { + let newValue = operation() + set(ownCapabilities: newValue) + + publisher?.didUpdateOwnCapabilities(newValue) + + enqueueCallSettings( + functionName: functionName, + fileName: fileName, + lineNumber: lineNumber + ) { [newValue] callSettings in + var updatedCallSettings = callSettings + + if !newValue.contains(.sendAudio), callSettings.audioOn { + updatedCallSettings = updatedCallSettings + .withUpdatedAudioState(false) + } + + if !newValue.contains(.sendVideo), callSettings.videoOn { + updatedCallSettings = updatedCallSettings + .withUpdatedVideoState(false) + } + + return updatedCallSettings + } + + if !newValue.contains(.screenshare), screenShareSessionProvider.activeSession != nil { + do { + try await publisher?.stopScreenSharing() + } catch { + log.error(error, subsystems: .webRTC) + } + } + } + func trace(_ trace: WebRTCTrace) { if let statsAdapter { statsAdapter.trace(trace) @@ -738,7 +785,23 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W try await audioStore.dispatch([ .setAudioDeviceModule(peerConnectionFactory.audioDeviceModule) ]).result() - + + let sourceIsCallKit = { + guard + let source, + case .callKit = source + else { + return false + } + return true + }() + + if case let .callKit(completion) = source { + // Let CallKit release its audio session ownership once WebRTC has + // the audio device module it needs. + completion.complete() + } + audioSession.activate( callSettingsPublisher: $callSettings.removeDuplicates().eraseToAnyPublisher(), ownCapabilitiesPublisher: $ownCapabilities.removeDuplicates().eraseToAnyPublisher(), @@ -746,7 +809,7 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W statsAdapter: statsAdapter, /// If we are joining from CallKit the AudioSession will be activated from it and we /// shouldn't attempt another activation. - shouldSetActive: source != .callKit + shouldSetActive: !sourceIsCallKit ) } diff --git a/Sources/StreamVideo/WebSockets/Events/Event.swift b/Sources/StreamVideo/WebSockets/Events/Event.swift index 75ac38240..a72f4e70e 100644 --- a/Sources/StreamVideo/WebSockets/Events/Event.swift +++ b/Sources/StreamVideo/WebSockets/Events/Event.swift @@ -38,7 +38,7 @@ extension Event { } /// An internal object that we use to wrap the kind of events that are handled by WS: SFU and coordinator events -internal enum WrappedEvent: Event, Sendable { +internal enum WrappedEvent: Event, Sendable, CustomStringConvertible { case internalEvent(Event) case coordinatorEvent(VideoEvent) case sfuEvent(Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload) @@ -88,11 +88,22 @@ internal enum WrappedEvent: Event, Sendable { var name: String { switch self { case let .coordinatorEvent(event): - return "Coordinator:\(event.type)" + return "coordinator: \(event.type)" case let .sfuEvent(event): - return "SFU:\(event.name)" + return "sfu: \(event.name)" case let .internalEvent(event): - return "Internal:\(event.name)" + return "internal: \(event.name)" + } + } + + var description: String { + switch self { + case let .coordinatorEvent(event): + return "coordinator: \(event)" + case let .sfuEvent(event): + return "sfu: \(event)" + case let .internalEvent(event): + return "internal: \(event)" } } } diff --git a/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift b/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift index df7ae850a..e13d5a376 100644 --- a/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift +++ b/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift @@ -23,11 +23,10 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { postNotifications: Bool = true, completion: (@Sendable () -> Void)? = nil ) { - let processingEventsDebugMessage: () -> String = { - let eventNames = events.map(\.name) - return "Processing webSocket events: \(eventNames)" - } - log.debug(processingEventsDebugMessage(), subsystems: .webSocket) + log.debug( + "Processing webSocket events: \(events)", + subsystems: .webSocket + ) let eventsToPost = events.compactMap { self.middlewares.process(event: $0) diff --git a/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift b/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift new file mode 100644 index 000000000..04bee3c39 --- /dev/null +++ b/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension VideoEvent: ReflectiveStringConvertible {} +extension AppUpdatedEvent: ReflectiveStringConvertible {} +extension BlockedUserEvent: ReflectiveStringConvertible {} +extension CallAcceptedEvent: ReflectiveStringConvertible {} +extension CallClosedCaptionsFailedEvent: ReflectiveStringConvertible {} +extension CallClosedCaptionsStartedEvent: ReflectiveStringConvertible {} +extension CallClosedCaptionsStoppedEvent: ReflectiveStringConvertible {} +extension CallCreatedEvent: ReflectiveStringConvertible {} +extension CallDeletedEvent: ReflectiveStringConvertible {} +extension CallEndedEvent: ReflectiveStringConvertible {} +extension CallFrameRecordingFailedEvent: ReflectiveStringConvertible {} +extension CallFrameRecordingFrameReadyEvent: ReflectiveStringConvertible {} +extension CallFrameRecordingStartedEvent: ReflectiveStringConvertible {} +extension CallFrameRecordingStoppedEvent: ReflectiveStringConvertible {} +extension CallHLSBroadcastingFailedEvent: ReflectiveStringConvertible {} +extension CallHLSBroadcastingStartedEvent: ReflectiveStringConvertible {} +extension CallHLSBroadcastingStoppedEvent: ReflectiveStringConvertible {} +extension CallLiveStartedEvent: ReflectiveStringConvertible {} +extension CallMemberAddedEvent: ReflectiveStringConvertible {} +extension CallMemberRemovedEvent: ReflectiveStringConvertible {} +extension CallMemberUpdatedEvent: ReflectiveStringConvertible {} +extension CallMemberUpdatedPermissionEvent: ReflectiveStringConvertible {} +extension CallMissedEvent: ReflectiveStringConvertible {} +extension CallModerationBlurEvent: ReflectiveStringConvertible {} +extension CallModerationWarningEvent: ReflectiveStringConvertible {} +extension CallNotificationEvent: ReflectiveStringConvertible {} +extension CallReactionEvent: ReflectiveStringConvertible {} +extension CallRecordingFailedEvent: ReflectiveStringConvertible {} +extension CallRecordingReadyEvent: ReflectiveStringConvertible {} +extension CallRecordingStartedEvent: ReflectiveStringConvertible {} +extension CallRecordingStoppedEvent: ReflectiveStringConvertible {} +extension CallRejectedEvent: ReflectiveStringConvertible {} +extension CallRingEvent: ReflectiveStringConvertible {} +extension CallRtmpBroadcastFailedEvent: ReflectiveStringConvertible {} +extension CallRtmpBroadcastStartedEvent: ReflectiveStringConvertible {} +extension CallRtmpBroadcastStoppedEvent: ReflectiveStringConvertible {} +extension CallSessionEndedEvent: ReflectiveStringConvertible {} +extension CallSessionParticipantCountsUpdatedEvent: ReflectiveStringConvertible {} +extension CallSessionParticipantJoinedEvent: ReflectiveStringConvertible {} +extension CallSessionParticipantLeftEvent: ReflectiveStringConvertible {} +extension CallSessionStartedEvent: ReflectiveStringConvertible {} +extension CallStatsReportReadyEvent: ReflectiveStringConvertible {} +extension CallTranscriptionFailedEvent: ReflectiveStringConvertible {} +extension CallTranscriptionReadyEvent: ReflectiveStringConvertible {} +extension CallTranscriptionStartedEvent: ReflectiveStringConvertible {} +extension CallTranscriptionStoppedEvent: ReflectiveStringConvertible {} +extension CallUpdatedEvent: ReflectiveStringConvertible {} +extension CallUserFeedbackSubmittedEvent: ReflectiveStringConvertible {} +extension CallUserMutedEvent: ReflectiveStringConvertible {} +extension ClosedCaptionEvent: ReflectiveStringConvertible {} +extension ConnectedEvent: ReflectiveStringConvertible {} +extension ConnectionErrorEvent: ReflectiveStringConvertible {} +extension CustomVideoEvent: ReflectiveStringConvertible {} +extension HealthCheckEvent: ReflectiveStringConvertible {} +extension KickedUserEvent: ReflectiveStringConvertible {} +extension PermissionRequestEvent: ReflectiveStringConvertible {} +extension UnblockedUserEvent: ReflectiveStringConvertible {} +extension UpdatedCallPermissionsEvent: ReflectiveStringConvertible {} +extension UserUpdatedEvent: ReflectiveStringConvertible {} +extension UserResponse: ReflectiveStringConvertible {} +extension CallParticipantResponse: ReflectiveStringConvertible {} diff --git a/Sources/StreamVideoSwiftUI/CallView/CallControls/Stateful/CallControlsView.swift b/Sources/StreamVideoSwiftUI/CallView/CallControls/Stateful/CallControlsView.swift index 9ad03bad7..e7c4803ba 100644 --- a/Sources/StreamVideoSwiftUI/CallView/CallControls/Stateful/CallControlsView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/CallControls/Stateful/CallControlsView.swift @@ -70,7 +70,10 @@ public struct VideoIconView: View { } public var body: some View { - StatelessVideoIconView(call: viewModel.call) { [weak viewModel] in + StatelessVideoIconView( + call: viewModel.call, + callSettings: viewModel.callSettings + ) { [weak viewModel] in viewModel?.toggleCameraEnabled() } } @@ -94,7 +97,10 @@ public struct MicrophoneIconView: View { } public var body: some View { - StatelessMicrophoneIconView(call: viewModel.call) { [weak viewModel] in + StatelessMicrophoneIconView( + call: viewModel.call, + callSettings: viewModel.callSettings + ) { [weak viewModel] in viewModel?.toggleMicrophoneEnabled() } } @@ -167,7 +173,9 @@ public struct AudioOutputIconView: View { } public var body: some View { - StatelessAudioOutputIconView(call: viewModel.call) { [weak viewModel] in + StatelessAudioOutputIconView( + call: viewModel.call + ) { [weak viewModel] in viewModel?.toggleAudioOutput() } } diff --git a/Sources/StreamVideoSwiftUI/CallView/ScreenSharing/ScreenSharingView.swift b/Sources/StreamVideoSwiftUI/CallView/ScreenSharing/ScreenSharingView.swift index 805dc1256..ab94f3efb 100644 --- a/Sources/StreamVideoSwiftUI/CallView/ScreenSharing/ScreenSharingView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/ScreenSharing/ScreenSharingView.swift @@ -56,7 +56,7 @@ public struct ScreenSharingView: View { VStack(spacing: innerItemSpace) { if !viewModel.hideUIElements, orientationAdapter.orientation.isPortrait || UIDevice.current.isIpad { Text("\(screenSharing.participant.name) presenting") - .foregroundColor(colors.text) + .foregroundColor(colors.white) .padding() .accessibility(identifier: "participantPresentingLabel") } diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index 6aca80f5b..8d50e340d 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -579,12 +579,18 @@ open class CallViewModel: ObservableObject { do { hasAcceptedCall = true try await call.accept() + + // Mirror `joinCall` so the incoming UI is dismissed before + // `enterCall` finishes the async join flow. + await MainActor.run { self.setCallingState(.joining) } + enterCall( call: call, callType: callType, callId: callId, members: [], - customData: customData + customData: customData, + policy: .peerConnectionReadinessAware(timeout: 2) ) } catch { hasAcceptedCall = false @@ -803,7 +809,8 @@ open class CallViewModel: ObservableObject { maxParticipants: Int? = nil, startsAt: Date? = nil, backstage: BackstageSettingsRequest? = nil, - customData: [String: RawJSON]? = nil + customData: [String: RawJSON]? = nil, + policy: WebRTCJoinPolicy = .default ) { if enteringCallTask != nil || callingState == .inCall { return @@ -838,7 +845,8 @@ open class CallViewModel: ObservableObject { create: true, options: options, ring: ring, - callSettings: settings + callSettings: settings, + policy: policy ) save(call: call) enteringCallTask = nil diff --git a/Sources/StreamVideoSwiftUI/Info.plist b/Sources/StreamVideoSwiftUI/Info.plist index dc8ece44d..b4b51650f 100644 --- a/Sources/StreamVideoSwiftUI/Info.plist +++ b/Sources/StreamVideoSwiftUI/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.43.0 + 1.44.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureSourceView.swift b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureSourceView.swift index bb68653c5..24cc660de 100644 --- a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureSourceView.swift +++ b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureSourceView.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine import Foundation import StreamVideo import SwiftUI @@ -10,36 +11,80 @@ import SwiftUI /// very weird if the sourceView isn't in the ViewHierarchy or doesn't have an appropriate size. struct PictureInPictureSourceView: UIViewRepresentable { - @Injected(\.pictureInPictureAdapter) private var pictureInPictureAdapter - var isActive: Bool + static func dismantleUIView( + _ uiView: UIView, + coordinator: Coordinator + ) { + coordinator.dismantle() + } + func makeUIView(context: Context) -> UIView { - let view = UIView() - view.backgroundColor = .clear - if #available(iOS 15.0, *), isActive { - // Once the view has been created/updated make sure to assign it to - // the `StreamPictureInPictureAdapter` in order to allow usage for - // picture-in-picture. - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - pictureInPictureAdapter.sourceView = view - } - } else { - pictureInPictureAdapter.sourceView = nil - } - return view + context.coordinator.view.backgroundColor = .clear + // Apply the initial state here so already-active PiP hosts start + // observing window changes as soon as the view is created. + context.coordinator.update(isActive: isActive) + return context.coordinator.view } func updateUIView(_ uiView: UIView, context: Context) { - if #available(iOS 15.0, *), isActive { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - // Once the view has been created/updated make sure to assign it to - // the `StreamPictureInPictureAdapter` in order to allow usage for - // picture-in-picture. - pictureInPictureAdapter.sourceView = uiView + context.coordinator.update(isActive: isActive) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // MARK: - Private Helpers + + @MainActor + final class Coordinator { + @Injected(\.pictureInPictureAdapter) private var pictureInPictureAdapter + + let view: WindowObservingView = .init() + private var isActive = false + private var cancellable: AnyCancellable? + + func dismantle() { + isActive = false + cancellable?.cancel() + cancellable = nil + publishUpdate(false) + } + + func update(isActive: Bool) { + guard self.isActive != isActive else { return } + + if isActive { + cancellable?.cancel() + cancellable = view + .publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.publishUpdate($0) } + } else { + // Clear the adapter immediately when PiP is turned off so it + // does not retain a stale source view reference. + dismantle() } - } else { - pictureInPictureAdapter.sourceView = nil + + self.isActive = isActive + } + + private func publishUpdate(_ hasWindow: Bool) { + pictureInPictureAdapter + .store? + .dispatch(.setSourceView(hasWindow ? view : nil)) + } + } + + final class WindowObservingView: UIView { + private let windowSubject: CurrentValueSubject = .init(false) + var publisher: AnyPublisher { windowSubject.eraseToAnyPublisher() } + + override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + windowSubject.send(newWindow != nil) } } } diff --git a/Sources/StreamVideoUIKit/Info.plist b/Sources/StreamVideoUIKit/Info.plist index dc8ece44d..b4b51650f 100644 --- a/Sources/StreamVideoUIKit/Info.plist +++ b/Sources/StreamVideoUIKit/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.43.0 + 1.44.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/StreamVideo-XCFramework.podspec b/StreamVideo-XCFramework.podspec index d148d4add..8422bf106 100644 --- a/StreamVideo-XCFramework.podspec +++ b/StreamVideo-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideo-XCFramework' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo iOS Video Client' spec.description = 'StreamVideo is the official Swift client for Stream Video, a service for building video applications.' @@ -24,7 +24,7 @@ Pod::Spec.new do |spec| spec.prepare_command = <<-CMD mkdir -p Frameworks/ - curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/137.0.67/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip + curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/137.0.71/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip unzip -o Frameworks/StreamWebRTC.zip -d Frameworks/ rm Frameworks/StreamWebRTC.zip CMD diff --git a/StreamVideo.podspec b/StreamVideo.podspec index 5980401ea..fd5e1805f 100644 --- a/StreamVideo.podspec +++ b/StreamVideo.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideo' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo iOS Video Client' spec.description = 'StreamVideo is the official Swift client for Stream Video, a service for building video applications.' @@ -25,7 +25,7 @@ Pod::Spec.new do |spec| spec.prepare_command = <<-CMD mkdir -p Frameworks/ - curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/137.0.67/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip + curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/137.0.71/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip unzip -o Frameworks/StreamWebRTC.zip -d Frameworks/ rm Frameworks/StreamWebRTC.zip CMD diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index ad533b24b..e67cb524a 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -283,6 +283,7 @@ Mock/Store/MockMiddleware.swift, Mock/Store/MockReducer.swift, Mock/StreamVideo_Mock.swift, + Mock/StreamVideoCallSession_Mock.swift, "Mock/VideoConfig+Dummy.swift", Mock/WebSocketClientEnvironment_Mock.swift, Mock/WebSocketEngine_Mock.swift, @@ -3313,7 +3314,7 @@ repositoryURL = "https://github.com/GetStream/stream-video-swift-webrtc.git"; requirement = { kind = exactVersion; - version = 137.0.67; + version = 137.0.71; }; }; 40F445C32A9E1D91004BE3DA /* XCRemoteSwiftPackageReference "stream-chat-swift-test-helpers" */ = { diff --git a/StreamVideoArtifacts.json b/StreamVideoArtifacts.json index 970a2569b..7cdf8003b 100644 --- a/StreamVideoArtifacts.json +++ b/StreamVideoArtifacts.json @@ -1 +1 @@ -{"0.4.2":"https://github.com/GetStream/stream-video-swift/releases/download/0.4.2/StreamVideo-All.zip","0.5.0":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.0/StreamVideo-All.zip","0.5.1":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.1/StreamVideo-All.zip","0.5.2":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.2/StreamVideo-All.zip","0.5.3":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.3/StreamVideo-All.zip","1.0.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.0/StreamVideo-All.zip","1.0.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.1/StreamVideo-All.zip","1.0.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.2/StreamVideo-All.zip","1.0.3":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.3/StreamVideo-All.zip","1.0.4":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.4/StreamVideo-All.zip","1.0.5":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.5/StreamVideo-All.zip","1.0.6":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.6/StreamVideo-All.zip","1.0.7":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.7/StreamVideo-All.zip","1.0.8":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.8/StreamVideo-All.zip","1.0.9":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.9/StreamVideo-All.zip","1.10.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.10.0/StreamVideo-All.zip","1.11.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.11.0/StreamVideo-All.zip","1.12.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.12.0/StreamVideo-All.zip","1.13.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.13.0/StreamVideo-All.zip","1.14.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.14.0/StreamVideo-All.zip","1.14.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.14.1/StreamVideo-All.zip","1.15.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.15.0/StreamVideo-All.zip","1.16.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.16.0/StreamVideo-All.zip","1.17.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.17.0/StreamVideo-All.zip","1.18.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.18.0/StreamVideo-All.zip","1.19.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.0/StreamVideo-All.zip","1.19.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.1/StreamVideo-All.zip","1.19.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.2/StreamVideo-All.zip","1.20.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.20.0/StreamVideo-All.zip","1.21.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.21.0/StreamVideo-All.zip","1.21.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.21.1/StreamVideo-All.zip","1.22.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.0/StreamVideo-All.zip","1.22.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.1/StreamVideo-All.zip","1.22.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.2/StreamVideo-All.zip","1.24.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.24.0/StreamVideo-All.zip","1.25.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.25.0/StreamVideo-All.zip","1.26.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.26.0/StreamVideo-All.zip","1.27.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.0/StreamVideo-All.zip","1.27.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.1/StreamVideo-All.zip","1.27.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.2/StreamVideo-All.zip","1.28.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.28.0/StreamVideo-All.zip","1.28.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.28.1/StreamVideo-All.zip","1.29.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.29.0/StreamVideo-All.zip","1.29.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.29.1/StreamVideo-All.zip","1.30.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.30.0/StreamVideo-All.zip","1.31.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.31.0/StreamVideo-All.zip","1.32.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.32.0/StreamVideo-All.zip","1.33.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.33.0/StreamVideo-All.zip","1.34.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.0/StreamVideo-All.zip","1.34.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.1/StreamVideo-All.zip","1.34.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.2/StreamVideo-All.zip","1.35.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.35.0/StreamVideo-All.zip","1.36.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.36.0/StreamVideo-All.zip","1.37.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.37.0/StreamVideo-All.zip","1.38.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.0/StreamVideo-All.zip","1.38.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.1/StreamVideo-All.zip","1.38.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.2/StreamVideo-All.zip","1.39.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.39.0/StreamVideo-All.zip","1.40.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.40.0/StreamVideo-All.zip","1.41.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.41.0/StreamVideo-All.zip","1.42.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.42.0/StreamVideo-All.zip","1.43.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.43.0/StreamVideo-All.zip"} \ No newline at end of file +{"0.4.2":"https://github.com/GetStream/stream-video-swift/releases/download/0.4.2/StreamVideo-All.zip","0.5.0":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.0/StreamVideo-All.zip","0.5.1":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.1/StreamVideo-All.zip","0.5.2":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.2/StreamVideo-All.zip","0.5.3":"https://github.com/GetStream/stream-video-swift/releases/download/0.5.3/StreamVideo-All.zip","1.0.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.0/StreamVideo-All.zip","1.0.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.1/StreamVideo-All.zip","1.0.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.2/StreamVideo-All.zip","1.0.3":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.3/StreamVideo-All.zip","1.0.4":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.4/StreamVideo-All.zip","1.0.5":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.5/StreamVideo-All.zip","1.0.6":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.6/StreamVideo-All.zip","1.0.7":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.7/StreamVideo-All.zip","1.0.8":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.8/StreamVideo-All.zip","1.0.9":"https://github.com/GetStream/stream-video-swift/releases/download/1.0.9/StreamVideo-All.zip","1.10.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.10.0/StreamVideo-All.zip","1.11.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.11.0/StreamVideo-All.zip","1.12.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.12.0/StreamVideo-All.zip","1.13.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.13.0/StreamVideo-All.zip","1.14.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.14.0/StreamVideo-All.zip","1.14.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.14.1/StreamVideo-All.zip","1.15.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.15.0/StreamVideo-All.zip","1.16.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.16.0/StreamVideo-All.zip","1.17.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.17.0/StreamVideo-All.zip","1.18.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.18.0/StreamVideo-All.zip","1.19.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.0/StreamVideo-All.zip","1.19.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.1/StreamVideo-All.zip","1.19.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.19.2/StreamVideo-All.zip","1.20.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.20.0/StreamVideo-All.zip","1.21.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.21.0/StreamVideo-All.zip","1.21.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.21.1/StreamVideo-All.zip","1.22.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.0/StreamVideo-All.zip","1.22.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.1/StreamVideo-All.zip","1.22.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.22.2/StreamVideo-All.zip","1.24.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.24.0/StreamVideo-All.zip","1.25.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.25.0/StreamVideo-All.zip","1.26.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.26.0/StreamVideo-All.zip","1.27.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.0/StreamVideo-All.zip","1.27.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.1/StreamVideo-All.zip","1.27.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.27.2/StreamVideo-All.zip","1.28.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.28.0/StreamVideo-All.zip","1.28.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.28.1/StreamVideo-All.zip","1.29.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.29.0/StreamVideo-All.zip","1.29.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.29.1/StreamVideo-All.zip","1.30.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.30.0/StreamVideo-All.zip","1.31.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.31.0/StreamVideo-All.zip","1.32.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.32.0/StreamVideo-All.zip","1.33.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.33.0/StreamVideo-All.zip","1.34.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.0/StreamVideo-All.zip","1.34.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.1/StreamVideo-All.zip","1.34.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.34.2/StreamVideo-All.zip","1.35.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.35.0/StreamVideo-All.zip","1.36.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.36.0/StreamVideo-All.zip","1.37.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.37.0/StreamVideo-All.zip","1.38.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.0/StreamVideo-All.zip","1.38.1":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.1/StreamVideo-All.zip","1.38.2":"https://github.com/GetStream/stream-video-swift/releases/download/1.38.2/StreamVideo-All.zip","1.39.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.39.0/StreamVideo-All.zip","1.40.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.40.0/StreamVideo-All.zip","1.41.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.41.0/StreamVideo-All.zip","1.42.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.42.0/StreamVideo-All.zip","1.43.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.43.0/StreamVideo-All.zip","1.44.0":"https://github.com/GetStream/stream-video-swift/releases/download/1.44.0/StreamVideo-All.zip"} \ No newline at end of file diff --git a/StreamVideoSwiftUI-XCFramework.podspec b/StreamVideoSwiftUI-XCFramework.podspec index d8d91adbe..ea4006550 100644 --- a/StreamVideoSwiftUI-XCFramework.podspec +++ b/StreamVideoSwiftUI-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideoSwiftUI-XCFramework' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo SwiftUI Video Components' spec.description = 'StreamVideoSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamVideo SDK.' diff --git a/StreamVideoSwiftUI.podspec b/StreamVideoSwiftUI.podspec index c22d82095..0e4e47d0b 100644 --- a/StreamVideoSwiftUI.podspec +++ b/StreamVideoSwiftUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideoSwiftUI' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo SwiftUI Video Components' spec.description = 'StreamVideoSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamVideo SDK.' diff --git a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessMicrophoneIconView_Tests.swift b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessMicrophoneIconView_Tests.swift index b5b824e78..089c877e6 100644 --- a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessMicrophoneIconView_Tests.swift +++ b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessMicrophoneIconView_Tests.swift @@ -94,13 +94,7 @@ final class StatelessMicrophoneIconView_Tests: StreamVideoUITestCase, @unchecked file: file, line: line ) - call.state.update( - from: .dummy( - settings: .dummy( - audio: .dummy(micDefaultOn: micOn) - ) - ) - ) + call.state.update(callSettings: .init(audioOn: micOn)) return .init(call: call, actionHandler: actionHandler) } diff --git a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessSpeakerIconView_Tests.swift b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessSpeakerIconView_Tests.swift index 5a3b0bd07..09e482c32 100644 --- a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessSpeakerIconView_Tests.swift +++ b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessSpeakerIconView_Tests.swift @@ -85,12 +85,10 @@ final class StatelessSpeakerIconView_Tests: StreamVideoUITestCase, @unchecked Se file: file, line: line ) + call.state.update( - from: .dummy( - settings: .dummy( - audio: .dummy(defaultDevice: audioDefaultDevice), - video: .dummy(cameraDefaultOn: cameraOn) - ) + callSettings: .init( + speakerOn: audioDefaultDevice == .speaker || cameraOn ) ) diff --git a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessVideoIconView_Tests.swift b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessVideoIconView_Tests.swift index c080a17ab..d27f2c2d4 100644 --- a/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessVideoIconView_Tests.swift +++ b/StreamVideoSwiftUITests/CallView/CallControls/Stateless/StatelessVideoIconView_Tests.swift @@ -94,15 +94,7 @@ final class StatelessVideoIconView_Tests: StreamVideoUITestCase, @unchecked Send file: file, line: line ) - call.state.update( - from: .dummy( - settings: .dummy( - video: .dummy( - cameraDefaultOn: videoOn - ) - ) - ) - ) + call.state.update(callSettings: .init(videoOn: videoOn)) return .init(call: call, actionHandler: actionHandler) } diff --git a/StreamVideoSwiftUITests/CallView/ScreenSharingView_Tests.swift b/StreamVideoSwiftUITests/CallView/ScreenSharingView_Tests.swift index b614dcf07..d6ab00efe 100644 --- a/StreamVideoSwiftUITests/CallView/ScreenSharingView_Tests.swift +++ b/StreamVideoSwiftUITests/CallView/ScreenSharingView_Tests.swift @@ -6,6 +6,7 @@ import SnapshotTesting @preconcurrency import StreamSwiftTestHelpers @testable import StreamVideo @testable import StreamVideoSwiftUI +import SwiftUI import XCTest final class ScreenSharingView_Tests: StreamVideoUITestCase, @unchecked Sendable { @@ -25,12 +26,15 @@ final class ScreenSharingView_Tests: StreamVideoUITestCase, @unchecked Sendable track: nil, participant: viewModel.participants[1] ) - let view = ScreenSharingView( - viewModel: viewModel, - screenSharing: session, - availableFrame: .init(origin: .zero, size: defaultScreenSize), - isZoomEnabled: false - ) + let view = ZStack { + Color.black.ignoresSafeArea(.all) + ScreenSharingView( + viewModel: viewModel, + screenSharing: session, + availableFrame: .init(origin: .zero, size: defaultScreenSize), + isZoomEnabled: false + ) + } AssertSnapshot(view, variants: snapshotVariants) } } diff --git a/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-dark.png b/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-dark.png index fc96453f7..4206c8785 100644 Binary files a/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-dark.png and b/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-dark.png differ diff --git a/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-light.png b/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-light.png index 838c5a573..4206c8785 100644 Binary files a/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-light.png and b/StreamVideoSwiftUITests/CallView/__Snapshots__/ScreenSharingView_Tests/test_screenSharingView_snapshot.default-light.png differ diff --git a/StreamVideoSwiftUITests/CallViewModel_Tests.swift b/StreamVideoSwiftUITests/CallViewModel_Tests.swift index c2763cd32..8ab946cef 100644 --- a/StreamVideoSwiftUITests/CallViewModel_Tests.swift +++ b/StreamVideoSwiftUITests/CallViewModel_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine @testable import StreamVideo @testable import StreamVideoSwiftUI import StreamWebRTC @@ -113,28 +114,28 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { func test_startCall_joiningState() { // Given let callViewModel = CallViewModel() - + // When callViewModel.startCall(callType: .default, callId: callId, members: participants) - + // Then XCTAssert(callViewModel.outgoingCallMembers == participants) XCTAssert(callViewModel.callingState == .joining) } - + @MainActor func test_startCall_outgoingState() { // Given let callViewModel = CallViewModel() - + // When callViewModel.startCall(callType: .default, callId: callId, members: participants, ring: true) - + // Then XCTAssert(callViewModel.outgoingCallMembers == participants) XCTAssert(callViewModel.callingState == .outgoing) } - + @MainActor func test_outgoingCall_rejectedEvent() async throws { // Given @@ -405,6 +406,55 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { await assertCallingState(.inCall) } + func test_incomingCall_acceptCall_updatesCallingStateToJoiningBeforeInCall() async throws { + // Given + await prepareIncomingCallScenario() + let joiningStateExpectation = expectation( + description: "CallingState becomes joining" + ) + joiningStateExpectation.assertForOverFulfill = false + var cancellable: AnyCancellable? + + // Capture the transient state because `acceptCall` continues into the + // async `enterCall` flow immediately after the acceptance request. + cancellable = subject.$callingState + .dropFirst() + .sink { state in + if state == .joining { + joiningStateExpectation.fulfill() + } + } + defer { cancellable?.cancel() } + + // When + subject.acceptCall(callType: callType, callId: callId) + + // Then + await fulfillment(of: [joiningStateExpectation], timeout: defaultTimeout) + await assertCallingState(.inCall) + } + + func test_incomingCall_acceptCall_usesPeerConnectionReadinessAwarePolicy() async throws { + await prepareIncomingCallScenario() + + subject.acceptCall(callType: callType, callId: callId) + + await assertCallingState(.inCall) + + let recordedInput = try XCTUnwrap(mockCall.stubbedFunctionInput[.join]?.first) + switch recordedInput { + case let .join(_, _, _, _, _, policy): + switch policy { + case .default: + XCTFail() + case let .peerConnectionReadinessAware(timeout): + XCTAssertEqual(timeout, 2) + } + default: + XCTFail() + } + } + func test_incomingCall_acceptedFromSameUserElsewhere_callingStateChangesToIdle() async throws { // Given await prepareIncomingCallScenario() @@ -580,10 +630,27 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 } let joinPayload = try XCTUnwrap( mockCall - .recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)? + .recordedInputPayload( + ( + Bool, + CreateCallOptions?, + Bool, + Bool, + CallSettings?, + WebRTCJoinPolicy + ).self, + for: .join + )? .last ) - let (createFlag, options, ringFlag, notifyFlag, forwardedCallSettings) = joinPayload + let ( + createFlag, + options, + ringFlag, + notifyFlag, + forwardedCallSettings, + _ + ) = joinPayload XCTAssertTrue(createFlag) XCTAssertEqual(options, expectedOptions) XCTAssertFalse(ringFlag) @@ -626,10 +693,20 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { await fulfilmentInMainActor { self.mockCall.timesCalled(.join) == 1 } let joinPayload = try XCTUnwrap( mockCall - .recordedInputPayload((Bool, CreateCallOptions?, Bool, Bool, CallSettings?).self, for: .join)? + .recordedInputPayload( + ( + Bool, + CreateCallOptions?, + Bool, + Bool, + CallSettings?, + WebRTCJoinPolicy + ).self, + for: .join + )? .last ) - let (_, _, _, _, forwardedCallSettings) = joinPayload + let (_, _, _, _, forwardedCallSettings, _) = joinPayload XCTAssertEqual(forwardedCallSettings, expectedCallSettings) XCTAssertEqual( joinPayload.1?.members, @@ -690,7 +767,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { // Then await assertCallingState(.idle) } - + // MARK: - Toggle media state func test_callSettings_toggleCamera() async throws { @@ -728,7 +805,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { // Then await fulfilmentInMainActor { self.subject.callSettings.cameraPosition == .back } } - + // MARK: - Events func test_inCall_participantEvents() async throws { @@ -851,7 +928,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { self.subject.participantsLayout == .fullScreen } } - + // MARK: - Participants func test_participants_layoutIsGrid_validateAllVariants() async throws { @@ -877,7 +954,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 1 ), - + .init( callParticipantsCount: 3, participantsLayout: .grid, @@ -899,7 +976,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 2 ), - + .init( callParticipantsCount: 4, participantsLayout: .grid, @@ -947,7 +1024,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 2 ), - + .init( callParticipantsCount: 3, participantsLayout: .spotlight, @@ -969,7 +1046,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 3 ), - + .init( callParticipantsCount: 4, participantsLayout: .spotlight, @@ -1017,7 +1094,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 2 ), - + .init( callParticipantsCount: 3, participantsLayout: .fullScreen, @@ -1039,7 +1116,7 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { isRemoteScreenSharing: true, expectedCount: 3 ), - + .init( callParticipantsCount: 4, participantsLayout: .fullScreen, diff --git a/StreamVideoSwiftUITests/Utils/PictureInPicture/StreamPictureInPictureAdapterTests.swift b/StreamVideoSwiftUITests/Utils/PictureInPicture/StreamPictureInPictureAdapterTests.swift index f65b61cef..706598e0d 100644 --- a/StreamVideoSwiftUITests/Utils/PictureInPicture/StreamPictureInPictureAdapterTests.swift +++ b/StreamVideoSwiftUITests/Utils/PictureInPicture/StreamPictureInPictureAdapterTests.swift @@ -5,6 +5,7 @@ import Foundation @testable import StreamVideo @testable import StreamVideoSwiftUI +import UIKit import XCTest @available(iOS 15.0, *) @@ -45,6 +46,45 @@ final class StreamPictureInPictureAdapterTests: XCTestCase, @unchecked Sendable await fulfilmentInMainActor { self.subject.store?.state.sourceView === view } } + // MARK: - SourceView coordinator lifecycle + + @MainActor + func test_sourceViewCoordinatorActivated_viewMovesToWindow_storeWasUpdated() async { + let previousPictureInPictureAdapter = InjectedValues[\.pictureInPictureAdapter] + InjectedValues[\.pictureInPictureAdapter] = subject + defer { InjectedValues[\.pictureInPictureAdapter] = previousPictureInPictureAdapter } + + await fulfilmentInMainActor { self.subject.store != nil } + let coordinator = PictureInPictureSourceView.Coordinator() + + coordinator.update(isActive: true) + coordinator.view.willMove(toWindow: UIWindow()) + + await fulfilmentInMainActor { + self.subject.store?.state.sourceView === coordinator.view + } + } + + @MainActor + func test_sourceViewCoordinatorDeactivated_storeSourceViewCleared() async { + let previousPictureInPictureAdapter = InjectedValues[\.pictureInPictureAdapter] + InjectedValues[\.pictureInPictureAdapter] = subject + defer { InjectedValues[\.pictureInPictureAdapter] = previousPictureInPictureAdapter } + + await fulfilmentInMainActor { self.subject.store != nil } + let coordinator = PictureInPictureSourceView.Coordinator() + + coordinator.update(isActive: true) + coordinator.view.willMove(toWindow: UIWindow()) + await fulfilmentInMainActor { + self.subject.store?.state.sourceView === coordinator.view + } + + coordinator.update(isActive: false) + + await fulfilmentInMainActor { self.subject.store?.state.sourceView == nil } + } + // MARK: - ViewFactory updated @MainActor diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index 1125f5047..76aea129a 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -2,11 +2,12 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine @testable import StreamVideo @preconcurrency import XCTest @MainActor -final class Call_Tests: StreamVideoTestCase { +final class Call_Tests: StreamVideoTestCase, @unchecked Sendable { let callType = "default" let callId = "123" @@ -14,6 +15,8 @@ final class Call_Tests: StreamVideoTestCase { let userId = "test" let mockResponseBuilder = MockResponseBuilder() + // MARK: - UpdateState + func test_updateState_fromCallAcceptedEvent() { // Given let call = streamVideo?.call(callType: callType, callId: callId) @@ -166,6 +169,88 @@ final class Call_Tests: StreamVideoTestCase { XCTAssert(call.currentUserHasCapability(.sendVideo) == false) } + func test_updateState_fromPermissionsEvent_fromDifferentUser_doesNotUpdateOwnCapabilities() { + let streamVideo = StreamVideo.mock(httpClient: HTTPClient_Mock()) + self.streamVideo = streamVideo + let call = streamVideo.call(callType: callType, callId: callId) + call.state.ownCapabilities = [.sendVideo] + let userResponse = mockResponseBuilder.makeUserResponse(id: "other-user") + let event = UpdatedCallPermissionsEvent( + callCid: callCid, + createdAt: Date(), + ownCapabilities: [.sendAudio], + user: userResponse + ) + + // When + call.state.updateState(from: .typeUpdatedCallPermissionsEvent(event)) + + // Then + XCTAssert(call.state.ownCapabilities == [.sendVideo]) + } + + func test_updateState_fromPermissionsEvent_usesInitialStreamVideoSessionUser() { + let streamVideo = StreamVideo.mock(httpClient: HTTPClient_Mock()) + self.streamVideo = streamVideo + let call = streamVideo.call(callType: callType, callId: callId) + let initialUserId = streamVideo.state.user.id + let updatedUserId = "updated-user-id" + streamVideo.state.user = User(id: updatedUserId) + + call.state.ownCapabilities = [.sendVideo] + let userResponse = mockResponseBuilder.makeUserResponse(id: initialUserId) + let event = UpdatedCallPermissionsEvent( + callCid: callCid, + createdAt: Date(), + ownCapabilities: [.sendAudio], + user: userResponse + ) + + // When + call.state.updateState(from: .typeUpdatedCallPermissionsEvent(event)) + + // Then + XCTAssertEqual(call.state.ownCapabilities, [.sendAudio]) + } + + func test_updateState_fromCallResponse_usesTokenForRtmpStreamKey() { + let streamVideo = StreamVideo.mock( + httpClient: HTTPClient_Mock(), + callController: CallController.dummy() + ) + self.streamVideo = streamVideo + let call = streamVideo.call(callType: callType, callId: callId) + + call.state.update(from: mockResponseBuilder.makeCallResponse(cid: callCid)) + + XCTAssertEqual(call.state.ingress?.rtmp.streamKey, streamVideo.token.rawValue) + } + + func test_updateState_fromCallResponse_usesUpdatedSessionTokenForRtmpStreamKey() { + let tokenSubject = CurrentValueSubject( + UserToken(rawValue: "initial-stream-session-token") + ) + let streamSession = StreamVideo.CallSession( + user: .dummy(), + token: UserToken(rawValue: "initial-stream-session-token"), + tokenPublisher: tokenSubject.eraseToAnyPublisher() + ) + let state = CallState(streamSession) + + state.update(from: mockResponseBuilder.makeCallResponse(cid: callCid)) + XCTAssertEqual( + state.ingress?.rtmp.streamKey, + "initial-stream-session-token" + ) + + tokenSubject.send(UserToken(rawValue: "refreshed-stream-session-token")) + state.update(from: mockResponseBuilder.makeCallResponse(cid: callCid)) + XCTAssertEqual( + state.ingress?.rtmp.streamKey, + "refreshed-stream-session-token" + ) + } + func test_updateState_fromMemberAddedEvent() { // Given let call = streamVideo?.call(callType: callType, callId: callId) @@ -342,7 +427,7 @@ final class Call_Tests: StreamVideoTestCase { func test_setDisconnectionTimeout_setDisconnectionTimeoutOnCallController() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) call.setDisconnectionTimeout(11) @@ -475,7 +560,7 @@ final class Call_Tests: StreamVideoTestCase { func test_join_callControllerWasCalledOnlyOnce() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) mockCallController.stub(for: .join, with: JoinCallResponse.dummy()) let executionExpectation = expectation(description: "Iteration expectation") @@ -500,25 +585,34 @@ final class Call_Tests: StreamVideoTestCase { func test_join_stateContainsJoinSource_joinSourceWasPassedToCallController() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) mockCallController.stub(for: .join, with: JoinCallResponse.dummy()) + let expectedJoinSource = JoinSource.callKit(.init {}) - call.state.joinSource = .callKit + call.state.joinSource = expectedJoinSource _ = try await call.join() XCTAssertEqual( mockCallController.recordedInputPayload( - (Bool, CallSettings?, CreateCallOptions?, Bool, Bool, JoinSource).self, + ( + Bool, + CallSettings?, + CreateCallOptions?, + Bool, + Bool, + JoinSource, + WebRTCJoinPolicy + ).self, for: .join )?.first?.5, - .callKit + expectedJoinSource ) } func test_join_stateDoesNotJoinSource_joinSourceDefaultsToInAppAndWasPassedToCallController() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) mockCallController.stub(for: .join, with: JoinCallResponse.dummy()) call.state.joinSource = nil @@ -526,13 +620,52 @@ final class Call_Tests: StreamVideoTestCase { XCTAssertEqual( mockCallController.recordedInputPayload( - (Bool, CallSettings?, CreateCallOptions?, Bool, Bool, JoinSource).self, + ( + Bool, + CallSettings?, + CreateCallOptions?, + Bool, + Bool, + JoinSource, + WebRTCJoinPolicy + ).self, for: .join )?.first?.5, .inApp ) } + func test_join_withPolicy_policyWasPassedToCallController() async throws { + let mockCallController = MockCallController() + let call = MockCall(.dummy(callController: mockCallController)) + call.stub(for: \.state, with: .init(.dummy())) + mockCallController.stub(for: .join, with: JoinCallResponse.dummy()) + + _ = try await call.join(policy: .peerConnectionReadinessAware(timeout: 2)) + + let recordedInput = try XCTUnwrap( + mockCallController.recordedInputPayload( + ( + Bool, + CallSettings?, + CreateCallOptions?, + Bool, + Bool, + JoinSource, + WebRTCJoinPolicy + ).self, + for: .join + )?.first + ) + + switch recordedInput.6 { + case .default: + XCTFail() + case let .peerConnectionReadinessAware(timeout): + XCTAssertEqual(timeout, 2) + } + } + // MARK: - updateParticipantsSorting func test_call_customSorting() async throws { @@ -601,7 +734,7 @@ final class Call_Tests: StreamVideoTestCase { func test_enableClientCapabilities_correctlyUpdatesStateAdapter() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) await call.enableClientCapabilities([.subscriberVideoPause]) @@ -619,7 +752,7 @@ final class Call_Tests: StreamVideoTestCase { func test_disableClientCapabilities_correctlyUpdatesStateAdapter() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) await call.disableClientCapabilities([.subscriberVideoPause]) @@ -660,7 +793,7 @@ final class Call_Tests: StreamVideoTestCase { func test_setVideoFilter_moderationVideoAdapterWasUpdated() async { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) let mockVideoFilter = VideoFilter(id: .unique, name: .unique, filter: \.originalImage) call.setVideoFilter(mockVideoFilter) @@ -672,7 +805,7 @@ final class Call_Tests: StreamVideoTestCase { private func assertUpdateState( with steps: [UpdateStateStep], - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let call = try XCTUnwrap( diff --git a/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift b/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift index 7292d3f05..f4c9fe9c6 100644 --- a/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift +++ b/StreamVideoTests/CallKit/CallKitPushNotificationAdapterTests.swift @@ -44,7 +44,7 @@ final class CallKitPushNotificationAdapterTests: XCTestCase, @unchecked Sendable XCTAssertTrue(subject.registry.desiredPushTypes?.isEmpty ?? false) } - func test_unregister_deviceTokenWasConfiguredCorrectly() { + func test_unregister_deviceTokenWasConfiguredCorrectly() async { let expectedDecodedToken = "test-device-token" subject.register() subject.pushRegistry( @@ -53,15 +53,16 @@ final class CallKitPushNotificationAdapterTests: XCTestCase, @unchecked Sendable for: .voIP ) - XCTAssertEqual(subject.deviceToken.decodedHex, expectedDecodedToken) - subject.unregister() + await fulfilmentInMainActor { self.subject.deviceToken.decodedHex == expectedDecodedToken } - XCTAssertTrue(subject.deviceToken.isEmpty) + subject.unregister() + await fulfilmentInMainActor { self.subject.deviceToken.decodedHex.isEmpty } } // MARK: - pushRegistry(_:didUpdate:for:) - func test_pushRegistryDidUpdatePushCredentials_deviceTokenWasConfiguredCorrectly() { + @MainActor + func test_pushRegistryDidUpdatePushCredentials_deviceTokenWasConfiguredCorrectly() async { let expected = "mock-device-token" subject.pushRegistry( subject.registry, @@ -69,7 +70,7 @@ final class CallKitPushNotificationAdapterTests: XCTestCase, @unchecked Sendable for: .voIP ) - XCTAssertEqual(subject.deviceToken.decodedHex, expected) + await fulfilmentInMainActor { self.subject.deviceToken.decodedHex == expected } } // MARK: - pushRegistry(_:didInvalidatePushTokenFor:) @@ -130,7 +131,7 @@ final class CallKitPushNotificationAdapterTests: XCTestCase, @unchecked Sendable _ content: CallKitPushNotificationAdapter.Content? = nil, contentType: PKPushType = .voIP, displayName: String = "", - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async { let pushPayload = MockPKPushPayload() diff --git a/StreamVideoTests/CallKit/CallKitServiceTests.swift b/StreamVideoTests/CallKit/CallKitServiceTests.swift index e2b64963d..1bd56424b 100644 --- a/StreamVideoTests/CallKit/CallKitServiceTests.swift +++ b/StreamVideoTests/CallKit/CallKitServiceTests.swift @@ -465,7 +465,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(call.stubbedFunctionInput[.join]?.count, 1) let input = try XCTUnwrap(call.stubbedFunctionInput[.join]?.first) switch input { - case let .join(_, _, _, _, callSettings): + case let .join(_, _, _, _, callSettings, _): XCTAssertEqual(callSettings, customCallSettings) case .updateTrackSize: XCTFail() @@ -509,7 +509,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(call.stubbedFunctionInput[.join]?.count, 1) let input = try XCTUnwrap(call.stubbedFunctionInput[.join]?.first) switch input { - case let .join(_, _, _, _, callSettings): + case let .join(_, _, _, _, callSettings, _): XCTAssertEqual(callSettings, customCallSettings) case .updateTrackSize: XCTFail() @@ -540,7 +540,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { let call = stubCall(response: defaultGetCallResponse) subject.streamVideo = mockedStreamVideo subject.missingPermissionPolicy = .none - let callStateWithMicOff = CallState() + let callStateWithMicOff = CallState(.dummy()) callStateWithMicOff.callSettings = .init(audioOn: false) call.stub(for: \.state, with: callStateWithMicOff) mockPermissions.stubMicrophonePermission(.denied) @@ -793,7 +793,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { ) ) - let callState = CallState() + let callState = CallState(.dummy()) callState.participants = [.dummy(), .dummy()] call.stub(for: \.state, with: callState) try await assertNotRequestTransaction(CXEndCallAction.self) { @@ -832,7 +832,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { ) ) - let callState = CallState() + let callState = CallState(.dummy()) callState.participants = [.dummy()] call.stub(for: \.state, with: callState) @@ -888,7 +888,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { let firstCallUUID = UUID() uuidFactory.getResult = firstCallUUID let call = stubCall(response: defaultGetCallResponse) - let callState = CallState() + let callState = CallState(.dummy()) callState.callSettings = .init(audioOn: true) call.stub(for: \.state, with: callState) subject.streamVideo = mockedStreamVideo @@ -1123,7 +1123,7 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { ) call.stub(for: .accept, with: AcceptCallResponse(duration: "0")) call.stub(for: .reject, with: RejectCallResponse(duration: "0")) - call.stub(for: \.state, with: .init()) + call.stub(for: \.state, with: .init(.dummy())) mockedStreamVideo.stub(for: .call, with: call) return call } diff --git a/StreamVideoTests/CallState/CallState_Tests.swift b/StreamVideoTests/CallState/CallState_Tests.swift index 96bb6ac9c..5427d7e5b 100644 --- a/StreamVideoTests/CallState/CallState_Tests.swift +++ b/StreamVideoTests/CallState/CallState_Tests.swift @@ -177,7 +177,7 @@ final class CallState_Tests: XCTestCase, @unchecked Sendable { /// Test the execution time of `didUpdate` with many merge/add/remove operations. func test_didUpdate_performanceWithManyParticipants_timeExecutionIsLessThanMaxDuration() { - let subject = CallState() + let subject = CallState(.dummy()) let cycleCount = 250 assertDuration(maxDuration: 5) { @@ -198,16 +198,39 @@ final class CallState_Tests: XCTestCase, @unchecked Sendable { } } + func test_update_fromJoinCallResponse_doesNotOverwriteCallSettings() { + let mockStreamVideo = MockStreamVideo() + let subject = CallState(mockStreamVideo.callSession) + let initialCallSettings = CallSettings( + audioOn: true, + videoOn: false, + speakerOn: false, + audioOutputOn: false, + cameraPosition: .back + ) + subject.update(callSettings: initialCallSettings) + + let remoteCallResponse = CallResponse.dummy( + settings: .dummy( + audio: .dummy(micDefaultOn: false), + video: .dummy(cameraFacing: .front) + ) + ) + subject.update(from: JoinCallResponse.dummy(call: remoteCallResponse)) + + XCTAssertEqual(subject.callSettings, initialCallSettings) + } + // MARK: - Private helpers private func assertParticipantsUpdate( initial: [CallParticipant], update: @escaping (_ initial: [CallParticipant]) -> [CallParticipant], expectedTransformer: @escaping ([CallParticipant]) -> [CallParticipant], - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { - let subject = CallState() + let subject = CallState(.dummy()) subject.participantsMap = initial.reduce([String: CallParticipant]()) { var mutated = $0 mutated[$1.id] = $1 @@ -255,7 +278,7 @@ final class CallState_Tests: XCTestCase, @unchecked Sendable { private func assertDuration( maxDuration: TimeInterval, block: () -> Void, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { let startDate = Date() diff --git a/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_JoiningStageTests.swift b/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_JoiningStageTests.swift index 3d02952dc..8088c1931 100644 --- a/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_JoiningStageTests.swift +++ b/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_JoiningStageTests.swift @@ -116,7 +116,7 @@ final class StreamCallStateMachineStageJoiningStage_Tests: StreamVideoTestCase, } } - func test_execute_withoutRetries_callStateCallSettingsUpdatedWithInput() async throws { + func test_execute_withoutRetries_joinPolicyWasPassedToCallController() async throws { let context = Call.StateMachine.Stage.Context( call: call, input: .join( @@ -127,6 +127,36 @@ final class StreamCallStateMachineStageJoiningStage_Tests: StreamVideoTestCase, ring: true, notify: false, source: .inApp, + deliverySubject: .init(nil), + policy: .peerConnectionReadinessAware(timeout: 2), + retryPolicy: .init(maxRetries: 0, delay: { _ in 0 }) + ) + ) + ) + + try await assertJoining( + context, + expectedTransition: .error + ) { + XCTAssertEqual(self.callController.timesCalled(.join), 1) + try self.validateCallControllerJoinCall(context: context) + } + } + + func test_execute_withoutRetries_callStateCallSettingsPreservedFromBeforeJoin() async throws { + let expectedCallSettings = CallSettings(audioOn: false) + self.call?.state.callSettings = expectedCallSettings + + let context = Call.StateMachine.Stage.Context( + call: call, + input: .join( + .init( + create: true, + callSettings: expectedCallSettings, + options: .init(memberIds: [.unique]), + ring: true, + notify: false, + source: .inApp, deliverySubject: .init(nil) ) ) @@ -137,8 +167,9 @@ final class StreamCallStateMachineStageJoiningStage_Tests: StreamVideoTestCase, joinResponse: JoinCallResponse.dummy(), expectedTransition: .joined ) { @MainActor in + let call = try XCTUnwrap(self.call) XCTAssertEqual(self.callController.timesCalled(.join), 1) - await self.fulfilmentInMainActor { self.call!.state.callSettings == context.input.join?.callSettings } + XCTAssertEqual(call.state.callSettings, expectedCallSettings) } } @@ -394,7 +425,15 @@ final class StreamCallStateMachineStageJoiningStage_Tests: StreamVideoTestCase, iteration: Int = 0, context: Call.StateMachine.Stage.Context ) throws { - let joinInputType = (Bool, CallSettings?, CreateCallOptions?, Bool, Bool, JoinSource).self + let joinInputType = ( + Bool, + CallSettings?, + CreateCallOptions?, + Bool, + Bool, + JoinSource, + WebRTCJoinPolicy + ).self let recordedInput = try XCTUnwrap( callController.recordedInputPayload( joinInputType, @@ -406,6 +445,19 @@ final class StreamCallStateMachineStageJoiningStage_Tests: StreamVideoTestCase, XCTAssertEqual(context.input.join?.options, recordedInput.2) XCTAssertEqual(context.input.join?.ring, recordedInput.3) XCTAssertEqual(context.input.join?.notify, recordedInput.4) + XCTAssertEqual(context.input.join?.source, recordedInput.5) + + switch (context.input.join?.policy, recordedInput.6) { + case (.default, .default): + break + case let ( + .peerConnectionReadinessAware(expectedTimeout)?, + .peerConnectionReadinessAware(recordedTimeout) + ): + XCTAssertEqual(expectedTimeout, recordedTimeout) + default: + XCTFail() + } } } diff --git a/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_RejectingStageTests.swift b/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_RejectingStageTests.swift index 4ef29cf00..5b34dfb45 100644 --- a/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_RejectingStageTests.swift +++ b/StreamVideoTests/CallStateMachine/CallStateMachine/Stages/CallStateMachine_RejectingStageTests.swift @@ -7,40 +7,29 @@ import Dispatch @testable import StreamVideo @preconcurrency import XCTest -@MainActor final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unchecked Sendable { private struct TestError: Error {} private lazy var mockDefaultAPI: MockDefaultAPI! = .init() - private lazy var call: MockCall! = .init(.dummy(coordinatorClient: mockDefaultAPI)) - private lazy var deliverySubject: PassthroughSubject! = .init() - private lazy var allOtherStages: [Call.StateMachine.Stage]! = Call.StateMachine.Stage.ID - .allCases - .filter { $0 != subject.id } - .map { Call.StateMachine.Stage(id: $0, context: .init(call: call)) } + private lazy var deliverySubject: CurrentValueSubject! = .init(nil) private lazy var validOtherStages: Set! = [ .idle, .joined ] private lazy var response: RejectCallResponse! = .init(duration: "10") - private lazy var subject: Call.StateMachine.Stage! = .rejecting( - call, - input: .rejecting(.init(deliverySubject: deliverySubject)) - ) private var transitionedToStage: Call.StateMachine.Stage? override func tearDown() async throws { - call = nil - allOtherStages = nil validOtherStages = nil - subject = nil try await super.tearDown() } // MARK: - Test Initialization - func test_initialization() { + func test_initialization() async { + let (call, subject) = await prepare() + XCTAssertEqual(subject.id, .rejecting) XCTAssertTrue(subject.context.call === call) } @@ -48,6 +37,12 @@ final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unc // MARK: - Test Transition func test_transition() async { + let (call, subject) = await prepare() + let allOtherStages: [Call.StateMachine.Stage]! = Call.StateMachine.Stage.ID + .allCases + .filter { $0 != subject.id } + .map { Call.StateMachine.Stage(id: $0, context: .init(call: call)) } + for nextStage in allOtherStages { if validOtherStages.contains(nextStage.id) { mockDefaultAPI.stub(for: .rejectCall, with: response) @@ -61,16 +56,17 @@ final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unc } } - func test_execute_rejectCallSucceeds_deliverySubjectDeliversResponse() async { + func test_execute_rejectCallSucceeds_deliverySubjectDeliversResponse() async throws { + let (call, subject) = await prepare() + mockDefaultAPI.stub(for: .rejectCall, with: response) let deliveryExpectation = expectation(description: "DeliverySubject delivered value.") let cancellable = deliverySubject + .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { _ in XCTFail() } receiveValue: { XCTAssertEqual($0, self.response) deliveryExpectation.fulfill() } - mockDefaultAPI.stub(for: .rejectCall, with: response) - subject.transition = { self.transitionedToStage = $0 } _ = subject.transition(from: .idle(.init(call: call))) @@ -81,8 +77,10 @@ final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unc } func test_execute_rejectCallFails_deliverySubjectDeliversError() async { + let (call, subject) = await prepare() let deliveryExpectation = expectation(description: "DeliverySubject delivered value.") let cancellable = deliverySubject + .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { switch $0 { @@ -94,7 +92,6 @@ final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unc } } receiveValue: { _ in XCTFail() } mockDefaultAPI.stub(for: .rejectCall, with: ClientError()) - subject.transition = { self.transitionedToStage = $0 } _ = subject.transition(from: .idle(.init(call: call))) @@ -104,12 +101,25 @@ final class CallStateMachineStageRejectingStage_Tests: StreamVideoTestCase, @unc } func test_execute_rejectCallFails_transitionsToError() async { + let (call, subject) = await prepare() mockDefaultAPI.stub(for: .rejectCall, with: ClientError()) - subject.transition = { self.transitionedToStage = $0 } _ = subject.transition(from: .idle(.init(call: call))) await fulfilmentInMainActor { self.transitionedToStage?.id == .error } XCTAssertEqual(mockDefaultAPI.timesCalled(.rejectCall), 1) } + + // MARK: - Private Helpers + + @MainActor + private func prepare() -> (MockCall, Call.StateMachine.Stage) { + let call = MockCall(.dummy(coordinatorClient: mockDefaultAPI)) + let subject: Call.StateMachine.Stage = .rejecting( + call, + input: .rejecting(.init(deliverySubject: deliverySubject)) + ) + subject.transition = { [weak self] in self?.transitionedToStage = $0 } + return (call, subject) + } } diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index 8c198b24e..c753d334d 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -99,6 +99,8 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { func test_joinCall_coordinatorTransitionsToConnecting() async throws { let callSettings = CallSettings(cameraPosition: .back) let options = CreateCallOptions(team: .unique) + let joinPolicy = WebRTCJoinPolicy.peerConnectionReadinessAware(timeout: 2) + let expectedJoinSource = JoinSource.callKit(.init {}) try await assertTransitionToStage( .connecting, @@ -113,7 +115,8 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { options: options, ring: true, notify: true, - source: .callKit + source: expectedJoinSource, + policy: joinPolicy ) } } @@ -122,7 +125,13 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { XCTAssertEqual(expectedStage.options?.team, options.team) XCTAssertTrue(expectedStage.ring) XCTAssertTrue(expectedStage.notify) - XCTAssertEqual(expectedStage.context.joinSource, .callKit) + XCTAssertEqual(expectedStage.context.joinSource, expectedJoinSource) + switch expectedStage.context.joinPolicy { + case .default: + XCTFail() + case let .peerConnectionReadinessAware(timeout): + XCTAssertEqual(timeout, 2) + } await self.assertEqualAsync( await self .mockWebRTCCoordinatorFactory @@ -135,6 +144,148 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { } } + func test_joinCall_whenAuthenticationFails_rethrowsOriginalError() async { + let expectedError = ClientError("auth failed") + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .stub( + for: .authenticate, + with: Result< + (SFUAdapter, JoinCallResponse), + Error + >.failure(expectedError) + ) + + do { + _ = try await subject.joinCall( + create: true, + callSettings: nil, + options: nil, + ring: false, + notify: false, + source: .inApp + ) + XCTFail("Expected joinCall to throw when authentication fails.") + } catch { + if let error = error as? ClientError { + XCTAssertEqual( + error.localizedDescription, + expectedError.localizedDescription + ) + } else { + XCTFail("Expected ClientError to be thrown.") + } + } + XCTAssertEqual( + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .timesCalled(.authenticate), + 1 + ) + } + + func test_joinCall_whenPreviousAttemptFails_thenNextAttemptUsesFreshJoinResponseHandler() async throws { + let expectedError = ClientError("auth failed") + let expectedResponse = JoinCallResponse.dummy(call: .dummy(cid: "test-cid")) + let mockAudioStore = MockRTCAudioStore() + mockAudioStore.makeShared() + defer { mockAudioStore.dismantle() } + + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .stub( + for: .authenticate, + with: Result< + (SFUAdapter, JoinCallResponse), + Error + >.failure(expectedError) + ) + + do { + _ = try await subject.joinCall( + create: true, + callSettings: nil, + options: nil, + ring: false, + notify: false, + source: .inApp + ) + XCTFail("Expected the first join attempt to fail.") + } catch let error as ClientError { + XCTAssertEqual(error.localizedDescription, expectedError.localizedDescription) + } + + await fulfillment { + self + .mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .coordinator + .stateMachine + .currentStage + .id == .idle + } + + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .coordinator + .stateMachine + .currentStage + .context + .authenticator = mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + + mockAudioStore.audioStore.dispatch(.setActive(true)) + mockAudioStore.audioStore.dispatch( + .setCurrentRoute( + .dummy(outputs: [.dummy(isReceiver: true)]) + ) + ) + + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .stub( + for: .authenticate, + with: Result<(SFUAdapter, JoinCallResponse), Error> + .success((mockWebRTCCoordinatorFactory.mockCoordinatorStack.sfuStack.adapter, expectedResponse)) + ) + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .stub( + for: .waitForAuthentication, + with: Result.success(()) + ) + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .webRTCAuthenticator + .stub( + for: .waitForConnect, + with: Result.success(()) + ) + + let joinTask = Task { + try await self.subject.joinCall( + create: true, + callSettings: nil, + options: nil, + ring: false, + notify: false, + source: .inApp + ) + } + + await wait(for: 0.5) + mockWebRTCCoordinatorFactory.mockCoordinatorStack.joinResponse([]) + + let result = try await joinTask.value + XCTAssertEqual(result.call.cid, expectedResponse.call.cid) + } + // MARK: - cleanUp func test_cleanUp_callIsNil() async throws { @@ -245,7 +396,9 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { func test_startScreensharing_typeIsInApp_includeAudioTrue_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueOwnCapabilities { + Set(ownCapabilities) + } let mockPublisher = try await XCTAsyncUnwrap( await mockWebRTCCoordinatorFactory .mockCoordinatorStack @@ -270,7 +423,9 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { func test_startScreensharing_typeIsInApp_includeAudioFalse_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueOwnCapabilities { + Set(ownCapabilities) + } let mockPublisher = try await XCTAsyncUnwrap( await mockWebRTCCoordinatorFactory .mockCoordinatorStack @@ -295,7 +450,9 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { func test_startScreensharing_typeIsBroadcast_includeAudioTrue_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueOwnCapabilities { + Set(ownCapabilities) + } let mockPublisher = try await XCTAsyncUnwrap( await mockWebRTCCoordinatorFactory .mockCoordinatorStack @@ -320,7 +477,9 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { func test_startScreensharing_typeIsBroadcast_includeAudioFalse_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueOwnCapabilities { + Set(ownCapabilities) + } let mockPublisher = try await XCTAsyncUnwrap( await mockWebRTCCoordinatorFactory .mockCoordinatorStack @@ -870,7 +1029,9 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { if let videoFilter { await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(videoFilter: videoFilter) } - await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(ownCapabilities: ownCapabilities) + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueOwnCapabilities { + ownCapabilities + } await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.enqueueCallSettings { _ in callSettings } await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(sessionID: .unique) await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.set(token: .unique) diff --git a/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift b/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift new file mode 100644 index 000000000..5648b985b --- /dev/null +++ b/StreamVideoTests/Controllers/OutgoingRingingController_Tests.swift @@ -0,0 +1,76 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamVideo +@preconcurrency import XCTest + +final class OutgoingRingingController_Tests: StreamVideoTestCase, @unchecked Sendable { + + private lazy var applicationStateAdapter: MockAppStateAdapter! = .init() + private lazy var callType: String! = .default + private lazy var callId: String! = .unique + private lazy var otherCallId: String! = .unique + private var subject: OutgoingRingingController! + + // MARK: - Lifecycle + + override func setUp() { + super.setUp() + applicationStateAdapter.makeShared() + } + + override func tearDown() { + applicationStateAdapter.dismante() + + subject = nil + otherCallId = nil + callId = nil + callType = nil + applicationStateAdapter = nil + super.tearDown() + } + + // MARK: - init + + func test_matchingRingingCall_onBackground_handlerIsCalled() async { + let call = streamVideo.call(callType: callType, callId: callId) + let handlerWasCalled = expectation(description: "Handler was called.") + subject = makeSubject(for: call) { + handlerWasCalled.fulfill() + } + + streamVideo.state.ringingCall = call + applicationStateAdapter.stubbedState = .background + + await fulfillment(of: [handlerWasCalled]) + } + + func test_nonMatchingRingingCall_onBackground_handlerIsNotCalled() async { + let call = streamVideo.call(callType: callType, callId: callId) + let otherCall = streamVideo.call(callType: callType, callId: otherCallId) + let handlerWasCalled = expectation(description: "Handler was called.") + handlerWasCalled.isInverted = true + subject = makeSubject(for: call) { + handlerWasCalled.fulfill() + } + + streamVideo.state.ringingCall = otherCall + applicationStateAdapter.stubbedState = .background + + await fulfillment(of: [handlerWasCalled], timeout: 0.2) + } + + // MARK: - Private Helpers + + private func makeSubject( + for call: Call, + handler: @escaping () async throws -> Void + ) -> OutgoingRingingController { + .init( + streamVideo: streamVideo, + callCiD: call.cId, + handler: handler + ) + } +} diff --git a/StreamVideoTests/IntegrationTests/CallCRUDTests.swift b/StreamVideoTests/IntegrationTests/CallCRUDTests.swift deleted file mode 100644 index 56795780b..000000000 --- a/StreamVideoTests/IntegrationTests/CallCRUDTests.swift +++ /dev/null @@ -1,746 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@preconcurrency import Combine -import Foundation -@testable import StreamVideo -import XCTest - -final class CallCRUDTests: IntegrationTest, @unchecked Sendable { - - let user1 = "thierry" - let user2 = "tommaso" - let defaultCallType = "default" - let apiErrorCode = 16 - let randomCallId = UUID().uuidString - let userIdKey = MemberRequest.CodingKeys.userId.rawValue - - func customWait(nanoseconds duration: UInt64 = 3_000_000_000) async throws { - try await Task.sleep(nanoseconds: duration) - } - - func waitForCapability( - _ capability: OwnCapability, - on call: Call, - granted: Bool = true, - timeout: Double = 20 - ) async -> Bool { - let endTime = Date().timeIntervalSince1970 * 1000 + timeout * 1000 - var userHasRequiredCapability = !granted - while userHasRequiredCapability != granted && endTime > Date().timeIntervalSince1970 * 1000 { - print("Waiting for \(capability.rawValue)") - userHasRequiredCapability = await call.currentUserHasCapability(capability) - } - return userHasRequiredCapability - } - - func waitForAudio( - on call: Call, - timeout: Double = 20 - ) async -> Bool { - let endTime = Date().timeIntervalSince1970 * 1000 + timeout * 1000 - var usersHaveAudio = false - while !usersHaveAudio && endTime > Date().timeIntervalSince1970 * 1000 { - print("Waiting for Audio") - let u1 = await call.state.participants.first!.hasAudio - let u2 = await call.state.participants.last!.hasAudio - usersHaveAudio = u1 && u2 - } - return usersHaveAudio - } - - func waitForAudioLoss( - on call: Call, - timeout: Double = 20 - ) async -> Bool { - let endTime = Date().timeIntervalSince1970 * 1000 + timeout * 1000 - var usersLostAudio = false - while !usersLostAudio && endTime > Date().timeIntervalSince1970 * 1000 { - print("Waiting for Audio Loss") - let u1 = await call.state.participants.first!.hasAudio - let u2 = await call.state.participants.last!.hasAudio - usersLostAudio = u1 == false && u2 == false - } - return usersLostAudio - } - - func waitForPinning( - firstUserCall: Call, - secondUserCall: Call, - timeout: Double = 20 - ) async { - let endTime = Date().timeIntervalSince1970 * 1000 + timeout * 1000 - var userIsPinned = false - while !userIsPinned && endTime > Date().timeIntervalSince1970 * 1000 { - print("Waiting for Pinning") - let pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - userIsPinned = pin != nil - } - } - - func waitForUnpinning( - firstUserCall: Call, - secondUserCall: Call, - timeout: Double = 20 - ) async { - let endTime = Date().timeIntervalSince1970 * 1000 + timeout * 1000 - var userIsUnpinned = false - while !userIsUnpinned && endTime > Date().timeIntervalSince1970 * 1000 { - print("Waiting for Unpinning") - let pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - userIsUnpinned = pin == nil - } - } - - private enum LeaveRouteVariant: String { - case defaultRoute - case speakerEnabled - } - - private func executeLeaveCallLifecycleScenario( - _ routeVariant: LeaveRouteVariant, - cycles: Int = 8 - ) async throws { - for iteration in 0..= joiningDate } - try await secondUserCall.join() - } - - func test_requestPermissionDiscard() async throws { - let firstUserCall = client.call( - callType: String.audioRoom, - callId: randomCallId - ) - try await firstUserCall.create(memberIds: [user1]) - - let secondUserClient = try await makeClient(for: user2) - try await secondUserClient.connect() - let secondUserCall = secondUserClient.call( - callType: String.audioRoom, - callId: firstUserCall.callId - ) - - _ = try await secondUserCall.get() - var hasAudioCapability = await secondUserCall.currentUserHasCapability(.sendAudio) - XCTAssertFalse(hasAudioCapability) - var hasVideoCapability = await secondUserCall.currentUserHasCapability(.sendVideo) - XCTAssertFalse(hasVideoCapability) - - try await secondUserCall.request(permissions: [.sendAudio]) - - await assertNext(firstUserCall.state.$permissionRequests) { value in - value.count == 1 && value.first?.permission == Permission.sendAudio.rawValue - } - if let p = await firstUserCall.state.permissionRequests.first { - p.reject() - } - - // Test: permission requests list is now empty - await assertNext(firstUserCall.state.$permissionRequests) { value in - value.isEmpty - } - - hasAudioCapability = await secondUserCall.currentUserHasCapability(.sendAudio) - XCTAssertFalse(hasAudioCapability) - hasVideoCapability = await secondUserCall.currentUserHasCapability(.sendVideo) - XCTAssertFalse(hasVideoCapability) - } - - func test_pinAndUnpinUser() async throws { - try await client.connect() - let firstUserCall = client.call(callType: .default, callId: randomCallId) - try await firstUserCall.create(memberIds: [user1, user2]) - - let secondUserClient = try await makeClient(for: user2) - let secondUserCall = secondUserClient.call( - callType: .default, - callId: firstUserCall.callId - ) - - try await firstUserCall.join() - try await customWait() - - try await secondUserCall.join() - try await customWait() - - _ = try await firstUserCall.pinForEveryone(userId: user2, sessionId: secondUserCall.state.sessionId) - await waitForPinning(firstUserCall: firstUserCall, secondUserCall: secondUserCall) - - var pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - XCTAssertNotNil(pin) - XCTAssertEqual(pin?.isLocal, false) - - _ = try await firstUserCall.unpinForEveryone(userId: user2, sessionId: secondUserCall.state.sessionId) - await waitForUnpinning(firstUserCall: firstUserCall, secondUserCall: secondUserCall) - - pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - XCTAssertNil(pin) - - try await firstUserCall.pin(sessionId: secondUserCall.state.sessionId) - await waitForPinning(firstUserCall: firstUserCall, secondUserCall: secondUserCall) - - pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - XCTAssertNotNil(pin) - XCTAssertEqual(pin?.isLocal, true) - - try await firstUserCall.unpin(sessionId: secondUserCall.state.sessionId) - await waitForUnpinning(firstUserCall: firstUserCall, secondUserCall: secondUserCall) - - pin = await firstUserCall.state.participantsMap[secondUserCall.state.sessionId]?.pin - XCTAssertNil(pin) - } - - func test_leaveCallRepeatedly_defaultRoute_doesNotCrash() async throws { - try await executeLeaveCallLifecycleScenario(.defaultRoute) - } - - func test_leaveCallRepeatedly_speakerEnabled_doesNotCrash() async throws { - try await executeLeaveCallLifecycleScenario(.speakerEnabled) - } - - // MARK: - Skipped Tests - - func test_muteUserById() async throws { - throw XCTSkip("https://github.com/GetStream/ios-issues-tracking/issues/541") - - let firstUserCall = client.call(callType: String.audioRoom, callId: randomCallId) - try await firstUserCall.create(memberIds: [user1, user2]) - try await firstUserCall.goLive() - - let secondUserClient = try await makeClient(for: user2) - let secondUserCall = secondUserClient.call( - callType: String.audioRoom, - callId: firstUserCall.callId - ) - - try await firstUserCall.join() - try await customWait() - - try await firstUserCall.microphone.enable() - try await customWait() - - try await secondUserCall.join() - try await customWait() - - try await firstUserCall.grant(permissions: [.sendAudio], for: user2) - try await customWait() - - try await secondUserCall.microphone.enable() - try await customWait() - - let everyoneIsUnmutedOnTheFirstCall = await waitForAudio(on: firstUserCall) - let everyoneIsUnmutedOnTheSecondCall = await waitForAudio(on: secondUserCall) - XCTAssertTrue(everyoneIsUnmutedOnTheFirstCall, "Everyone should be unmuted on creator's call") - XCTAssertTrue(everyoneIsUnmutedOnTheSecondCall, "Everyone should be unmuted on participant's call") - - for userId in [user1, user2] { - try await firstUserCall.mute(userId: userId) - } - try await customWait() - - let everyoneIsMutedOnTheFirstCall = await waitForAudioLoss(on: firstUserCall) - let everyoneIsMutedOnTheSecondCall = await waitForAudioLoss(on: secondUserCall) - XCTAssertTrue(everyoneIsMutedOnTheFirstCall, "Everyone should be muted on creator's call") - XCTAssertTrue(everyoneIsMutedOnTheSecondCall, "Everyone should be muted on participant's call") - } - - func test_muteAllUsers() async throws { - throw XCTSkip("https://github.com/GetStream/ios-issues-tracking/issues/541") - - let firstUserCall = client.call(callType: String.audioRoom, callId: randomCallId) - try await firstUserCall.create(memberIds: [user1, user2]) - try await firstUserCall.goLive() - - let secondUserClient = try await makeClient(for: user2) - let secondUserCall = secondUserClient.call( - callType: String.audioRoom, - callId: firstUserCall.callId - ) - - try await firstUserCall.join() - try await customWait() - - try await firstUserCall.microphone.enable() - try await customWait() - - try await secondUserCall.join() - try await customWait() - - try await firstUserCall.grant(permissions: [.sendAudio], for: user2) - try await customWait() - - try await secondUserCall.microphone.enable() - try await customWait() - - let everyoneIsUnmutedOnTheFirstCall = await waitForAudio(on: firstUserCall) - let everyoneIsUnmutedOnTheSecondCall = await waitForAudio(on: secondUserCall) - XCTAssertTrue(everyoneIsUnmutedOnTheFirstCall, "Everyone should be unmuted on creator's call") - XCTAssertTrue(everyoneIsUnmutedOnTheSecondCall, "Everyone should be unmuted on participant's call") - - try await firstUserCall.muteAllUsers() - try await customWait() - - let everyoneIsMutedOnTheFirstCall = await waitForAudioLoss(on: firstUserCall) - let everyoneIsMutedOnTheSecondCall = await waitForAudioLoss(on: secondUserCall) - XCTAssertTrue(everyoneIsMutedOnTheFirstCall, "Everyone should be muted on creator's call") - XCTAssertTrue(everyoneIsMutedOnTheSecondCall, "Everyone should be muted on participant's call") - } - - func test_setAndDeleteVoipDevices() async throws { - throw XCTSkip("https://github.com/GetStream/ios-issues-tracking/issues/541") - - let deviceId = UUID().uuidString - let call = client.call(callType: defaultCallType, callId: randomCallId) - try await call.create(memberIds: [user1, user2]) - - try await call.streamVideo.setVoipDevice(id: deviceId) - try await customWait() - var listDevices = try await call.streamVideo.listDevices() - XCTAssertTrue(listDevices.contains(where: { $0.id == deviceId })) - - try await call.streamVideo.deleteDevice(id: deviceId) - try await customWait() - listDevices = try await call.streamVideo.listDevices() - XCTAssertFalse(listDevices.contains(where: { $0.id == deviceId })) - } - - func test_grantPermissions() async throws { - throw XCTSkip("https://github.com/GetStream/ios-issues-tracking/issues/541") - - let call = client.call(callType: defaultCallType, callId: randomCallId) - try await call.create(memberIds: [user1]) - - let expectedPermissions: [Permission] = [.sendAudio, .sendVideo, .screenshare] - try await call.revoke(permissions: expectedPermissions, for: user1) - - for permission in expectedPermissions { - let capability = try XCTUnwrap(OwnCapability(rawValue: permission.rawValue)) - let userHasRequiredCapability = await waitForCapability(capability, on: call, granted: false) - XCTAssertFalse(userHasRequiredCapability, "\(permission.rawValue) should not be granted") - } - - try await call.grant(permissions: expectedPermissions, for: user1) - - for permission in expectedPermissions { - let capability = try XCTUnwrap(OwnCapability(rawValue: permission.rawValue)) - let userHasRequiredCapability = await waitForCapability(capability, on: call) - XCTAssertTrue(userHasRequiredCapability, "\(permission.rawValue) permission should be granted") - } - } - - func test_grantPermissionsByRequest() async throws { - throw XCTSkip("https://github.com/GetStream/ios-issues-tracking/issues/541") - - let firstUserCall = client.call(callType: String.audioRoom, callId: randomCallId) - try await firstUserCall.create(memberIds: [user1, user2]) - - let secondUserClient = try await makeClient(for: user2) - let secondUserCall = secondUserClient.call( - callType: firstUserCall.callType, - callId: firstUserCall.callId - ) - - refreshStreamVideoProviderKey() - - try await firstUserCall.revoke(permissions: [.sendAudio], for: secondUserClient.user.id) - - var userHasUnexpectedCapability = await waitForCapability(.sendAudio, on: secondUserCall, granted: false) - XCTAssertFalse(userHasUnexpectedCapability) - - try await secondUserCall.request(permissions: [.sendAudio]) - - userHasUnexpectedCapability = await waitForCapability(.sendAudio, on: secondUserCall, granted: false) - XCTAssertFalse(userHasUnexpectedCapability) - - await assertNext(firstUserCall.state.$permissionRequests) { value in - value.count == 1 && value.first?.permission == Permission.sendAudio.rawValue - } - if let p = await firstUserCall.state.permissionRequests.first { - try await firstUserCall.grant(request: p) - } - - let userHasExpectedCapability = await waitForCapability(.sendAudio, on: secondUserCall) - XCTAssertTrue(userHasExpectedCapability) - } -} diff --git a/StreamVideoTests/IntegrationTests/Call_IntegrationTests.swift b/StreamVideoTests/IntegrationTests/Call_IntegrationTests.swift new file mode 100644 index 000000000..e1a8bb8c1 --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Call_IntegrationTests.swift @@ -0,0 +1,1034 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +final class Call_IntegrationTests: XCTestCase, @unchecked Sendable { + + // MARK: - Nested Types + + private enum LeaveRouteVariant: String { case defaultRoute, speakerEnabled } + + // MARK: - Properties + + private var helpers: Call_IntegrationTests.Helpers! = .init(loggingMode: .sdk) + + // MARK: - Lifecycle + + override func tearDown() async throws { + _ = 0 + try await helpers.dismantle() + helpers = nil + try await super.tearDown() + } + + // MARK: - Scenarios + + // MARK: - Add Device + + func test_addDevice_whenANewDeviceIsAdded_thenListDevicesContainsTheNewlyAddedDeviceAsExpected() async throws { + let deviceId = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.client.setDevice(id: deviceId) } + .perform { try await $0.client.listDevices() } + .assert { $0.value.map(\.id).contains(deviceId) } + } + + func test_addDevice_whenANewVoIPDeviceIsAdded_thenListDevicesContainsTheNewlyAddedDeviceAsExpected() async throws { + let deviceId = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.client.setVoipDevice(id: deviceId) } + .perform { try await $0.client.listDevices() } + .assert { $0.value.map(\.id).contains(deviceId) } + } + + // MARK: - Delete Device + + func test_deleteDevice_whenADeviceIsRemoved_thenListDevicesShoulBeUpdatedAsExpected() async throws { + let deviceId = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.client.setDevice(id: deviceId) } + .perform { try await $0.client.deleteDevice(id: deviceId) } + .perform { try await $0.client.listDevices() } + .assert { $0.value.isEmpty } + } + + func test_deleteDevice_whenAVoIPDeviceIsremoved_thenListDevicesShoulBeUpdatedAsExpected() async throws { + let deviceId = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.client.setVoipDevice(id: deviceId) } + .perform { try await $0.client.deleteDevice(id: deviceId) } + .perform { try await $0.client.listDevices() } + .assert { $0.value.isEmpty } + } + + // MARK: Create + + func test_create_callContainsExpectedMembers() async throws { + let user1 = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.id == user1 } + } + + func test_create_whenCreatesCallwithMembersAndMemberIds_thenCallContainsExpectedMembers() async throws { + let user1 = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)], memberIds: [helpers.users.knownUser1]) } + .assertInMainActor { $0.call.state.members.endIndex == 2 } + .perform { try await $0.call.queryMembers() } + .assert { $0.value.members.endIndex == 2 } + } + + // MARK: Update + + func test_update_callWasUpdatedAsExpected() async throws { + let colorKey = "color" + let red: RawJSON = "red" + let blue: RawJSON = "blue" + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create(custom: [colorKey: red]) } + .assertEventually { await $0.call.state.custom[colorKey]?.stringValue == red.stringValue } + .perform { try await $0.call.update(custom: [colorKey: blue]) } + .assert { $0.value.call.custom[colorKey] == blue } + .assertEventually { await $0.call.state.custom[colorKey]?.stringValue == blue.stringValue } + } + + func test_update_whenUpdateExistingMembers_thenCallContainsExpectedMembers() async throws { + let user1 = String.unique + let membersGroup = "stars" + let membersCount: Double = 3 + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.id == user1 } + .perform { + try await $0.call.updateMembers( + members: [.init( + custom: [membersGroup: .number(membersCount)], + userId: user1 + )] + ) + } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.customData[membersGroup]?.numberValue == membersCount } + } + + func test_update_addMembers_whenAddedMemberIsAnAlreadyCreatedUser_thenCallContainsExpectedMembers() async throws { + let user1 = String.unique + let roleKey = "role" + let roleValue = "CEO" + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.id == user1 } + .perform { + try await $0.call.addMembers( + members: [ + .init( + custom: [roleKey: .string(roleValue)], + userId: self.helpers.users.knownUser1 + ) + ] + ) + } + .assertInMainActor { $0.call.state.members.endIndex == 2 } + .assertInMainActor { + $0.call.state.members.first { $0.id == self.helpers.users.knownUser1 }?.customData[roleKey]? + .stringValue == roleValue + } + } + + func test_update_addMembers_whenAddedMemberIsNotAnAlreadyCreatedUser_thenCallUpdateFailsWithExpectedError() async throws { + let user1 = String.unique + let user2 = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.id == user1 } + .performWithErrorExpectation { try await $0.call.addMembers(members: [.init(userId: user2)]) } + .tryMap { $0.value as? APIError } + .assert { $0.value.code == 4 } + .assert { $0.value.message == "UpdateCallMembers failed with error: \"the provided users [\(user2)] don't exist\"" } + } + + func test_update_removeMembers_whenRemoveAValidMember_thenCallContainsExpectedMembers() async throws { + let user1 = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: user1) + .perform { try await $0.call.create(members: [.init(userId: user1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 1 } + .assertInMainActor { $0.call.state.members.first?.id == user1 } + .perform { try await $0.call.addMembers(members: [.init(userId: self.helpers.users.knownUser1)]) } + .assertInMainActor { $0.call.state.members.endIndex == 2 } + .perform { try await $0.call.removeMembers(ids: [self.helpers.users.knownUser1]) } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 1 } + } + + // MARK: Get + + func test_get_whenTheCallHasNotBeenCreated_throwsExpectedError() async throws { + let callId = String.unique + let callType = String.default + let cid = callCid(from: callId, callType: callType) + + try await helpers + .callFlow(id: callId, type: callType, userId: .unique) + .performWithErrorExpectation { try await $0.call.get() } + .tryMap { $0.value as? APIError } + .assert { $0.value.code == 16 } + .assert { $0.value.message == "GetCall failed with error: \"Can't find call with id \(cid)\"" } + } + + func test_get_whenCallTypeIsInvalid_throwsExpectedError() async throws { + let callType = String.unique + + try await helpers + .callFlow(id: .unique, type: callType, userId: .unique) + .performWithErrorExpectation { try await $0.call.get() } + .tryMap { $0.value as? APIError } + .assert { $0.value.code == 16 } + .assert { $0.value.message.localizedStandardContains("\(callType): call type does not exist") } + } + + // MARK: - Subscribe + + func test_subscribe_whenCustomerEventIsBeingSent_thenItShouldBeReceivedCorrectly() async throws { + let customEventKey = String.unique + let customEventValue = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .subscribe(for: CustomVideoEvent.self) + .performWithoutValueOverride { try await $0.call.sendCustomEvent([customEventKey: .string(customEventValue)]) } + .assertEventually { (event: CustomVideoEvent) in event.custom[customEventKey]?.stringValue == customEventValue } + } + + // MARK: - QueryMembers + + func test_queryMembers_whenUsingASecondCallInstanceFromTheSameClient_thenCallContainsTheExpectedMembers() async throws { + let callId = String.unique + let user1 = String.unique + + // Initial CallFlow + _ = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + .perform { try await $0.call.create(memberIds: [user1]) } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 1 } + + // Second CallFlow that uses the existing StreamVideo client + try await helpers + .callFlow(id: callId, type: .default, userId: user1, clientResolutionMode: .default) + .perform { try await $0.call.get(membersLimit: 1) } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 1 } + .perform { try await $0.call.queryMembers() } + .assert { $0.value.members.endIndex == 1 } + .perform { try await $0.call.queryMembers(filters: [MemberRequest.CodingKeys.userId.rawValue: .string(user1)]) } + .assert { $0.value.members.endIndex == 1 } + } + + func test_queryMembers_whenUsingASecondCallInstanceFromDifferentClient_thenCallContainsTheExpectedMembers() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + // Initial CallFlow + _ = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + .perform { try await $0.call.create(memberIds: [user1]) } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 1 } + + // Second CallFlow that uses a new StreamVideo client + try await helpers + .callFlow(id: callId, type: .default, userId: user2) + .perform { try await $0.call.get() } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 1 && $0.call.state.members.first?.user.id == user1 } + .perform { try await $0.call.addMembers(ids: [user2]) } + .assertEventuallyInMainActor { $0.call.state.members.endIndex == 2 } + .perform { try await $0.call.queryMembers(filters: [MemberRequest.CodingKeys.userId.rawValue: .string(user2)]) } + .assertEventuallyInMainActor { $0.value.members.endIndex == 1 } + .perform { try await $0.call.queryMembers(limit: 1) } + .assert { $0.value.members.endIndex == 1 && $0.value.members.first?.userId == user2 } + .perform { (flow: CallFlow) in try await flow.call.queryMembers(next: flow.value.next!) } + .assert { $0.value.members.endIndex == 1 && $0.value.members.first?.userId == user1 } + } + + // MARK: - QueryCalls + + func test_queryCalls_whenQueryForNotCreatedCall_thenReturnsNoResults() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { + try await $0.client.queryCalls( + filters: [CallSortField.cid.rawValue: .string($0.call.cId)], + watch: true + ) + } + .assert { $0.value.calls.isEmpty } + } + + func test_queryCalls_whenQueryForCreatedCall_thenReturnsExpectedResults() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .perform { + try await $0.client.queryCalls( + filters: [CallSortField.cid.rawValue: .string($0.call.cId)], + watch: true + ) + } + .assert { $0.value.calls.endIndex == 1 } + .assert { $0.value.calls.first?.cId == $0.call.cId } + } + + func test_queryCalls_whenQueryForCreatedCallAndThatCallUpdate_thenLocalInstanceGetsUpdatedAsExpected() async throws { + let colorKey = "color" + let colorValue = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .perform { + try await $0.client.queryCalls( + filters: [CallSortField.cid.rawValue: .string($0.call.cId)], + watch: true + ) + } + .assert { $0.value.calls.endIndex == 1 } + .assert { $0.value.calls.first?.cId == $0.call.cId } + .perform { try await $0.call.update(custom: [colorKey: .string(colorValue)]) } + .assertEventuallyInMainActor { $0.call.state.custom[colorKey]?.stringValue == colorValue } + } + + func test_queryCalls_whenQueryForNotEndedCallAndCallHasNotEnded_thenReturnsExpectedResult() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .perform { + try await $0.client.queryCalls( + filters: [ + CallSortField.endedAt.rawValue: .nil, + CallSortField.cid.rawValue: .string($0.call.cId) + ], + watch: true + ) + } + .assert { $0.value.calls.endIndex == 1 } + .assert { $0.value.calls.first?.cId == $0.call.cId } + } + + func test_queryCalls_whenQueryForNotEndedCallAndCallNotEnded_thenReturnsExpectedResult() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .perform { try await $0.call.end() } + .perform { + try await $0.client.queryCalls( + filters: [ + CallSortField.endedAt.rawValue: .nil, + CallSortField.cid.rawValue: .string($0.call.cId) + ], + watch: true + ) + } + .assert { $0.value.calls.isEmpty } + } + + // MARK: - End + + func test_end_whenCreatorEndsCall_thenParticipantAutomaticallyLeaves() async throws { + let callId = String.unique + let creatorUserId = String.unique + let participantUserId = String.unique + helpers.duringDismantleObservedAllCallEnded = false + + let creatorUserFlow = try await helpers + .callFlow(id: callId, type: .default, userId: creatorUserId) + + let participantUserFlow = try await helpers + .callFlow(id: callId, type: .default, userId: participantUserId) + + let creatorFlow = try await creatorUserFlow + .perform { try await $0.call.create(memberIds: [creatorUserId, participantUserId]) } + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await creatorFlow + .perform { try await $0.call.join() } + .subscribe(for: CustomVideoEvent.self) + .assertEventually { (event: CustomVideoEvent) in event.custom["state"] == "joined" } + .perform { try await $0.call.end() } + } + + group.addTask { + try await participantUserFlow + .perform { try await $0.call.join() } + .perform { try await $0.call.sendCustomEvent(["state": "joined"]) } + .assertEventuallyInMainActor { $0.call.streamVideo.state.activeCall == nil } + } + + try await group.waitForAll() + } + } + + // MARK: - SendReactions + + func test_sendReaction_whenSendingReactionWithoutEmojiCode_thenCallReceivesTheReactionAsExpected() async throws { + let reactionType = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .subscribe(for: CallReactionEvent.self) + .performWithoutValueOverride { try await $0.call.sendReaction(type: reactionType) } + .assertEventually { (event: CallReactionEvent) in event.reaction.type == reactionType } + } + + func test_sendReaction_whenSendingReactionWithEmojiCode_thenCallReceivesTheReactionAsExpected() async throws { + let reactionType = String.unique + let emojiCode = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .subscribe(for: CallReactionEvent.self) + .performWithoutValueOverride { try await $0.call.sendReaction(type: reactionType, emojiCode: emojiCode) } + .assertEventually { (event: CallReactionEvent) in + event.reaction.type == reactionType && event.reaction.emojiCode == emojiCode + } + } + + func test_sendReaction_whenSendingReactionWithCustomData_thenCallReceivesTheReactionAsExpected() async throws { + let reactionType = String.unique + let customKey = String.unique + let customValue = String.unique + + try await helpers + .callFlow(id: .unique, type: .default, userId: .unique) + .perform { try await $0.call.create() } + .subscribe(for: CallReactionEvent.self) + .performWithoutValueOverride { try await $0.call.sendReaction( + type: reactionType, + custom: [customKey: .string(customValue)] + ) } + .assertEventually { (event: CallReactionEvent) in + event.reaction.type == reactionType && event.reaction.custom?[customKey]?.stringValue == customValue + } + } + + // MARK: - Block + + func test_block_whenUserGetsBlocked_thenCallStateUpdatesAsExpected() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: helpers.users.knownUser1) + .perform { try await $0.call.create(memberIds: [helpers.users.knownUser1, helpers.users.knownUser2]) } + .perform { try await $0.call.blockUser(with: helpers.users.knownUser2) } + .assertEventuallyInMainActor { $0.call.state.blockedUserIds.contains(helpers.users.knownUser2) } + .perform { try await $0.call.queryMembers() } + .assert { $0.value.members.endIndex == 2 } + .perform { try await $0.call.get() } + .assert { $0.value.call.blockedUserIds.contains(helpers.users.knownUser2) } + } + + // MARK: - Unblock + + func test_unblock_whenUserGetsBlocked_thenCallStateUpdatesAsExpected() async throws { + try await helpers + .callFlow(id: .unique, type: .default, userId: helpers.users.knownUser1) + .perform { try await $0.call.create(memberIds: [helpers.users.knownUser1, helpers.users.knownUser2]) } + .perform { try await $0.call.blockUser(with: helpers.users.knownUser2) } + .assertEventuallyInMainActor { $0.call.state.blockedUserIds.contains(helpers.users.knownUser2) } + .perform { try await $0.call.unblockUser(with: helpers.users.knownUser2) } + .assertEventuallyInMainActor { $0.call.state.blockedUserIds.isEmpty } + .perform { try await $0.call.queryMembers() } + .assert { $0.value.members.endIndex == 2 } + .perform { try await $0.call.get() } + .assert { $0.value.call.blockedUserIds.isEmpty } + } + + // MARK: - Accept + + func test_accept_whenUserAcceptsTheCall_thenCallStateUpdatesForAllParticipantsAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2], ring: true) } + .assertEventuallyInMainActor { $0.call.state.session?.acceptedBy[user2] != nil } + } + + group.addTask { + try await user2CallFlow + .perform { $0.client.subscribe(for: CallRingEvent.self) } + .assertEventually { (event: CallRingEvent) in event.call.id == callId } + .perform { try await $0.call.get() } + .perform { try await $0.call.accept() } + .assertEventuallyInMainActor { $0.call.state.session?.acceptedBy[user2] != nil } + } + + try await group.waitForAll() + } + } + + // MARK: - Notify + + func test_notify_whenNotifyEventIsBeingSent_thenOtherParticipantsReceiveTheEventAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + + let user1CallFlowAfterCallCreation = try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2]) } + + let user2CallFlowAfterCallCreation = try await user2CallFlow + .perform { try await $0.call.get() } + .subscribe(for: CallNotificationEvent.self) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlowAfterCallCreation + .perform { try await $0.call.notify() } + } + + group.addTask { + try await user2CallFlowAfterCallCreation + .assertEventually { (event: CallNotificationEvent) in + event.call.id == callId && event.members.map(\.userId).contains(user2) + } + } + + try await group.waitForAll() + } + } + + // MARK: - Join + + // MARK: Livestream + + func test_join_livestream_whenCallIsInBackstageOnlyHostCanJoin_thenAnyOtherParticipantShouldFailToJoin() async throws { + let callId = String.unique + let participant = String.unique + + try await helpers + .callFlow(id: callId, type: .livestream, userId: .unique, environment: "demo") + .perform { try await $0.call.create(backstage: .init(enabled: true)) } + .perform { try await $0.call.join() } + + try await helpers + .callFlow(id: callId, type: .livestream, userId: participant, environment: "demo") + .performWithErrorExpectation { try await $0.call.join() } + .tryMap { $0.value as? APIError } + .assert { $0.value.code == 17 } + .assert { + $0.value + .message == + "JoinCall failed with error: \"User '\(participant)' with role 'user' is not allowed to perform action JoinBackstage in scope 'video:livestream'\"" + } + .perform { _ in + NotificationCenter + .default + .publisher(for: Notification.Name(CallNotification.callEnded)) + .map { _ in true } + .eraseToAnyPublisher() + } + .assertEventually { _ in true } + } + + func test_join_livestream_whenCallIsInBackstage_thenOnlyCreatorAndOtherHostsCanJoin() async throws { + let callId = String.unique + let creator = String.unique + let otherHost = String.unique + let participant = String.unique + + let creatorCallFlow = try await helpers + .callFlow(id: callId, type: .livestream, userId: creator, environment: "demo") + + let otherHostCallFlow = try await helpers + .callFlow(id: callId, type: .livestream, userId: otherHost, environment: "demo") + + _ = try await creatorCallFlow + .perform { try await $0.call.create( + members: [creator, otherHost].map { MemberRequest.init(role: "admin", userId: $0) }, + backstage: .init(enabled: true) + ) } + .perform { try await $0.call.join() } + + try await otherHostCallFlow + .perform { try await $0.call.join() } + + try await helpers + .callFlow(id: callId, type: .livestream, userId: participant, environment: "demo") + .performWithErrorExpectation { try await $0.call.join() } + .tryMap { $0.value as? APIError } + .assert { $0.value.code == 17 } + .assert { + $0.value + .message == + "JoinCall failed with error: \"User '\(participant)' with role 'user' is not allowed to perform action JoinBackstage in scope 'video:livestream'\"" + } + } + + func test_join_livestream_whenCallIsInBackstageOnlyHostCanJoin_thenAfterCallGoesLiveAnyOtherParticipantCanJoin() async throws { + let callId = String.unique + let participant = String.unique + let joinAheadTimeSeconds: Double = 10 + let startingDate = Date(timeIntervalSinceNow: joinAheadTimeSeconds * 2) + let joiningDate = Date(timeIntervalSinceNow: joinAheadTimeSeconds + 2) + + try await helpers + .callFlow(id: callId, type: .livestream, userId: .unique, environment: "demo") + .perform { + try await $0.call.create( + startsAt: startingDate, + backstage: .init( + enabled: true, + joinAheadTimeSeconds: Int(joinAheadTimeSeconds) + ) + ) + } + .perform { try await $0.call.join() } + + try await self + .helpers + .callFlow(id: callId, type: .livestream, userId: participant, environment: "demo") + .performWithErrorExpectation { try await $0.call.join() } + .assertEventually { _ in Date() >= joiningDate } + .perform { try await $0.call.join() } + } + + // MARK: AudioRoom + + func test_join_audioRoom_whenAParticipantIsGrantedPermissionsToSpeak_thenTheirCallStateUpdatesWithExpectedCapabilities( + ) async throws { + let callId = String.unique + let host = String.unique + + let hostCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: host, environment: "demo") + .perform { try await $0.call.create(memberIds: [host], backstage: .init(enabled: false)) } + .perform { try await $0.call.join() } + + let participantCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: .unique, environment: "demo") + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await hostCallFlow + .assertEventuallyInMainActor { $0.call.state.permissionRequests.endIndex == 1 } + .tryMap { await $0.call.state.permissionRequests.first } + .perform { try await $0.call.grant(request: $0.value) } + } + + group.addTask { + try await participantCallFlow + .perform { try await $0.call.join() } + .assertInMainActor { $0.call.currentUserHasCapability(.sendAudio) == false } + .assertInMainActor { $0.call.currentUserHasCapability(.sendVideo) == false } + .perform { try await $0.call.request(permissions: [.sendAudio]) } + .assertEventuallyInMainActor { $0.call.currentUserHasCapability(.sendAudio) } + } + + try await group.waitForAll() + } + } + + func test_join_audioRoom_whenAParticipanRequestsPermissionToSpeakAndGetsRejected_thenTheirCallStateDoesNotUpdate() async throws { + let callId = String.unique + let host = String.unique + + let hostCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: host, environment: "demo") + .perform { try await $0.call.create(memberIds: [host], backstage: .init(enabled: false)) } + .perform { try await $0.call.join() } + + let participantCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: .unique, environment: "demo") + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await hostCallFlow + .assertEventuallyInMainActor { $0.call.state.permissionRequests.endIndex == 1 } + .tryMap { await $0.call.state.permissionRequests.first } + .perform { $0.value.reject() } + } + + group.addTask { + try await participantCallFlow + .perform { try await $0.call.join() } + .assertInMainActor { $0.call.currentUserHasCapability(.sendAudio) == false } + .assertInMainActor { $0.call.currentUserHasCapability(.sendVideo) == false } + .perform { try await $0.call.request(permissions: [.sendAudio]) } + .delay(2) + .assertInMainActor { $0.call.currentUserHasCapability(.sendAudio) == false } + } + + try await group.waitForAll() + } + } + + func test_join_audioRoom_whenAParticipantPermissionGetsRevoked_thenTheirCallStateUpdatesWithExpectedCapabilities() async throws { + let callId = String.unique + let host = "host" + let participant = "participant" + + let hostCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: host) + .perform { try await $0.call.create(members: [.init(role: "host", userId: host)], backstage: .init(enabled: false)) } + .perform { try await $0.call.join() } + .assertEventuallyInMainActor { $0.call.state.ownCapabilities.contains(.updateCallPermissions) } + + let participantCallFlow = try await helpers + .callFlow(id: callId, type: .audioRoom, userId: participant) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await hostCallFlow + .assertEventuallyInMainActor { $0.call.state.permissionRequests.endIndex == 1 } + .tryMap { await $0.call.state.permissionRequests.first } + .perform { try await $0.call.grant(request: $0.value) } + .delay(2) + .perform { try await $0.call.revoke(permissions: [.sendAudio], for: participant) } + } + + group.addTask { + try await participantCallFlow + .perform { try await $0.call.join() } + .assertEventuallyInMainActor { $0.call.state.participants.endIndex == 2 } + .assertInMainActor { $0.call.currentUserHasCapability(.sendAudio) == false } + .perform { try await $0.call.request(permissions: [.sendAudio]) } + .assertEventuallyInMainActor { $0.call.currentUserHasCapability(.sendAudio) } + .assertEventuallyInMainActor { $0.call.currentUserHasCapability(.sendAudio) == false } + } + + try await group.waitForAll() + } + } + + // MARK: - Pin + + func test_pin_whenUserGetsPinnedForEveryone_thenCallStateOfAllParticipantsUpdatesAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + .assertEventuallyInMainActor { $0.call.state.sessionId.isEmpty == false } + + let user1CallFlowAfterCallCreation = try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2]) } + .perform { try await $0.call.join() } + + let user2SessionId = await user2CallFlow.call.state.sessionId + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlowAfterCallCreation + .subscribe(for: CustomVideoEvent.self) + .assertEventually { (event: CustomVideoEvent) in event.custom["state"] == "joined" } + .perform { try await $0.call.pinForEveryone(userId: user2, sessionId: user2SessionId) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin != nil } + } + + group.addTask { + try await user2CallFlow + .perform { try await $0.call.join() } + .perform { try await $0.call.sendCustomEvent(["state": "joined"]) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin != nil } + } + + try await group.waitForAll() + } + } + + func test_pin_whenUserGetsPinnedLocally_thenCallStateOfLocalParticipantOnlyUpdatesAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + .assertEventuallyInMainActor { $0.call.state.sessionId.isEmpty == false } + + let user1CallFlowAfterCallCreation = try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2]) } + .perform { try await $0.call.join() } + + let user2SessionId = await user2CallFlow.call.state.sessionId + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlowAfterCallCreation + .subscribe(for: CustomVideoEvent.self) + .assertEventually { (event: CustomVideoEvent) in event.custom["state"] == "joined" } + .perform { try await $0.call.pin(sessionId: user2SessionId) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin?.isLocal == true } + } + + group.addTask { + try await user2CallFlow + .perform { try await $0.call.join() } + .perform { try await $0.call.sendCustomEvent(["state": "joined"]) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin == nil } + } + + try await group.waitForAll() + } + } + + // MARK: - Unpin + + func test_pin_whenUserGetsUnpinnedForEveryone_thenCallStateOfAllParticipantsUpdatesAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + .assertEventuallyInMainActor { $0.call.state.sessionId.isEmpty == false } + + let user1CallFlowAfterCallCreation = try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2]) } + .perform { try await $0.call.join() } + + let user2SessionId = await user2CallFlow.call.state.sessionId + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlowAfterCallCreation + .subscribe(for: CustomVideoEvent.self) + .assertEventually { (event: CustomVideoEvent) in event.custom["state"] == "joined" } + .perform { try await $0.call.pinForEveryone(userId: user2, sessionId: user2SessionId) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin != nil } + .perform { try await $0.call.unpinForEveryone(userId: user2, sessionId: user2SessionId) } + } + + group.addTask { + try await user2CallFlow + .perform { try await $0.call.join() } + .perform { try await $0.call.sendCustomEvent(["state": "joined"]) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin != nil } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin == nil } + } + + try await group.waitForAll() + } + } + + func test_pin_whenUserGetsUnpinnedLocally_thenCallStateOfLocalParticipantOnlyUpdatesAsExpected() async throws { + let callId = String.unique + let user1 = String.unique + let user2 = String.unique + + let user1CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user1) + + let user2CallFlow = try await helpers + .callFlow(id: callId, type: .default, userId: user2) + .assertEventuallyInMainActor { $0.call.state.sessionId.isEmpty == false } + + let user1CallFlowAfterCallCreation = try await user1CallFlow + .perform { try await $0.call.create(memberIds: [user1, user2]) } + .perform { try await $0.call.join() } + + let user2SessionId = await user2CallFlow.call.state.sessionId + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await user1CallFlowAfterCallCreation + .subscribe(for: CustomVideoEvent.self) + .assertEventually { (event: CustomVideoEvent) in event.custom["state"] == "joined" } + .perform { try await $0.call.pin(sessionId: user2SessionId) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin?.isLocal == true } + .perform { try await $0.call.unpin(sessionId: user2SessionId) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin == nil } + } + + group.addTask { + try await user2CallFlow + .perform { try await $0.call.join() } + .perform { try await $0.call.sendCustomEvent(["state": "joined"]) } + .assertEventuallyInMainActor { $0.call.state.participantsMap[user2SessionId]?.pin == nil } + } + + try await group.waitForAll() + } + } + + // MARK: - Leave + + func test_leaveCallRepeatedly_defaultRoute_doesNotCrash() async throws { + try await executeLeaveCallLifecycleScenario(.defaultRoute) + } + + func test_leaveCallRepeatedly_speakerEnabled_doesNotCrash() async throws { + try await executeLeaveCallLifecycleScenario(.speakerEnabled) + } + + private func executeLeaveCallLifecycleScenario( + _ routeVariant: LeaveRouteVariant, + cycles: Int = 8 + ) async throws { + let userId = String.unique + + for _ in 0.. String = "", + _ condition: @Sendable () async throws -> Bool + ) async throws { + let result = try await condition() + XCTAssertTrue(result, message(), file: file, line: line) + } + + static func assertEventually( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + file: StaticString = #filePath, + line: UInt = #line, + _ message: @autoclosure () -> String = "", + _ condition: @Sendable () async throws -> Bool + ) async throws { + do { + try await Retry.waitUntil( + timeout: timeout, + interval: interval, + operation: condition + ) + } catch let FlowError.timeout(timeoutMessage) { + XCTAssertTrue( + false, + message().isEmpty ? timeoutMessage : message(), + file: file, + line: line + ) + } catch { + throw error + } + } + + static func assertFromAsyncStream( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + message: @autoclosure () -> String = "", + stream: AsyncStream, + operation: @Sendable @escaping (Element) async throws -> Bool + ) async throws { + let timeout = max(0, timeout) + let result = try await Task( + timeoutInSeconds: timeout, + file: fileID, + line: line + ) { + for await element in stream { + if try await operation(element) { + return true + } + } + return false + }.value + + guard !result else { + return + } + + XCTAssertTrue( + false, + message(), + file: filePath, + line: line + ) + } + + static func assertFromAsyncStream( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + message: @autoclosure () -> String = "", + stream: AsyncThrowingStream, + operation: @Sendable @escaping (Element) async throws -> Bool + ) async throws { + let timeout = max(0, timeout) + let result = try await Task( + timeoutInSeconds: timeout, + file: fileID, + line: line + ) { + for try await element in stream { + if try await operation(element) { + return true + } + } + return false + }.value + + guard !result else { + return + } + + XCTAssertTrue( + false, + message(), + file: filePath, + line: line + ) + } + + static func assertFromPublisher( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + message: @autoclosure () -> String = "", + publisher: AnyPublisher, + operation: @Sendable @escaping (Output) async throws -> Bool + ) async throws { + try await Self.assertFromAsyncStream( + timeout: timeout, + interval: interval, + fileID: fileID, + filePath: filePath, + line: line, + message: message(), + stream: publisher.eraseAsAsyncThrowingStream(), + operation: operation + ) + } + } +} + +extension Call_IntegrationTests.Assertions { + + fileprivate enum FlowError: Error { + case assertionFailed(String) + case timeout(String) + } + + fileprivate enum Retry { + static func waitUntil( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + operation: @Sendable () async throws -> Bool + ) async throws { + let timeout = max(0, timeout) + let safeInterval = max(0, interval) + let sleepNanoseconds = UInt64(safeInterval * 1_000_000_000) + let deadline = Date().timeIntervalSince1970 + timeout + + while true { + if try await operation() { return } + if Date().timeIntervalSince1970 >= deadline { + throw FlowError.timeout("Condition not satisfied within \(timeout)s") + } + if sleepNanoseconds > 0 { + try await Task.sleep(nanoseconds: sleepNanoseconds) + } else { + await Task.yield() + } + } + } + } +} + +private extension Publisher where Output: Sendable { + + func eraseAsAsyncThrowingStream() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let cancellable = sink( + receiveCompletion: { + switch $0 { + case .finished: + continuation.finish() + case let .failure(error): + continuation.finish(throwing: error) + } + }, + receiveValue: { + continuation.yield($0) + } + ) + + continuation.onTermination = { @Sendable _ in + cancellable.cancel() + } + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Call_IntegrationTests+CallFlow.swift b/StreamVideoTests/IntegrationTests/Components/Call_IntegrationTests+CallFlow.swift new file mode 100644 index 000000000..0b29593ec --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Call_IntegrationTests+CallFlow.swift @@ -0,0 +1,210 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamVideo +import XCTest + +extension Call_IntegrationTests { + final class CallFlow: Sendable { + let call: Call + let client: StreamVideo + let value: Result + + convenience init( + client: StreamVideo, + call: Call + ) where Result == Void { + self.init( + client: client, + call: call, + value: () + ) + } + + init( + client: StreamVideo, + call: Call, + value: Result + ) { + self.call = call + self.value = value + self.client = client + } + + /// Generic step: output becomes next flow payload. + @discardableResult + func perform( + _ operation: @Sendable (_ flow: CallFlow) async throws -> Next + ) async throws -> CallFlow { + let nextValue = try await operation(self) + return .init( + client: client, + call: call, + value: nextValue + ) + } + + @discardableResult + func performWithoutValueOverride( + _ operation: @Sendable (_ flow: Self) async throws -> Void + ) async throws -> Self { + try await operation(self) + return self + } + + @discardableResult + func performWithErrorExpectation( + file: StaticString = #fileID, + line: UInt = #line, + _ operation: @Sendable (_ flow: CallFlow) async throws -> Value + ) async throws -> CallFlow { + do { + _ = try await operation(self) + } catch { + return .init( + client: client, + call: call, + value: error + ) + } + throw ClientError("Flow is expected to fail", file, line) + } + + @discardableResult + func assert( + file: StaticString = #filePath, + line: UInt = #line, + _ message: @autoclosure () -> String = "", + _ condition: @Sendable (_ flow: CallFlow) async throws -> Bool + ) async throws -> Self { + try await Assertions.assert(file: file, line: line, message()) { + try await condition(self) + } + return self + } + + @discardableResult + func assertInMainActor( + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @MainActor @Sendable (_ flow: CallFlow) async throws -> Bool, + _ message: @autoclosure () -> String = "" + ) async throws -> Self { + try await Assertions.assert(file: file, line: line, message()) { + try await condition(self) + } + return self + } + + @discardableResult + func assertEventually( + timeout: TimeInterval = defaultTimeout, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @Sendable (_ flow: CallFlow) async throws -> Bool + ) async throws -> Self { + try await Assertions.assertEventually(timeout: timeout, file: file, line: line) { + try await condition(self) + } + return self + } + + @discardableResult + func assertEventuallyInMainActor( + timeout: TimeInterval = defaultTimeout, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @MainActor @Sendable (_ flow: CallFlow) async throws -> Bool + ) async throws -> Self { + try await Assertions.assertEventually(timeout: timeout, file: file, line: line) { + try await condition(self) + } + return self + } + + @discardableResult + func assertEventually( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + _ condition: @Sendable @escaping (_ element: Element) async throws -> Bool + ) async throws -> Self where Result == AsyncStream { + try await Assertions.assertFromAsyncStream( + timeout: timeout, + interval: interval, + stream: value + ) { try await condition($0) } + return self + } + + @discardableResult + func assertEventually( + timeout: TimeInterval = defaultTimeout, + interval: TimeInterval = 0.1, + _ condition: @Sendable @escaping (_ element: Output) async throws -> Bool + ) async throws -> Self where Result == AnyPublisher { + try await Assertions.assertFromPublisher( + timeout: timeout, + interval: interval, + publisher: value + ) { try await condition($0) } + return self + } + + // MARK: - Timing + + @discardableResult + func delay( + _ interval: TimeInterval + ) async throws -> Self { + let clampedInterval = max(0, interval) + guard clampedInterval > 0 else { + return self + } + + try await Task.sleep( + nanoseconds: UInt64(clampedInterval * 1_000_000_000) + ) + return self + } + + // MARK: - Subscription + + @discardableResult + func subscribe(for event: WSEvent.Type) -> CallFlow> { + let value = client.subscribe(for: event) + return .init(client: client, call: call, value: value) + } + + // MARK: - Map + + @discardableResult + func tryMap( + _ message: @autoclosure () -> String = "Flow value was nil", + _ transformation: @Sendable (_ flow: CallFlow) async throws -> Next? + ) async throws -> CallFlow { + guard let nextValue = try await transformation(self) else { + throw ClientError(message()) + } + return .init( + client: client, + call: call, + value: nextValue + ) + } + + @discardableResult + func map( + _ transformation: @Sendable (_ flow: CallFlow) async throws -> Next + ) async throws -> CallFlow { + let mappedValue = try await transformation(self) + return .init( + client: client, + call: call, + value: mappedValue + ) + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+AuthenticationHelper.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+AuthenticationHelper.swift new file mode 100644 index 000000000..e732dc0c3 --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+AuthenticationHelper.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamVideo +import XCTest + +extension Call_IntegrationTests.Helpers { + struct AuthenticationHelper: @unchecked Sendable { + private var baseURL: URL + let authenticationProvider: TestsAuthenticationProvider + + init( + baseURL: URL = .init(string: "https://pronto.getstream.io/api/auth/create-token")!, + authenticationProvider: TestsAuthenticationProvider = .init() + ) { + self.baseURL = baseURL + self.authenticationProvider = authenticationProvider + } + + func authenticate( + userId: String, + environment: String = "pronto" + ) async throws -> TestsAuthenticationProvider.TokenResponse { + try await authenticationProvider.authenticate( + environment: environment, + baseURL: baseURL, + userId: userId + ) + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+ConfigurationHelper.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+ConfigurationHelper.swift new file mode 100644 index 000000000..55062443e --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+ConfigurationHelper.swift @@ -0,0 +1,20 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +extension Call_IntegrationTests.Helpers { + struct ConfigurationHelper: Sendable { + init( + webRTCConfiguration: WebRTCConfiguration.Timeout = .production, + callConfiguration: CallConfiguration.Timeout = .production + ) { + // We configure the production timeouts as we hit real endpoints + WebRTCConfiguration.timeout = webRTCConfiguration + CallConfiguration.timeout = callConfiguration + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+Helpers.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+Helpers.swift new file mode 100644 index 000000000..70d79e078 --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+Helpers.swift @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +extension Call_IntegrationTests { + struct Helpers: Sendable { + @Injected(\.audioStore) private var audioStore + + enum LoggingMode { case none, sdk, webrtc, all } + + var duringDismantleObservedAllCallEnded = true + + var authentication: AuthenticationHelper + var configuration: ConfigurationHelper + var client: StreamVideoHelper + var users: UserHelper + var permissions: PermissionsHelper + + private var registeredCalls: [String: Call] = [:] + + init( + loggingMode: LoggingMode = .none, + configuration: ConfigurationHelper = .init(), + authentication: AuthenticationHelper = .init(), + client: StreamVideoHelper = .init(), + user: UserHelper = .init(), + permissions: PermissionsHelper = .init() + ) { + self.configuration = configuration + self.authentication = authentication + self.client = client + self.users = user + self.permissions = permissions + + switch loggingMode { + case .none: + LogConfig.webRTCLogsEnabled = false + LogConfig.level = .error + case .sdk: + LogConfig.webRTCLogsEnabled = false + LogConfig.level = .debug + case .webrtc: + LogConfig.webRTCLogsEnabled = true + LogConfig.level = .error + case .all: + LogConfig.webRTCLogsEnabled = true + LogConfig.level = .debug + } + } + + mutating func dismantle() async throws { + if duringDismantleObservedAllCallEnded { + for call in registeredCalls.values { + call.leave() + _ = try? await NotificationCenter + .default + .publisher(for: .init(CallNotification.callEnded)) + .compactMap { ($0.object as? Call)?.cId } + .filter { $0 == call.cId } + .nextValue(timeout: 2) + } + } + registeredCalls = [:] + + audioStore + .dispatch(.setAudioDeviceModule(nil)) + + _ = try await audioStore + .publisher(\.audioDeviceModule) + .filter { $0 == nil } + .nextValue(timeout: 2) + + await client.dismantle() + } + + // MARK: - CallFlow + + mutating func callFlow( + id: String, + type: String, + userId: String, + environment: String = "pronto", + clientResolutionMode: StreamVideoHelper.ClientResolutionMode = .ignoreCache + ) async throws -> CallFlow { + let authentication = try await authentication + .authenticate(userId: userId, environment: environment) + let client = try await client.buildClient( + apiKey: authentication.apiKey, + token: authentication.token, + userId: userId, + connectMode: .afterInit, + clientResolutionMode: clientResolutionMode, + clientRegisterMode: .auto + ) + let call = client.call(callType: type, callId: id) + registeredCalls[userId] = call + return .init( + client: client, + call: call + ) + } + + // MARK: - Raw + + func call( + id: String, + type: String, + userId: String, + clientResolutionMode: StreamVideoHelper.ClientResolutionMode = .ignoreCache + ) async throws -> Call { + let authentication = try await authentication + .authenticate(userId: userId) + let client = try await client.buildClient( + apiKey: authentication.apiKey, + token: authentication.token, + userId: userId, + connectMode: .afterInit, + clientResolutionMode: clientResolutionMode, + clientRegisterMode: .auto + ) + return client.call(callType: type, callId: id) + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+PermissionsHelper.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+PermissionsHelper.swift new file mode 100644 index 000000000..905b3fc9c --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+PermissionsHelper.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamVideo +import XCTest + +extension Call_IntegrationTests.Helpers { + struct PermissionsHelper: @unchecked Sendable { + private var mockPermissionStore = MockPermissionsStore() + + func dismantle() { + mockPermissionStore.dismantle() + } + + func setMicrophonePermission(isGranted: Bool) { + mockPermissionStore.stubMicrophonePermission(isGranted ? .granted : .denied) + } + + func setCameraPermission(isGranted: Bool) { + mockPermissionStore.stubCameraPermission(isGranted ? .granted : .denied) + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+StreamVideoHelper.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+StreamVideoHelper.swift new file mode 100644 index 000000000..39fd22cda --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+StreamVideoHelper.swift @@ -0,0 +1,114 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +extension Call_IntegrationTests.Helpers { + final class StreamVideoHelper: @unchecked Sendable { + static let videoConfig: VideoConfig = .dummy() + enum ConnectMode { case none, onInit, afterInit } + enum ClientRegisterMode { case none, auto, autoWithouSingletonUpdate } + enum ClientResolutionMode { case `default`, ignoreCache } + + let videoConfig: VideoConfig + let pushNotificationConfig: PushNotificationsConfig + + private var registeredClients: [String: StreamVideo] = [:] + + init( + videoConfig: VideoConfig = StreamVideoHelper.videoConfig, + pushNotificationConfig: PushNotificationsConfig = .default + ) { + self.videoConfig = videoConfig + self.pushNotificationConfig = pushNotificationConfig + } + + func dismantle() async { + for client in registeredClients.values { + await client.disconnect() + } + + StreamVideoProviderKey.currentValue = nil + + registeredClients = [:] + } + + func buildClient( + apiKey: String, + token: String, + userId: String, + connectMode: ConnectMode, + clientResolutionMode: ClientResolutionMode, + clientRegisterMode: ClientRegisterMode + ) async throws -> StreamVideo { + let autoConnectOnInit = { + switch connectMode { + case .none: + return false + case .onInit: + return true + case .afterInit: + return false + } + }() + + let currentStreamVideo = StreamVideoProviderKey.currentValue + let result = { + if clientResolutionMode == .default, let existingClient = registeredClients[userId] { + return existingClient + } else { + return StreamVideo( + apiKey: apiKey, + user: User(id: userId), + token: .init(rawValue: token), + videoConfig: videoConfig, + pushNotificationsConfig: pushNotificationConfig, + tokenProvider: { _ in }, + autoConnectOnInit: autoConnectOnInit + ) + } + }() + + if connectMode == .afterInit { + try await result.connect() + } + + switch clientRegisterMode { + case .none: + // Reassign the StreamVideo that was assigned before the + // new instance gets created. + StreamVideoProviderKey.currentValue = currentStreamVideo + + case .auto: + StreamVideoProviderKey.currentValue = result + registeredClients[userId] = result + + case .autoWithouSingletonUpdate: + // Reassign the StreamVideo that was assigned before the + // new instance gets created. + StreamVideoProviderKey.currentValue = currentStreamVideo + registeredClients[userId] = result + } + + return result + } + + func removeClient( + for userId: String, + disconnect: Bool + ) async { + guard let client = registeredClients[userId] else { + return + } + + if disconnect { + await client.disconnect() + } + + registeredClients[userId] = nil + } + } +} diff --git a/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+UserHelper.swift b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+UserHelper.swift new file mode 100644 index 000000000..f6bcf5468 --- /dev/null +++ b/StreamVideoTests/IntegrationTests/Components/Helpers/Call_IntegrationTests+UserHelper.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +extension Call_IntegrationTests.Helpers { + struct UserHelper: Sendable { + var knownUser1 = "thierry" + var knownUser2 = "tommaso" + var knownUser3 = "martin" + var knownUser4 = "ilias" + } +} diff --git a/StreamVideoTests/IntegrationTests/IntegrationTest.swift b/StreamVideoTests/IntegrationTests/IntegrationTest.swift deleted file mode 100644 index 51d052cf6..000000000 --- a/StreamVideoTests/IntegrationTests/IntegrationTest.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation -@testable import StreamVideo -import XCTest - -class IntegrationTest: XCTestCase, @unchecked Sendable { - - private nonisolated(unsafe) static var videoConfig: VideoConfig! = .dummy() - - private var apiKey: String! = "" - private var userId: String! = "thierry" - private var baseURL: URL! = .init(string: "https://pronto.getstream.io/api/auth/create-token")! - private var authenticationProvider: TestsAuthenticationProvider! = .init() - private(set) var client: StreamVideo! - - // MARK: - Lifecycle - - override func setUp() async throws { - #if compiler(<5.8) - throw XCTSkip("API tests are flaky on Xcode <14.3 due to async expectation handler in XCTest") - #else - try await super.setUp() - client = try await makeClient(for: userId) - #endif - - // We configure the production timeouts as we hit real endpoints - WebRTCConfiguration.timeout = WebRTCConfiguration.Timeout.production - CallConfiguration.timeout = CallConfiguration.Timeout.production - } - - override class func tearDown() { - Self.videoConfig = nil - super.tearDown() - } - - override func tearDown() { - apiKey = nil - userId = nil - baseURL = nil - authenticationProvider = nil - client = nil - - #if STREAM_TESTS - WebRTCConfiguration.timeout = WebRTCConfiguration.Timeout.testing - CallConfiguration.timeout = CallConfiguration.Timeout.testing - #endif - - super.tearDown() - } - - // MARK: - Helpers - - func makeClient( - for userId: String, - environment: String = "demo" - ) async throws -> StreamVideo { - let tokenResponse = try await authenticationProvider.authenticate( - environment: environment, - baseURL: baseURL, - userId: userId - ) - let client = StreamVideo( - apiKey: tokenResponse.apiKey, - user: User(id: userId), - token: .init(rawValue: tokenResponse.token), - videoConfig: Self.videoConfig, - pushNotificationsConfig: .init( - pushProviderInfo: .init(name: "ios-apn", pushProvider: .apn), - voipPushProviderInfo: .init(name: "ios-voip", pushProvider: .apn) - ), - tokenProvider: { _ in }, - autoConnectOnInit: false - ) - try await client.connect() - return client - } - - func refreshStreamVideoProviderKey() { - StreamVideoProviderKey.currentValue = client - } - - // TODO: extract code between these two assertNext methods - func assertNext( - _ s: AsyncStream, - timeout seconds: TimeInterval = 1, - _ assertion: @Sendable @escaping (Output) -> Bool - ) async -> Void { - let expectation = expectation(description: "NextValue") - expectation.assertForOverFulfill = false - - Task { - for await v in s { - if assertion(v) { - expectation.fulfill() - return - } - } - } - - await safeFulfillment(of: [expectation], timeout: seconds) - } - - func assertNext( - _ p: some Publisher, - timeout seconds: TimeInterval = 1, - _ assertion: @escaping (Output) -> Bool, - file: StaticString = #file, - line: UInt = #line - ) async -> Void { - let expectation = expectation(description: "NextValue") - expectation.assertForOverFulfill = false - - var values = [Output]() - var bag = Set() - defer { bag.forEach { $0.cancel() } } - - p.sink { - values.append($0) - if assertion($0) { - expectation.fulfill() - } - }.store(in: &bag) - - await safeFulfillment(of: [expectation], timeout: seconds) - } -} diff --git a/StreamVideoTests/Mock/CallController_Mock.swift b/StreamVideoTests/Mock/CallController_Mock.swift index ccd3cc6da..bf174450b 100644 --- a/StreamVideoTests/Mock/CallController_Mock.swift +++ b/StreamVideoTests/Mock/CallController_Mock.swift @@ -19,7 +19,8 @@ class CallController_Mock: CallController, @unchecked Sendable { options: CreateCallOptions? = nil, ring: Bool = false, notify: Bool = false, - source: JoinSource + source: JoinSource, + policy: WebRTCJoinPolicy = .default ) async throws -> JoinCallResponse { mockResponseBuilder.makeJoinCallResponse(cid: super.call?.cId ?? "default:\(String.unique)") } diff --git a/StreamVideoTests/Mock/MockCall.swift b/StreamVideoTests/Mock/MockCall.swift index 84f642b6e..782e197a4 100644 --- a/StreamVideoTests/Mock/MockCall.swift +++ b/StreamVideoTests/Mock/MockCall.swift @@ -29,7 +29,8 @@ final class MockCall: Call, Mockable, @unchecked Sendable { options: CreateCallOptions?, ring: Bool, notify: Bool, - callSettings: CallSettings? + callSettings: CallSettings?, + policy: WebRTCJoinPolicy ) case updateTrackSize(trackSize: CGSize, participant: CallParticipant) @@ -44,8 +45,8 @@ final class MockCall: Call, Mockable, @unchecked Sendable { var payload: Any { switch self { - case let .join(create, options, ring, notify, callSettings): - return (create, options, ring, notify, callSettings) + case let .join(create, options, ring, notify, callSettings, policy): + return (create, options, ring, notify, callSettings, policy) case let .updateTrackSize(trackSize, participant): return (trackSize, participant) @@ -60,7 +61,7 @@ final class MockCall: Call, Mockable, @unchecked Sendable { return request case let .setVideoFilter(videoFilter): - return videoFilter + return videoFilter ?? NSNull() } } } @@ -88,7 +89,7 @@ final class MockCall: Call, Mockable, @unchecked Sendable { _ source: Call = .dummy() ) { stubbedProperty = [ - MockCall.propertyKey(for: \.state): CallState() + MockCall.propertyKey(for: \.state): CallState(.dummy()) ] super.init( @@ -165,7 +166,8 @@ final class MockCall: Call, Mockable, @unchecked Sendable { options: CreateCallOptions? = nil, ring: Bool = false, notify: Bool = false, - callSettings: CallSettings? = nil + callSettings: CallSettings? = nil, + policy: WebRTCJoinPolicy = .default ) async throws -> JoinCallResponse { stubbedFunctionInput[.join]?.append( .join( @@ -173,7 +175,8 @@ final class MockCall: Call, Mockable, @unchecked Sendable { options: options, ring: ring, notify: notify, - callSettings: callSettings + callSettings: callSettings, + policy: policy ) ) if let stub = stubbedFunction[.join] as? JoinCallResponse { @@ -184,7 +187,8 @@ final class MockCall: Call, Mockable, @unchecked Sendable { options: options, ring: ring, notify: notify, - callSettings: callSettings + callSettings: callSettings, + policy: policy ) } } diff --git a/StreamVideoTests/Mock/MockCallController.swift b/StreamVideoTests/Mock/MockCallController.swift index 44a9b0059..795ed2bf4 100644 --- a/StreamVideoTests/Mock/MockCallController.swift +++ b/StreamVideoTests/Mock/MockCallController.swift @@ -25,7 +25,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { options: CreateCallOptions?, ring: Bool = false, notify: Bool = false, - source: JoinSource + source: JoinSource, + policy: WebRTCJoinPolicy ) case observeWebRTCStateUpdated @@ -40,8 +41,16 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { switch self { case let .setDisconnectionTimeout(timeout): return timeout - case let .join(create, callSettings, options, ring, notify, source): - return (create, callSettings, options, ring, notify, source) + case let .join( + create, + callSettings, + options, + ring, + notify, + source, + policy + ): + return (create, callSettings, options, ring, notify, source, policy) case .observeWebRTCStateUpdated: return () case let .changeVideoState(value): @@ -87,7 +96,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { options: CreateCallOptions? = nil, ring: Bool = false, notify: Bool = false, - source: JoinSource + source: JoinSource, + policy: WebRTCJoinPolicy = .default ) async throws -> JoinCallResponse { stubbedFunctionInput[.join]?.append( .join( @@ -96,10 +106,15 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { options: options, ring: ring, notify: notify, - source: source + source: source, + policy: policy ) ) + if let callSettings { + await call?.state.update(callSettings: callSettings) + } + if let stub = stubbedFunction[.join] as? JoinCallResponse { return stub } else if let joinError = stubbedFunction[.join] as? Error { @@ -111,7 +126,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { options: options, ring: ring, notify: notify, - source: source + source: source, + policy: policy ) } } diff --git a/StreamVideoTests/Mock/MockLocalMediaAdapter.swift b/StreamVideoTests/Mock/MockLocalMediaAdapter.swift index 481baa4c8..c9610fa41 100644 --- a/StreamVideoTests/Mock/MockLocalMediaAdapter.swift +++ b/StreamVideoTests/Mock/MockLocalMediaAdapter.swift @@ -30,6 +30,7 @@ final class MockLocalMediaAdapter: LocalMediaAdapting, Mockable, @unchecked Send case trackInfo case didUpdatePublishOptions case changePublishQuality + case didUpdateOwnCapabilities } enum MockFunctionInputKey: Payloadable { @@ -39,6 +40,7 @@ final class MockLocalMediaAdapter: LocalMediaAdapting, Mockable, @unchecked Send case unpublish case trackInfo(collectionType: RTCPeerConnectionTrackInfoCollectionType) case didUpdatePublishOptions(publishOptions: PublishOptions) + case didUpdateOwnCapabilities(ownCapabilities: Set) var payload: Any { switch self { @@ -46,6 +48,8 @@ final class MockLocalMediaAdapter: LocalMediaAdapting, Mockable, @unchecked Send return (settings, ownCapabilities) case let .didUpdateCallSettings(settings): return settings + case let .didUpdateOwnCapabilities(ownCapabilities): + return ownCapabilities case .publish: return () case .unpublish: @@ -68,12 +72,12 @@ final class MockLocalMediaAdapter: LocalMediaAdapting, Mockable, @unchecked Send .append(.setUp(settings: settings, ownCapabilities: ownCapabilities)) } - func publish() { + func publish() async throws { stubbedFunctionInput[.publish]? .append(.publish) } - func unpublish() { + func unpublish() async throws { stubbedFunctionInput[.unpublish]? .append(.unpublish) } @@ -83,6 +87,11 @@ final class MockLocalMediaAdapter: LocalMediaAdapting, Mockable, @unchecked Send .append(.didUpdateCallSettings(settings: settings)) } + func didUpdateOwnCapabilities(_ ownCapabilities: Set) { + stubbedFunctionInput[.didUpdateOwnCapabilities]? + .append(.didUpdateOwnCapabilities(ownCapabilities: ownCapabilities)) + } + func trackInfo(for collectionType: RTCPeerConnectionTrackInfoCollectionType) -> [Stream_Video_Sfu_Models_TrackInfo] { stubbedFunctionInput[.trackInfo]?.append(.trackInfo(collectionType: collectionType)) return stubbedFunction[.trackInfo] as? [Stream_Video_Sfu_Models_TrackInfo] ?? [] diff --git a/StreamVideoTests/Mock/MockThrowingStreamVideoCapturer.swift b/StreamVideoTests/Mock/MockThrowingStreamVideoCapturer.swift new file mode 100644 index 000000000..d18220a31 --- /dev/null +++ b/StreamVideoTests/Mock/MockThrowingStreamVideoCapturer.swift @@ -0,0 +1,69 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import AVFoundation +@testable import StreamVideo +import StreamWebRTC + +enum MockThrowingStreamVideoCapturerError: Error, Equatable { + case startCaptureFailed + case stopCaptureFailed +} + +final class MockThrowingStreamVideoCapturer: StreamVideoCapturing, @unchecked Sendable { + let shouldThrowOnStartCapture: Bool + let shouldThrowOnStopCapture: Bool + + init( + shouldThrowOnStartCapture: Bool = false, + shouldThrowOnStopCapture: Bool = false + ) { + self.shouldThrowOnStartCapture = shouldThrowOnStartCapture + self.shouldThrowOnStopCapture = shouldThrowOnStopCapture + } + + func supportsBackgrounding() async -> Bool { + false + } + + func startCapture( + position: AVCaptureDevice.Position, + dimensions: CGSize, + frameRate: Int + ) async throws { + if shouldThrowOnStartCapture { + throw MockThrowingStreamVideoCapturerError.startCaptureFailed + } + } + + func stopCapture() async throws { + if shouldThrowOnStopCapture { + throw MockThrowingStreamVideoCapturerError.stopCaptureFailed + } + } + + func setCameraPosition(_ position: AVCaptureDevice.Position) async throws { + if false { _ = position } + } + + func setVideoFilter(_ videoFilter: VideoFilter?) async {} + + func updateCaptureQuality(_ dimensions: CGSize) async throws {} + + func focus(at point: CGPoint) async throws {} + + func zoom(by factor: CGFloat) async throws {} + + func addCapturePhotoOutput( + _ capturePhotoOutput: AVCapturePhotoOutput + ) async throws {} + + func removeCapturePhotoOutput( + _ capturePhotoOutput: AVCapturePhotoOutput + ) async throws {} + + func addVideoOutput(_ videoOutput: AVCaptureVideoDataOutput) async throws {} + + func removeVideoOutput(_ videoOutput: AVCaptureVideoDataOutput) async throws {} +} diff --git a/StreamVideoTests/Mock/StreamVideoCallSession_Mock.swift b/StreamVideoTests/Mock/StreamVideoCallSession_Mock.swift new file mode 100644 index 000000000..a82f0cfbd --- /dev/null +++ b/StreamVideoTests/Mock/StreamVideoCallSession_Mock.swift @@ -0,0 +1,15 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo + +extension StreamVideo.CallSession { + static func dummy( + user: User = .dummy(), + token: UserToken = .empty + ) -> Self { + .init(user: user, token: token) + } +} diff --git a/StreamVideoTests/StreamVideo_Tests.swift b/StreamVideoTests/StreamVideo_Tests.swift index 732bb3a2b..038032e34 100644 --- a/StreamVideoTests/StreamVideo_Tests.swift +++ b/StreamVideoTests/StreamVideo_Tests.swift @@ -80,6 +80,33 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { XCTAssert(streamVideo.state.activeCall == nil) } + func test_streamVideo_callEndedNotificationIsPostedAfterCleanup() async throws { + let call = streamVideo.call(callType: callType, callId: callId) + streamVideo.state.activeCall = call + streamVideo.state.ringingCall = call + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await self.wait(for: 0.5) + call.leave() + } + + group.addTask { + _ = try await NotificationCenter + .default + .publisher(for: NSNotification.Name(CallNotification.callEnded)) + .compactMap { $0.object as? Call } + .filter { $0.cId == call.cId } + .nextValue(timeout: defaultTimeout) + } + + try await group.waitForAll() + } + + XCTAssertNil(streamVideo.state.activeCall) + XCTAssertNil(streamVideo.state.ringingCall) + } + func test_streamVideo_ringCallAccept() async throws { let httpClient = httpClientWithGetCallResponse() let streamVideo = StreamVideo.mock(httpClient: httpClient) diff --git a/StreamVideoTests/Utils/AudioSession/CallAudioSession/CallAudioSession_Tests.swift b/StreamVideoTests/Utils/AudioSession/CallAudioSession/CallAudioSession_Tests.swift index 81dbad993..5c8303ea5 100644 --- a/StreamVideoTests/Utils/AudioSession/CallAudioSession/CallAudioSession_Tests.swift +++ b/StreamVideoTests/Utils/AudioSession/CallAudioSession/CallAudioSession_Tests.swift @@ -52,7 +52,11 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { } } - func test_activate_enablesAudioAndAppliesPolicy() async { + // MARK: - activate + + // MARK: shouldSetActive = true + + func test_activate_shouldSetActiveTrue_enablesAudioAndAppliesPolicy() async { let callSettingsSubject = PassthroughSubject() let capabilitiesSubject = PassthroughSubject, Never>() let delegate = SpyAudioSessionAdapterDelegate() @@ -101,6 +105,139 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(traces.count, 2) } + func test_activate_shouldSetActiveTrue_setsStereoPreference_whenPolicyPrefersStereoPlayout() async { + let callSettingsSubject = PassthroughSubject() + let capabilitiesSubject = PassthroughSubject, Never>() + let delegate = SpyAudioSessionAdapterDelegate() + subject = .init(policy: LivestreamAudioSessionPolicy()) + + subject.activate( + callSettingsPublisher: callSettingsSubject.eraseToAnyPublisher(), + ownCapabilitiesPublisher: capabilitiesSubject.eraseToAnyPublisher(), + delegate: delegate, + statsAdapter: nil, + shouldSetActive: true + ) + + await fulfillment { + self.mockAudioStore.audioStore.state.stereoConfiguration.playout.preferred + } + } + + // MARK: shouldSetActive = false + + func test_activate_shouldSetActiveFalse_isActiveOnStoreIsTrue_dropsFirstValueAndActivatesCorrectly() async { + mockAudioStore.audioStore.dispatch(.setActive(true)) + let callSettingsSubject = CurrentValueSubject(.default) + let capabilitiesSubject = CurrentValueSubject, Never>([.sendAudio]) + let delegate = SpyAudioSessionAdapterDelegate() + let policy = MockAudioSessionPolicy() + let policyConfiguration = AudioSessionConfiguration( + isActive: true, + category: .playAndRecord, + mode: .voiceChat, + options: [.allowBluetoothHFP, .allowBluetoothA2DP], + overrideOutputAudioPort: .speaker + ) + policy.stub(for: .configuration, with: policyConfiguration) + subject = .init(policy: policy) + + subject.activate( + callSettingsPublisher: callSettingsSubject.eraseToAnyPublisher(), + ownCapabilitiesPublisher: capabilitiesSubject.eraseToAnyPublisher(), + delegate: delegate, + statsAdapter: nil, + shouldSetActive: false + ) + mockAudioStore.audioStore.dispatch(.setActive(true)) + + await fulfilmentInMainActor { + let state = self.mockAudioStore.audioStore.state + return state.audioSessionConfiguration.category == policyConfiguration.category + && state.audioSessionConfiguration.mode == policyConfiguration.mode + && state.audioSessionConfiguration.options == policyConfiguration.options + && state.isMicrophoneMuted == false + && state.webRTCAudioSessionConfiguration.isAudioEnabled + } + } + + func test_activate_shouldSetActiveFalse_enablesAudioAndAppliesPolicy() async { + let callSettingsSubject = PassthroughSubject() + let capabilitiesSubject = PassthroughSubject, Never>() + let delegate = SpyAudioSessionAdapterDelegate() + let statsAdapter = MockWebRTCStatsAdapter() + let policy = MockAudioSessionPolicy() + let mockAudioDeviceModule = MockRTCAudioDeviceModule() + mockAudioDeviceModule.stub(for: \.isRecording, with: true) + mockAudioDeviceModule.stub(for: \.isMicrophoneMuted, with: false) + mockAudioStore.audioStore.dispatch(.setAudioDeviceModule(.init(mockAudioDeviceModule))) + let policyConfiguration = AudioSessionConfiguration( + isActive: true, + category: .playAndRecord, + mode: .voiceChat, + options: [.allowBluetoothHFP, .allowBluetoothA2DP], + overrideOutputAudioPort: .speaker + ) + policy.stub(for: .configuration, with: policyConfiguration) + + subject = .init(policy: policy) + subject.activate( + callSettingsPublisher: callSettingsSubject.eraseToAnyPublisher(), + ownCapabilitiesPublisher: capabilitiesSubject.eraseToAnyPublisher(), + delegate: delegate, + statsAdapter: statsAdapter, + shouldSetActive: false + ) + + mockAudioStore.audioStore.dispatch(.setActive(true)) + await fulfilmentInMainActor { + self.mockAudioStore.audioStore.state.isActive + } + + // Provide call settings to trigger policy application. + callSettingsSubject.send(CallSettings(audioOn: true, speakerOn: true)) + capabilitiesSubject.send([.sendAudio]) + + await fulfillment { + let state = self.mockAudioStore.audioStore.state + return state.audioSessionConfiguration.category == policyConfiguration.category + && state.audioSessionConfiguration.mode == policyConfiguration.mode + && state.audioSessionConfiguration.options == policyConfiguration.options + && state.isRecording + && state.isMicrophoneMuted == false + && state.webRTCAudioSessionConfiguration.isAudioEnabled + } + + let traces = statsAdapter.stubbedFunctionInput[.trace]?.compactMap { input -> WebRTCTrace? in + guard case let .trace(trace) = input else { return nil } + return trace + } ?? [] + XCTAssertEqual(traces.count, 2) + } + + func test_activate_shouldSetActiveFalse_setsStereoPreference_whenPolicyPrefersStereoPlayout() async { + let callSettingsSubject = PassthroughSubject() + let capabilitiesSubject = PassthroughSubject, Never>() + let delegate = SpyAudioSessionAdapterDelegate() + subject = .init(policy: LivestreamAudioSessionPolicy()) + + subject.activate( + callSettingsPublisher: callSettingsSubject.eraseToAnyPublisher(), + ownCapabilitiesPublisher: capabilitiesSubject.eraseToAnyPublisher(), + delegate: delegate, + statsAdapter: nil, + shouldSetActive: false + ) + + mockAudioStore.audioStore.dispatch(.setActive(true)) + + await fulfillment { + self.mockAudioStore.audioStore.state.stereoConfiguration.playout.preferred + } + } + + // MARK: - deactivate + func test_deactivate_clearsDelegateAndDisablesAudio() async { let callSettingsSubject = PassthroughSubject() let capabilitiesSubject = PassthroughSubject, Never>() @@ -135,6 +272,8 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { XCTAssertNil(subject.delegate) } + // MARK: - didUpdatePolicy + func test_didUpdatePolicy_reconfiguresWhenActive() async { let callSettingsSubject = PassthroughSubject() let capabilitiesSubject = PassthroughSubject, Never>() @@ -193,24 +332,7 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { } } - func test_activate_setsStereoPreference_whenPolicyPrefersStereoPlayout() async { - let callSettingsSubject = PassthroughSubject() - let capabilitiesSubject = PassthroughSubject, Never>() - let delegate = SpyAudioSessionAdapterDelegate() - subject = .init(policy: LivestreamAudioSessionPolicy()) - - subject.activate( - callSettingsPublisher: callSettingsSubject.eraseToAnyPublisher(), - ownCapabilitiesPublisher: capabilitiesSubject.eraseToAnyPublisher(), - delegate: delegate, - statsAdapter: nil, - shouldSetActive: true - ) - - await fulfillment { - self.mockAudioStore.audioStore.state.stereoConfiguration.playout.preferred - } - } + // MARK: - routeChange func test_routeChangeWithMatchingSpeaker_reappliesPolicy() async { let callSettingsSubject = PassthroughSubject() @@ -288,6 +410,25 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(policy.stubbedFunctionInput[.configuration]?.count ?? 0, 1) } + func test_currentRouteIsExternal_matchesAudioStoreState() async { + let policy = MockAudioSessionPolicy() + subject = .init(policy: policy) + + let externalRoute = RTCAudioStore.StoreState.AudioRoute( + MockAVAudioSessionRouteDescription( + outputs: [MockAVAudioSessionPortDescription(portType: .bluetoothHFP)] + ) + ) + + mockAudioStore.audioStore.dispatch(.setCurrentRoute(externalRoute)) + + await fulfillment { + self.subject.currentRouteIsExternal == true + } + } + + // MARK: - callOptionsCleared + func test_callOptionsCleared_reappliesLastOptions() async { let callSettingsSubject = PassthroughSubject() let capabilitiesSubject = PassthroughSubject, Never>() @@ -325,23 +466,6 @@ final class CallAudioSession_Tests: XCTestCase, @unchecked Sendable { self.mockAudioStore.audioStore.state.audioSessionConfiguration.options == policyConfiguration.options } } - - func test_currentRouteIsExternal_matchesAudioStoreState() async { - let policy = MockAudioSessionPolicy() - subject = .init(policy: policy) - - let externalRoute = RTCAudioStore.StoreState.AudioRoute( - MockAVAudioSessionRouteDescription( - outputs: [MockAVAudioSessionPortDescription(portType: .bluetoothHFP)] - ) - ) - - mockAudioStore.audioStore.dispatch(.setCurrentRoute(externalRoute)) - - await fulfillment { - self.subject.currentRouteIsExternal == true - } - } } private final class SpyAudioSessionAdapterDelegate: StreamAudioSessionAdapterDelegate, @unchecked Sendable { diff --git a/StreamVideoTests/WebRTC/Extensions/Foundation/Task_TimeoutTests.swift b/StreamVideoTests/WebRTC/Extensions/Foundation/Task_TimeoutTests.swift index 95772ec6d..8d0bf068e 100644 --- a/StreamVideoTests/WebRTC/Extensions/Foundation/Task_TimeoutTests.swift +++ b/StreamVideoTests/WebRTC/Extensions/Foundation/Task_TimeoutTests.swift @@ -50,11 +50,8 @@ final class TaskTimeoutTests: XCTestCase, @unchecked Sendable { do { _ = try await task.value XCTFail("Expected timeout error but operation succeeded") - } catch let error as ClientError { - XCTAssertTrue( - error.localizedDescription.contains("timed out"), - "Error should indicate timeout" - ) + } catch let error as TimeOutError { + XCTAssertEqual(error.localizedDescription, "Operation timed out") } catch { XCTFail("Unexpected error type: \(error)") } @@ -223,8 +220,10 @@ final class TaskTimeoutTests: XCTestCase, @unchecked Sendable { do { _ = try await task.value XCTFail("Zero timeout should fail immediately") + } catch let error as TimeOutError { + XCTAssertEqual(error.localizedDescription, "Operation timed out") } catch { - // Expected timeout + XCTFail("Unexpected error type: \(error)") } } @@ -239,8 +238,10 @@ final class TaskTimeoutTests: XCTestCase, @unchecked Sendable { do { _ = try await task.value XCTFail("Negative timeout should fail immediately") + } catch let error as TimeOutError { + XCTAssertEqual(error.localizedDescription, "Operation timed out") } catch { - // Expected timeout or immediate completion + XCTFail("Unexpected error type: \(error)") } } diff --git a/StreamVideoTests/WebRTC/v2/IntegrationTests/WebRTCIntegrationTests.swift b/StreamVideoTests/WebRTC/v2/IntegrationTests/WebRTCIntegrationTests.swift index 49a794e97..ba7c2c305 100644 --- a/StreamVideoTests/WebRTC/v2/IntegrationTests/WebRTCIntegrationTests.swift +++ b/StreamVideoTests/WebRTC/v2/IntegrationTests/WebRTCIntegrationTests.swift @@ -59,7 +59,8 @@ final class WebRTCIntegrationTests: XCTestCase, @unchecked Sendable { options: nil, ring: false, notify: false, - source: .inApp + source: .inApp, + joinResponseHandler: .init() ) }, .init { diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter_Tests.swift index 2a28956c0..18864659a 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalAudioMediaAdapter_Tests.swift @@ -197,6 +197,63 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { await fulfillment { self.subject.primaryTrack.isEnabled == true } } + // MARK: - didUpdateOwnCapabilities + + func test_didUpdateOwnCapabilities_addsAudioCapability_trackCanBeRegistered() async throws { + try await assertTrackEvent( + isInverted: true + ) { subject in + try await subject.setUp( + with: .init(audioOn: true), + ownCapabilities: [] + ) + } + + subject.didUpdateOwnCapabilities([.sendAudio]) + + try await assertTrackEvent { + switch $0 { + case let .added(id, trackType, track): + return (id, trackType, track) + default: + return nil + } + } operation: { subject in + try await subject.didUpdateCallSettings(.init(audioOn: true)) + } validation: { id, trackType, track in + XCTAssertEqual(id, self.sessionId) + XCTAssertEqual(trackType, .audio) + XCTAssertTrue(track is RTCAudioTrack) + } + + await fulfillment { self.subject.primaryTrack.isEnabled } + await fulfillment { + self.mockSFUStack.service.updateMuteStatesWasCalledWithRequest != nil + } + let request = try XCTUnwrap( + mockSFUStack.service.updateMuteStatesWasCalledWithRequest + ) + XCTAssertEqual(request.sessionID, sessionId) + XCTAssertFalse(request.muteStates[0].muted) + } + + func test_didUpdateOwnCapabilities_removesAudioCapability_blocksMuteUpdate() async throws { + try await subject.setUp( + with: .init(audioOn: true), + ownCapabilities: [.sendAudio] + ) + try await subject.didUpdateCallSettings(.init(audioOn: true)) + await fulfillment { self.subject.primaryTrack.isEnabled } + + mockSFUStack.service.updateMuteStatesWasCalledWithRequest = nil + subject.didUpdateOwnCapabilities([]) + try await subject.didUpdateCallSettings(.init(audioOn: false)) + + await fulfillment { self.mockSFUStack.service.updateMuteStatesWasCalledWithRequest == nil } + XCTAssertNil(mockSFUStack.service.updateMuteStatesWasCalledWithRequest) + XCTAssertTrue(subject.primaryTrack.isEnabled) + } + // MARK: - didUpdatePublishOptions func test_didUpdatePublishOptions_primaryTrackIsNotEnabled_nothingHappens() async throws { @@ -246,7 +303,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { } // We call publish to simulate the publishing flow that will create // all necessary transceveivers on the PeerConnection - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } try await subject.didUpdatePublishOptions( @@ -263,7 +320,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { let opusTransceiver = try makeTransceiver(of: .audio, audioOptions: .dummy(codec: .opus)) let redTransceiver = try makeTransceiver(of: .audio, audioOptions: .dummy(codec: .red)) mockPeerConnection.stub(for: .addTransceiver, with: opusTransceiver) - subject.publish() + try await subject.publish() await fulfillment { opusTransceiver.sender.track != nil } mockPeerConnection.stub(for: .addTransceiver, with: redTransceiver) @@ -296,7 +353,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { .dummy(codec: .opus), .dummy(codec: .red) ] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 2 } let trackInfo = subject.trackInfo(for: .allAvailable) @@ -319,7 +376,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { $0 == 1 ? opusTransceiver : redTransceiver }) publishOptions = [.dummy(codec: .opus)] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } let opusTrackId = try XCTUnwrap(opusTransceiver.sender.track?.trackId) @@ -343,7 +400,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { $0 == 1 ? opusTransceiver : redTransceiver }) publishOptions = [.dummy(codec: .opus)] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } try await subject.didUpdatePublishOptions( .dummy(audio: [.dummy(codec: .red)]) @@ -374,7 +431,7 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { ownCapabilities: [.sendAudio] ) - subject.publish() + try await subject.publish() await fulfillment { self.subject.primaryTrack.isEnabled == true } XCTAssertEqual(mockPeerConnection.stubbedFunctionInput[.addTransceiver]?.count, 1) @@ -406,10 +463,10 @@ final class LocalAudioMediaAdapter_Tests: XCTestCase, @unchecked Sendable { with: .init(audioOn: true), ownCapabilities: [.sendAudio] ) - subject.publish() + try await subject.publish() await fulfilmentInMainActor { self.subject.primaryTrack.isEnabled == true } - subject.unpublish() + try await subject.unpublish() await fulfilmentInMainActor { self.subject.primaryTrack.isEnabled == false } let transceiver = try XCTUnwrap( diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter_Tests.swift index 5ddf720b1..1e0b57d80 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalScreenShareMediaAdapter_Tests.swift @@ -65,6 +65,34 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable XCTAssertTrue(subject.primaryTrack.source === track.source) } + // MARK: - didUpdateOwnCapabilities + + func test_didUpdateOwnCapabilities_whenActiveSessionExists_isNoOp() async throws { + let capturer = MockStreamVideoCapturer() + mockCapturerFactory.stub(for: .buildScreenCapturer, with: capturer) + + try await subject.beginScreenSharing( + of: .inApp, + ownCapabilities: [.screenshare], + includeAudio: true + ) + await fulfillment { capturer.timesCalled(.startCapture) == 1 } + + subject.didUpdateOwnCapabilities([.screenshare]) + + XCTAssertNotNil(screenShareSessionProvider.activeSession) + XCTAssertTrue(subject.primaryTrack.isEnabled) + XCTAssertEqual( + screenShareSessionProvider.activeSession?.screenSharingType, + .inApp + ) + XCTAssertEqual( + screenShareSessionProvider.activeSession?.localTrack.trackId, + subject.primaryTrack.trackId + ) + XCTAssertTrue(capturer.timesCalled(.stopCapture) == 0) + } + // MARK: - didUpdatePublishOptions func test_didUpdatePublishOptions_primaryTrackIsNotEnabled_nothingHappens() async throws { @@ -131,7 +159,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable includeAudio: true ) subject.primaryTrack.isEnabled = false - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } try await subject.didUpdatePublishOptions( @@ -155,7 +183,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable includeAudio: true ) subject.primaryTrack.isEnabled = false - subject.publish() + try await subject.publish() await fulfillment { h264Transceiver.sender.track != nil } mockPeerConnection.stub(for: .addTransceiver, with: av1Transceiver) @@ -417,7 +445,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable includeAudio: true ) - subject.publish() + try await subject.publish() await fulfillment { self.subject.primaryTrack.isEnabled == true } XCTAssertEqual(mockPeerConnection.stubbedFunctionInput[.addTransceiver]?.count, 1) @@ -435,7 +463,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable includeAudio: true ) - subject.publish() + try await subject.publish() await fulfillment { self.subject.primaryTrack.isEnabled == true } XCTAssertTrue(subject.primaryTrack.isEnabled) @@ -458,7 +486,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable await fulfillment { capturer.timesCalled(.startCapture) == 1 } XCTAssertTrue(subject.primaryTrack.isEnabled) - subject.unpublish() + try await subject.unpublish() await fulfillment { self.subject.primaryTrack.isEnabled == false && mockTransceiver.sender.track?.isEnabled == false @@ -479,11 +507,30 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable ) await fulfillment { capturer.timesCalled(.startCapture) == 1 } - subject.unpublish() + try await subject.unpublish() await fulfillment { capturer.timesCalled(.stopCapture) >= 1 } } + func test_unpublish_enabledLocalTrack_whenCaptureStopThrows_propagatesError() async { + screenShareSessionProvider.activeSession = .init( + localTrack: subject.primaryTrack, + screenSharingType: .inApp, + capturer: MockThrowingStreamVideoCapturer(shouldThrowOnStopCapture: true), + includeAudio: true + ) + subject.primaryTrack.isEnabled = true + + do { + try await subject.unpublish() + XCTFail("Expected unpublish() to throw when capture stop fails.") + } catch let error as MockThrowingStreamVideoCapturerError { + XCTAssertEqual(error, .stopCaptureFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + // MARK: - Private private func makeTransceiver( @@ -575,7 +622,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable private func assertActiveSessionConfiguration( _ screensharingType: ScreensharingType, assertStopCapture: Bool, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let capturerA = MockStreamVideoCapturer() @@ -597,7 +644,7 @@ final class LocalScreenShareMediaAdapter_Tests: XCTestCase, @unchecked Sendable includeAudio: true ) - await fulfillment(file: file, line: line) { + await fulfillment(filePath: file, line: line) { self.screenShareSessionProvider.activeSession != nil } diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter_Tests.swift index 33dfa9cfa..ddd5bfd1d 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/LocalMediaAdapters/LocalVideoMediaAdapter_Tests.swift @@ -276,6 +276,66 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { await fulfillment { self.subject.primaryTrack.isEnabled == true } } + // MARK: - didUpdateOwnCapabilities + + func test_didUpdateOwnCapabilities_addsVideoCapability_trackCanBeRegistered() async throws { + let expectedSessionID = sessionId + try await assertTrackEvent( + isInverted: true + ) { subject in + try await subject.setUp( + with: .init(videoOn: true), + ownCapabilities: [] + ) + } + + subject.didUpdateOwnCapabilities([.sendVideo]) + + try await assertTrackEvent { + switch $0 { + case let .added(id, trackType, track): + return (id, trackType, track) + default: + return nil + } + } operation: { subject in + try await subject.didUpdateCallSettings(.init(videoOn: true)) + } validation: { id, trackType, track in + XCTAssertEqual(id, expectedSessionID) + XCTAssertEqual(trackType, .video) + XCTAssertTrue(track is RTCVideoTrack) + } + + await fulfillment { self.subject.primaryTrack.isEnabled } + await fulfillment { + self.mockSFUStack.service.updateMuteStatesWasCalledWithRequest != nil + } + let request = try XCTUnwrap( + mockSFUStack.service.updateMuteStatesWasCalledWithRequest + ) + XCTAssertEqual(request.sessionID, self.sessionId) + XCTAssertFalse(request.muteStates[0].muted) + } + + func test_didUpdateOwnCapabilities_removesVideoCapability_blocksMuteUpdate() async throws { + try await subject.setUp( + with: .init(videoOn: true), + ownCapabilities: [.sendVideo] + ) + try await subject.didUpdateCallSettings(.init(videoOn: true)) + await fulfillment { self.subject.primaryTrack.isEnabled } + + mockSFUStack.service.updateMuteStatesWasCalledWithRequest = nil + subject.didUpdateOwnCapabilities([]) + try await subject.didUpdateCallSettings(.init(videoOn: false)) + + await fulfillment { + self.mockSFUStack.service.updateMuteStatesWasCalledWithRequest == nil + } + XCTAssertNil(mockSFUStack.service.updateMuteStatesWasCalledWithRequest) + XCTAssertTrue(subject.primaryTrack.isEnabled) + } + // MARK: - didUpdatePublishOptions func test_didUpdatePublishOptions_primaryTrackIsNotEnabled_nothingHappens() async throws { @@ -325,7 +385,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { } // We call publish to simulate the publishing flow that will create // all necessary transceveivers on the PeerConnection - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } try await subject.didUpdatePublishOptions( @@ -342,7 +402,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { let h264Transceiver = try makeTransceiver(of: .video, videoOptions: .dummy(codec: .h264)) let av1Transceiver = try makeTransceiver(of: .video, videoOptions: .dummy(codec: .av1)) mockPeerConnection.stub(for: .addTransceiver, with: h264Transceiver) - subject.publish() + try await subject.publish() await fulfillment { h264Transceiver.sender.track != nil } mockPeerConnection.stub(for: .addTransceiver, with: av1Transceiver) @@ -372,7 +432,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { .dummy(codec: .h264, fmtp: "a"), .dummy(codec: .av1, fmtp: "b") ] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 2 } let trackInfo = subject.trackInfo(for: .allAvailable) @@ -398,7 +458,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { $0 == 1 ? h264Transceiver : av1Transceiver }) publishOptions = [.dummy(codec: .h264)] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } let h264TrackId = try XCTUnwrap(h264Transceiver.sender.track?.trackId) @@ -422,7 +482,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { $0 == 1 ? h264Transceiver : av1Transceiver }) publishOptions = [.dummy(codec: .h264)] - subject.publish() + try await subject.publish() await fulfillment { self.mockPeerConnection.timesCalled(.addTransceiver) == 1 } try await subject.didUpdatePublishOptions( .dummy(video: [.dummy(codec: .av1)]) @@ -451,7 +511,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { ownCapabilities: [.sendVideo] ) - subject.publish() + try await subject.publish() await fulfillment { self.subject.primaryTrack.isEnabled == true } XCTAssertEqual(mockPeerConnection.stubbedFunctionInput[.addTransceiver]?.count, 1) @@ -465,7 +525,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { ownCapabilities: [.sendVideo] ) - subject.publish() + try await subject.publish() await fulfillment { self.subject.primaryTrack.isEnabled == true } XCTAssertTrue(subject.primaryTrack.isEnabled) @@ -473,6 +533,28 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(mockPeerConnection.timesCalled(.addTransceiver), 1) } + func test_publish_disabledLocalTrack_whenCaptureStartThrows_propagatesError() async { + mockCaptureDeviceProvider.stub(for: .deviceForPosition, with: MockCaptureDevice()) + mockCaptureDeviceProvider.stub(for: .deviceForAVPosition, with: MockCaptureDevice()) + videoCaptureSessionProvider.activeSession = .init( + position: .front, + device: nil, + localTrack: subject.primaryTrack, + capturer: MockThrowingStreamVideoCapturer(shouldThrowOnStartCapture: true) + ) + + do { + try await subject.publish() + XCTFail("Expected publish() to throw when capture start fails.") + } catch let error as MockThrowingStreamVideoCapturerError { + XCTAssertEqual(error, .startCaptureFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(mockPeerConnection.timesCalled(.addTransceiver), 0) + } + // MARK: - unpublish func test_unpublish_enabledLocalTrack_enablesAndAddsTrackAndTransceiver() async throws { @@ -486,7 +568,7 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { await fulfillment { mockTransceiver.sender.track?.isEnabled == true } XCTAssertTrue(subject.primaryTrack.isEnabled) - subject.unpublish() + try await subject.unpublish() await fulfillment { self.subject.primaryTrack.isEnabled == false && mockTransceiver.sender.track?.isEnabled == false @@ -508,11 +590,30 @@ final class LocalVideoMediaAdapter_Tests: XCTestCase, @unchecked Sendable { try await subject.didUpdateCallSettings(.init(videoOn: true)) await fulfillment { self.videoCaptureSessionProvider.activeSession?.device != nil } - subject.unpublish() + try await subject.unpublish() await fulfillment { self.mockVideoCapturer.timesCalled(.stopCapture) == 1 } } + func test_unpublish_enabledLocalTrack_whenCaptureStopThrows_propagatesError() async { + videoCaptureSessionProvider.activeSession = .init( + position: .front, + device: MockCaptureDevice(), + localTrack: subject.primaryTrack, + capturer: MockThrowingStreamVideoCapturer(shouldThrowOnStopCapture: true) + ) + subject.primaryTrack.isEnabled = true + + do { + try await subject.unpublish() + XCTFail("Expected unpublish() to throw when capture stop fails.") + } catch let error as MockThrowingStreamVideoCapturerError { + XCTAssertEqual(error, .stopCaptureFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + // MARK: - didUpdateCameraPosition(_:) func test_didUpdateCameraPosition_videoCapturerWasCalledWithExpectedInput() async throws { diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift index 03fdc6a86..17cbb1e97 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift @@ -154,6 +154,19 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable } } + // MARK: - didUpdateOwnCapabilities(_:) + + func test_didUpdateOwnCapabilities_setUpWasCalledOnAllMediaAdapters() async throws { + _ = subject + subject.didUpdateOwnCapabilities([.sendAudio, .sendVideo, .screenshare]) + + await fulfillment { [mockLocalMediaAdapterA, mockLocalMediaAdapterB, mockLocalMediaAdapterC] in + mockLocalMediaAdapterA?.timesCalled(.didUpdateOwnCapabilities) == 1 + && mockLocalMediaAdapterB?.timesCalled(.didUpdateOwnCapabilities) == 1 + && mockLocalMediaAdapterC?.timesCalled(.didUpdateOwnCapabilities) == 1 + } + } + // MARK: - createOffer(constraints:) func test_createOffer_peerConnectionWasCalled() async throws { diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_CleanUpStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_CleanUpStageTests.swift index c409a26b4..f11b59e1d 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_CleanUpStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_CleanUpStageTests.swift @@ -86,7 +86,7 @@ final class WebRTCCoordinatorStateMachine_CleanUpStageTests: XCTestCase, @unchec await mockCoordinatorStack .coordinator .stateAdapter - .set(ownCapabilities: [OwnCapability.blockUsers]) + .enqueueOwnCapabilities { [OwnCapability.blockUsers] } await mockCoordinatorStack .coordinator .stateAdapter diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ConnectingStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ConnectingStageTests.swift index 32510c7f0..dcacb2508 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ConnectingStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ConnectingStageTests.swift @@ -253,6 +253,37 @@ final class WebRTCCoordinatorStateMachine_ConnectingStageTests: XCTestCase, @unc } } + func test_transition_fromIdle_successfulAuthenticationStoresInitialJoinCallResponse() async throws { + let expectedJoinResponse = JoinCallResponse.dummy(call: .dummy(cid: "test-cid")) + subject.context.coordinator = mockCoordinatorStack.coordinator + subject.context.authenticator = mockCoordinatorStack.webRTCAuthenticator + mockCoordinatorStack.webRTCAuthenticator.stub( + for: .authenticate, + with: Result< + (SFUAdapter, JoinCallResponse), + Error + > + .success( + ( + mockCoordinatorStack.sfuStack.adapter, + expectedJoinResponse + ) + ) + ) + mockCoordinatorStack.webRTCAuthenticator.stub( + for: .waitForAuthentication, + with: Result.success(()) + ) + + try await assertTransition( + from: .idle, + expectedTarget: .connected, + subject: subject + ) { target in + XCTAssertEqual(target.context.initialJoinCallResponse?.call.cid, expectedJoinResponse.call.cid) + } + } + // MARK: - transition from `.rejoining` func test_transition_fromRejoiningWithoutCoordinator_transitionsToDisconnected() async throws { diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ErrorStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ErrorStageTests.swift index 655090eb5..b36d62d0a 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ErrorStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_ErrorStageTests.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine @testable import StreamVideo import XCTest @@ -50,4 +51,39 @@ final class WebRTCCoordinatorStateMachine_ErrorStageTests: XCTestCase, @unchecke await fulfillment(of: [transitionExpectation]) } + + func test_transition_sendsErrorToJoinResponseHandler() async { + let handler = PassthroughSubject() + let expectation = expectation(description: "Join response handler receives failure") + let expectedError = ClientError("Join failed") + var receivedError: Error? + let cancellable = handler.sink( + receiveCompletion: { completion in + switch completion { + case .finished: + return + case let .failure(error): + receivedError = error + expectation.fulfill() + } + }, + receiveValue: { _ in + XCTFail("No value expected before failure") + } + ) + subject = .error(.init(), error: expectedError) + subject.context.joinResponseHandler = handler + + let transitionExpectation = self.expectation(description: "Will transition to id:.cleanUp") + subject.transition = { + if $0.id == .cleanUp { + transitionExpectation.fulfill() + } + } + _ = subject.transition(from: .joining(subject.context)) + + await fulfillment(of: [transitionExpectation, expectation]) + XCTAssertTrue(receivedError is ClientError) + cancellable.cancel() + } } diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift index 73e2268c3..d2e631a61 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift @@ -17,7 +17,10 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck .allCases .filter { $0 != subject.id } .map { WebRTCCoordinator.StateMachine.Stage(id: $0, context: .init()) } - private lazy var validStages: Set! = [.joining] + private lazy var validStages: Set! = [ + .joining, + .peerConnectionPreparing + ] private lazy var mockCoordinatorStack: MockWebRTCCoordinatorStack! = .init( videoConfig: Self.videoConfig ) diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift index 7fefa23cf..c4fa508e9 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift @@ -200,12 +200,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec from: .connected, expectedTarget: .disconnected, subject: subject - ) { target in - XCTAssertEqual( - (target.context.flowError as? ClientError)?.localizedDescription, - "Operation timed out" - ) - } + ) { XCTAssertTrue($0.context.flowError is TimeOutError) } } func test_transition_fromConnectedReceivesJoinResponse_updatesCallSettingsOnStateAdapter() async throws { @@ -446,6 +441,81 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec } } + func test_transition_fromConnected_reportsJoinCompletionToHandler() async throws { + subject.context.coordinator = mockCoordinatorStack.coordinator + subject.context.reconnectAttempts = 11 + let expectedJoinCallResponse = JoinCallResponse.dummy(call: .dummy(cid: "expected-call-id")) + subject.context.initialJoinCallResponse = expectedJoinCallResponse + + let completionSubject = PassthroughSubject() + let completionExpectation = expectation(description: "JoinResponseHandler should receive response") + var receivedCallID: String? + let completionCancellable = completionSubject.sink( + receiveCompletion: { _ in }, + receiveValue: { response in + receivedCallID = response.call.cid + completionExpectation.fulfill() + } + ) + subject.context.joinResponseHandler = completionSubject + + await mockCoordinatorStack + .coordinator + .stateAdapter + .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) + mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) + + let eventCancellable = receiveEvent( + .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + every: 0.3 + ) + + try await assertTransition( + from: .connected, + expectedTarget: .joined, + subject: subject + ) { target in + XCTAssertNil(target.context.initialJoinCallResponse) + XCTAssertNil(target.context.joinResponseHandler) + } + + await fulfillment(of: [completionExpectation], timeout: defaultTimeout) + XCTAssertEqual(receivedCallID, expectedJoinCallResponse.call.cid) + + completionCancellable.cancel() + eventCancellable.cancel() + } + + func test_transition_fromConnected_withReadinessAwarePolicy_transitionsToPeerConnectionPreparing() async throws { + subject.context.coordinator = mockCoordinatorStack.coordinator + subject.context.reconnectAttempts = 11 + subject.context.joinPolicy = .peerConnectionReadinessAware(timeout: 5) + subject.context.initialJoinCallResponse = .dummy() + subject.context.joinResponseHandler = .init() + + await mockCoordinatorStack + .coordinator + .stateAdapter + .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) + mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) + + let eventCancellable = receiveEvent( + .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + every: 0.3 + ) + + try await assertTransition( + from: .connected, + expectedTarget: .peerConnectionPreparing, + subject: subject + ) { target in + XCTAssertNotNil(target.context.initialJoinCallResponse) + XCTAssertNotNil(target.context.joinResponseHandler) + } + + eventCancellable.cancel() + } + // MARK: - transition from connected with isRejoiningFromSessionID != nil func test_transition_fromConnectedWithRejoinWithoutCoordinator_transitionsToDisconnected() async throws { @@ -639,12 +709,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec from: .connected, expectedTarget: .disconnected, subject: subject - ) { target in - XCTAssertEqual( - (target.context.flowError as? ClientError)?.localizedDescription, - "Operation timed out" - ) - } + ) { XCTAssertTrue($0.context.flowError is TimeOutError) } } func test_transition_fromConnectedWithRejoinReceivesJoinResponse_updatesCallSettingsOnStateAdapter() async throws { @@ -1289,12 +1354,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec from: .migrated, expectedTarget: .disconnected, subject: subject - ) { target in - XCTAssertEqual( - (target.context.flowError as? ClientError)?.localizedDescription, - "Operation timed out" - ) - } + ) { XCTAssertTrue($0.context.flowError is TimeOutError) } } func test_transition_fromMigratedReceivesJoinResponse_updatesCallSettingsOnStateAdapter() async throws { @@ -1550,7 +1610,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: WebRTCCoordinator.StateMachine.Stage.ID, subject: WebRTCCoordinator.StateMachine.Stage, validator: @escaping @Sendable (WebRTCCoordinator.StateMachine.Stage) async throws -> Void, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let transitionExpectation = diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift index 55bb129a7..b607a66e4 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift @@ -21,7 +21,8 @@ final class WebRTCCoordinatorStateMachine_LeavingStageTests: XCTestCase, @unchec .disconnected, .connected, .connecting, - .joining + .joining, + .peerConnectionPreparing ] private lazy var subject: WebRTCCoordinator.StateMachine.Stage! = .leaving(.init()) private lazy var mockCoordinatorStack: MockWebRTCCoordinatorStack! = .init( diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_PeerConnectionPreparingStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_PeerConnectionPreparingStageTests.swift new file mode 100644 index 000000000..1489b4d70 --- /dev/null +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_PeerConnectionPreparingStageTests.swift @@ -0,0 +1,112 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +@testable import StreamVideo +import XCTest + +final class WebRTCCoordinatorStateMachine_PeerConnectionPreparingStageTests: + XCTestCase, + @unchecked Sendable { + + private nonisolated(unsafe) static var videoConfig: VideoConfig! = .dummy() + + private lazy var allOtherStages: [WebRTCCoordinator.StateMachine.Stage]! = + WebRTCCoordinator + .StateMachine + .Stage + .ID + .allCases + .filter { $0 != subject.id } + .map { WebRTCCoordinator.StateMachine.Stage(id: $0, context: .init()) } + private lazy var validStages: Set! = + [.joining] + private lazy var mockCoordinatorStack: MockWebRTCCoordinatorStack! = .init( + videoConfig: Self.videoConfig + ) + private lazy var subject: WebRTCCoordinator.StateMachine.Stage! = + .peerConnectionPreparing(.init(), timeout: 0.01) + + override class func tearDown() { + Self.videoConfig = nil + super.tearDown() + } + + override func tearDown() { + allOtherStages = nil + validStages = nil + mockCoordinatorStack = nil + subject = nil + super.tearDown() + } + + func test_init() { + XCTAssertEqual(subject.id, .peerConnectionPreparing) + } + + func test_transition() { + for nextStage in allOtherStages { + if validStages.contains(nextStage.id) { + XCTAssertNotNil(subject.transition(from: nextStage)) + } else { + XCTAssertNil(subject.transition(from: nextStage)) + } + } + } + + func test_transition_whenPeerConnectionsDoNotBecomeReadyWithinTimeout_reportsJoinCompletionAndTransitionsToJoined( + ) async throws { + let expectedJoinCallResponse = JoinCallResponse.dummy( + call: .dummy(cid: "expected-call-id") + ) + let completionSubject = PassthroughSubject() + let completionExpectation = expectation( + description: "JoinResponseHandler should receive response." + ) + var receivedCallID: String? + let completionCancellable = completionSubject.sink( + receiveCompletion: { _ in }, + receiveValue: { response in + receivedCallID = response.call.cid + completionExpectation.fulfill() + } + ) + + let context = WebRTCCoordinator.StateMachine.Stage.Context( + coordinator: mockCoordinatorStack.coordinator, + initialJoinCallResponse: expectedJoinCallResponse, + joinResponseHandler: completionSubject + ) + subject = .peerConnectionPreparing(context, timeout: 0.01) + + await mockCoordinatorStack + .coordinator + .stateAdapter + .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) + try await mockCoordinatorStack + .coordinator + .stateAdapter + .configurePeerConnections() + + let transitionExpectation = expectation( + description: "Stage is expected to transition to joined." + ) + subject.transition = { target in + guard target.id == .joined else { return } + XCTAssertNil(target.context.initialJoinCallResponse) + XCTAssertNil(target.context.joinResponseHandler) + transitionExpectation.fulfill() + } + + _ = subject.transition(from: .joining(subject.context)) + + await fulfillment( + of: [transitionExpectation, completionExpectation], + timeout: defaultTimeout + ) + XCTAssertEqual(receivedCallID, expectedJoinCallResponse.call.cid) + + completionCancellable.cancel() + } +} diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index c897dc5eb..7bcc353d8 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -87,10 +87,12 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { startsAt: .init(timeIntervalSince1970: 100), team: .unique ) + let expectedJoinSource = JoinSource.callKit(.init {}) try await assertTransitionToStage( .connecting, operation: { + let joinResponseHandler = PassthroughSubject() try await self .subject .connect( @@ -98,7 +100,8 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { options: expectedOptions, ring: true, notify: true, - source: .callKit + source: expectedJoinSource, + joinResponseHandler: joinResponseHandler ) } ) { stage in @@ -111,7 +114,8 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(expectedStage.options?.team, expectedOptions.team) XCTAssertTrue(expectedStage.ring) XCTAssertTrue(expectedStage.notify) - XCTAssertEqual(expectedStage.context.joinSource, .callKit) + XCTAssertEqual(expectedStage.context.joinSource, expectedJoinSource) + XCTAssertNotNil(expectedStage.context.joinResponseHandler) await self.assertEqualAsync( await self.subject.stateAdapter.initialCallSettings, expectedCallSettings @@ -176,7 +180,8 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { options: nil, ring: true, notify: true, - source: .inApp + source: .inApp, + joinResponseHandler: .init() ) } ) { _ in @@ -307,7 +312,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { func test_startScreensharing_typeIsInApp_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await subject.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await subject.stateAdapter.enqueueOwnCapabilities { Set(ownCapabilities) } let mockPublisher = try await XCTAsyncUnwrap( await subject .stateAdapter @@ -330,7 +335,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { func test_startScreensharing_typeIsBroadcast_shouldBeginScreenSharing() async throws { try await prepareAsConnected() let ownCapabilities = [OwnCapability.createReaction] - await subject.stateAdapter.set(ownCapabilities: Set(ownCapabilities)) + await subject.stateAdapter.enqueueOwnCapabilities { Set(ownCapabilities) } let mockPublisher = try await XCTAsyncUnwrap( await subject .stateAdapter @@ -696,7 +701,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { if let videoFilter { await subject.stateAdapter.set(videoFilter: videoFilter) } - await subject.stateAdapter.set(ownCapabilities: ownCapabilities) + await subject.stateAdapter.enqueueOwnCapabilities { ownCapabilities } await subject.stateAdapter.enqueueCallSettings { _ in callSettings } await subject.stateAdapter.set(sessionID: .unique) await subject.stateAdapter.set(token: .unique) diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index 9712491a2..87217b92e 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -199,16 +199,121 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) } - // MARK: - setOwnCapabilities + // MARK: - enqueueOwnCapabilities - func test_setOwnCapabilities_shouldUpdateOwnCapabilities() async throws { + func test_enqueueOwnCapabilities_shouldUpdateOwnCapabilities() async throws { let expected = Set([OwnCapability.blockUsers, .removeCallMember]) - await subject.set(ownCapabilities: expected) + await subject.enqueueOwnCapabilities { expected } await assertEqualAsync(await subject.ownCapabilities, expected) } + func test_enqueueOwnCapabilities_withoutSendAudioCapability_turnsAudioOff() async throws { + await subject.enqueueCallSettings { _ in CallSettings(audioOn: true, videoOn: true) } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn + } + + await subject.enqueueOwnCapabilities { [.sendVideo] } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn == false && currentSettings.videoOn + } + } + + func test_enqueueOwnCapabilities_revokesSendAudioCapability_turnsAudioOff() async throws { + await subject.enqueueOwnCapabilities { [.sendAudio, .sendVideo] } + await subject.enqueueCallSettings { _ in CallSettings(audioOn: true, videoOn: true) } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn + } + + await subject.enqueueOwnCapabilities { [.sendVideo] } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn == false && currentSettings.videoOn + } + } + + func test_enqueueOwnCapabilities_withoutSendVideoCapability_turnsVideoOff() async throws { + await subject.enqueueCallSettings { _ in CallSettings(audioOn: true, videoOn: true) } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn + } + + await subject.enqueueOwnCapabilities { [.sendAudio] } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn == false + } + } + + func test_enqueueOwnCapabilities_revokesSendVideoCapability_turnsVideoOff() async throws { + await subject.enqueueOwnCapabilities { [.sendAudio, .sendVideo] } + await subject.enqueueCallSettings { _ in CallSettings(audioOn: true, videoOn: true) } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn + } + + await subject.enqueueOwnCapabilities { [.sendAudio] } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn == false + } + } + + func test_enqueueOwnCapabilities_revokeBothAudioAndVideoCapabilities_turnsAudioVideoOff() async throws { + await subject.enqueueCallSettings { _ in CallSettings(audioOn: true, videoOn: true) } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn && currentSettings.videoOn + } + + await subject.enqueueOwnCapabilities { [] } + + await fulfillment { + let currentSettings = await self.subject.callSettings + return currentSettings.audioOn == false && currentSettings.videoOn == false + } + } + + func test_enqueueOwnCapabilities_withoutScreenshareCapability_stopsScreenSharing() async throws { + let sfuStack = MockSFUStack() + sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init())) + 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 screenShareSessionProvider = await subject.screenShareSessionProvider + screenShareSessionProvider.activeSession = .init( + localTrack: await subject.peerConnectionFactory.mockVideoTrack(forScreenShare: true), + screenSharingType: .inApp, + capturer: MockStreamVideoCapturer(), + includeAudio: true + ) + + await subject.enqueueOwnCapabilities { [.sendAudio, .sendVideo] } + + await fulfillment { + mockPublisher.timesCalled(.stopScreenSharing) == 1 + } + } + // MARK: - setStatsAdapter func test_setStatsReporter_shouldUpdateStatsAdapter() async throws { @@ -371,7 +476,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) await subject.set(videoFilter: videoFilter) let ownCapabilities = Set([OwnCapability.blockUsers, .changeMaxDuration]) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } let callSettings = CallSettings(cameraPosition: .back) await subject.enqueueCallSettings { _ in callSettings } await fulfillment { @@ -437,7 +542,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) await subject.set(videoFilter: videoFilter) let ownCapabilities = Set([OwnCapability.blockUsers, .changeMaxDuration]) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } let callSettings = CallSettings(cameraPosition: .back) await subject.enqueueCallSettings { _ in callSettings } @@ -471,7 +576,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { includeAudio: true ) let ownCapabilities = Set([OwnCapability.blockUsers]) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } try await subject.configurePeerConnections() let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) @@ -503,7 +608,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init())) await subject.set(sfuAdapter: sfuStack.adapter) let ownCapabilities = Set([OwnCapability.blockUsers]) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } try await subject.configurePeerConnections() let mockPublisher = try await XCTAsyncUnwrap(await subject.publisher as? MockRTCPeerConnectionCoordinator) @@ -524,7 +629,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) await subject.set(statsAdapter: statsAdapter) let ownCapabilities = Set([OwnCapability.blockUsers]) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } try await subject.configureAudioSession(source: .inApp) @@ -546,6 +651,25 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { } } + func test_configureAudioSession_callKitSource_completesActionCompletion() async throws { + let completionExpectation = expectation( + description: "CallKit action completion invoked." + ) + let sfuStack = MockSFUStack() + sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init())) + await subject.set(sfuAdapter: sfuStack.adapter) + let ownCapabilities = Set([OwnCapability.blockUsers]) + await subject.enqueueOwnCapabilities { ownCapabilities } + + try await subject.configureAudioSession( + source: .callKit(.init { + completionExpectation.fulfill() + }) + ) + + await safeFulfillment(of: [completionExpectation], timeout: 1) + } + // MARK: - cleanUp func test_cleanUp_shouldResetProperties() async throws { @@ -668,6 +792,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { participantPins: pins ) await subject.enqueueCallSettings { _ in .init(cameraPosition: .back) } + await fulfillment { await self.subject.callSettings.cameraPosition == .back } await subject.cleanUpForReconnection() @@ -1071,7 +1196,7 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { await fulfillment { await self.subject.sessionID.isEmpty == false } await subject.set(sfuAdapter: sfuStack.adapter) await subject.set(videoFilter: videoFilter) - await subject.set(ownCapabilities: ownCapabilities) + await subject.enqueueOwnCapabilities { ownCapabilities } await subject.enqueueCallSettings { _ in callSettings } await subject.set(token: .unique) await subject.set(participantsCount: 12) diff --git a/StreamVideoUIKit-XCFramework.podspec b/StreamVideoUIKit-XCFramework.podspec index 4df36261b..c4c675781 100644 --- a/StreamVideoUIKit-XCFramework.podspec +++ b/StreamVideoUIKit-XCFramework.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideoUIKit-XCFramework' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo UIKit Video Components' spec.description = 'StreamVideoUIKit SDK offers flexible UIKit components able to display data provided by StreamVideo SDK.' diff --git a/StreamVideoUIKit.podspec b/StreamVideoUIKit.podspec index c1c286939..e8b90b9d1 100644 --- a/StreamVideoUIKit.podspec +++ b/StreamVideoUIKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'StreamVideoUIKit' - spec.version = '1.43.0' + spec.version = '1.44.0' spec.summary = 'StreamVideo UIKit Video Components' spec.description = 'StreamVideoUIKit SDK offers flexible UIKit components able to display data provided by StreamVideo SDK.'