Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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)

# [1.43.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.43.0)
_February 27, 2026_
Expand Down
11 changes: 8 additions & 3 deletions Sources/StreamVideo/CallKit/CallKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,14 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
}

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()
})

try await callToJoinEntry.call.join(callSettings: callSettings)
action.fulfill()
Expand Down
33 changes: 31 additions & 2 deletions Sources/StreamVideo/Models/JoinSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
10 changes: 8 additions & 2 deletions Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -785,15 +785,21 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W
try await audioStore.dispatch([
.setAudioDeviceModule(peerConnectionFactory.audioDeviceModule)
]).result()


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(),
delegate: self,
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: source == .inApp
)
}

Expand Down
5 changes: 3 additions & 2 deletions StreamVideoTests/Call/Call_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -587,16 +587,17 @@ final class Call_Tests: StreamVideoTestCase, @unchecked Sendable {
let call = MockCall(.dummy(callController: mockCallController))
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,
for: .join
)?.first?.5,
.callKit
expectedJoinSource
)
}

Expand Down
5 changes: 3 additions & 2 deletions StreamVideoTests/Controllers/CallController_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable {
func test_joinCall_coordinatorTransitionsToConnecting() async throws {
let callSettings = CallSettings(cameraPosition: .back)
let options = CreateCallOptions(team: .unique)
let expectedJoinSource = JoinSource.callKit(.init {})

try await assertTransitionToStage(
.connecting,
Expand All @@ -113,7 +114,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable {
options: options,
ring: true,
notify: true,
source: .callKit
source: expectedJoinSource
)
}
}
Expand All @@ -122,7 +123,7 @@ 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)
await self.assertEqualAsync(
await self
.mockWebRTCCoordinatorFactory
Expand Down
5 changes: 3 additions & 2 deletions StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
startsAt: .init(timeIntervalSince1970: 100),
team: .unique
)
let expectedJoinSource = JoinSource.callKit(.init {})

try await assertTransitionToStage(
.connecting,
Expand All @@ -98,7 +99,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
options: expectedOptions,
ring: true,
notify: true,
source: .callKit
source: expectedJoinSource
)
}
) { stage in
Expand All @@ -111,7 +112,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(expectedStage.options?.team, expectedOptions.team)
XCTAssertTrue(expectedStage.ring)
XCTAssertTrue(expectedStage.notify)
XCTAssertEqual(expectedStage.context.joinSource, .callKit)
XCTAssertEqual(expectedStage.context.joinSource, expectedJoinSource)
await self.assertEqualAsync(
await self.subject.stateAdapter.initialCallSettings,
expectedCallSettings
Expand Down
19 changes: 19 additions & 0 deletions StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,25 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable {
}
}

func test_configureAudioSession_callKitSource_completesActionCompletion() async throws {
let completionExpectation = expectation(
description: "CallKit action completion invoked."
)
let sfuStack = MockSFUStack()
sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init()))
await subject.set(sfuAdapter: sfuStack.adapter)
let ownCapabilities = Set<OwnCapability>([OwnCapability.blockUsers])
await subject.enqueueOwnCapabilities { ownCapabilities }

try await subject.configureAudioSession(
source: .callKit(.init {
completionExpectation.fulfill()
})
)

await safeFulfillment(of: [completionExpectation], timeout: 1)
}

// MARK: - cleanUp

func test_cleanUp_shouldResetProperties() async throws {
Expand Down
Loading