Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -14,6 +14,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/1080)

# [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