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 @@
-
+
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