Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_

Expand All @@ -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_

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-5.9%2B-orange.svg" /></a>
</p>
<p align="center">
<img id="stream-video-label" alt="StreamVideo" src="https://img.shields.io/badge/StreamVideo-10.06%20MB-blue"/>
<img id="stream-video-label" alt="StreamVideo" src="https://img.shields.io/badge/StreamVideo-10.12%20MB-blue"/>
<img id="stream-video-swiftui-label" alt="StreamVideoSwiftUI" src="https://img.shields.io/badge/StreamVideoSwiftUI-2.45%20MB-blue"/>
<img id="stream-video-uikit-label" alt="StreamVideoUIKit" src="https://img.shields.io/badge/StreamVideoUIKit-2.58%20MB-blue"/>
<img id="stream-web-rtc-label" alt="StreamWebRTC" src="https://img.shields.io/badge/StreamWebRTC-11.09%20MB-blue"/>
Expand Down
68 changes: 49 additions & 19 deletions Sources/StreamVideo/Call.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -143,14 +145,16 @@ 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(
create: Bool = false,
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.
///
Expand Down Expand Up @@ -218,7 +222,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
ring: ring,
notify: notify,
source: joinSource,
deliverySubject: deliverySubject
deliverySubject: deliverySubject,
policy: policy
)
)
)
Expand Down Expand Up @@ -262,14 +267,21 @@ 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
}
}
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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<RejectCallResponse, Error>()
let deliverySubject = CurrentValueSubject<RejectCallResponse?, Error>(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)
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -77,18 +77,26 @@ 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.
open func pushRegistry(
_ 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.
Expand Down
23 changes: 17 additions & 6 deletions Sources/StreamVideo/CallKit/CallKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
Loading
Loading