Skip to content

Commit 6b78beb

Browse files
authored
[Enhancement]Keep CallKit answer actions alive during WebRTC audio setup (#1081)
1 parent d3034e8 commit 6b78beb

File tree

8 files changed

+76
-13
lines changed

8 files changed

+76
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1515
- Fix local mediaAdapters not reacting to changed own capabilities. [#1070](https://github.com/GetStream/stream-video-swift/pull/1070)
1616
- Fix label color when presenting. [#1077](https://github.com/GetStream/stream-video-swift/pull/1077)
1717
- 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)
18+
- 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)
1819

1920
# [1.43.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.43.0)
2021
_February 27, 2026_

Sources/StreamVideo/CallKit/CallKitService.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,14 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
473473
}
474474

475475
do {
476-
/// Mark join source as `.callKit` for audio session.
477-
///
478-
callToJoinEntry.call.state.joinSource = .callKit
476+
// Pass a CallKit completion hook through the join flow so the
477+
// WebRTC layer can release CallKit's audio session ownership as
478+
// soon as it has configured the audio device module.
479+
callToJoinEntry.call.state.joinSource = .callKit(.init {
480+
// Allow CallKit to hand audio session activation back to
481+
// the app before we continue configuring audio locally.
482+
action.fulfill()
483+
})
479484

480485
try await callToJoinEntry.call.join(callSettings: callSettings)
481486
action.fulfill()

Sources/StreamVideo/Models/JoinSource.swift

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,39 @@ import Foundation
1010
/// the app's own UI or through a system-level interface such as CallKit.
1111
/// This helps distinguish the user's entry point and can be used to customize
1212
/// behavior or analytics based on how the call was initiated.
13-
enum JoinSource {
13+
enum JoinSource: Sendable, Equatable {
14+
/// Carries the completion hook CallKit expects us to invoke once the SDK is
15+
/// ready for CallKit to hand audio session ownership back to the app.
16+
struct ActionCompletion: @unchecked Sendable {
17+
fileprivate let identifier: UUID = .init()
18+
private let completion: () -> Void
19+
20+
init(_ completion: @escaping () -> Void) {
21+
self.completion = completion
22+
}
23+
24+
/// Invokes the stored completion callback.
25+
func complete() {
26+
completion()
27+
}
28+
}
29+
1430
/// Indicates that the call was joined from within the app's UI.
1531
case inApp
1632

1733
/// Indicates that the call was joined via CallKit integration.
18-
case callKit
34+
case callKit(ActionCompletion)
35+
36+
/// Compares `JoinSource` values while treating CallKit sources as distinct
37+
/// whenever they wrap different completion hooks.
38+
static func == (lhs: JoinSource, rhs: JoinSource) -> Bool {
39+
switch (lhs, rhs) {
40+
case (.inApp, .inApp):
41+
return true
42+
case (.callKit(let lhs), .callKit(let rhs)):
43+
return lhs.identifier == rhs.identifier
44+
default:
45+
return false
46+
}
47+
}
1948
}

Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,15 +785,21 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate, W
785785
try await audioStore.dispatch([
786786
.setAudioDeviceModule(peerConnectionFactory.audioDeviceModule)
787787
]).result()
788-
788+
789+
if case let .callKit(completion) = source {
790+
// Let CallKit release its audio session ownership once WebRTC has
791+
// the audio device module it needs.
792+
completion.complete()
793+
}
794+
789795
audioSession.activate(
790796
callSettingsPublisher: $callSettings.removeDuplicates().eraseToAnyPublisher(),
791797
ownCapabilitiesPublisher: $ownCapabilities.removeDuplicates().eraseToAnyPublisher(),
792798
delegate: self,
793799
statsAdapter: statsAdapter,
794800
/// If we are joining from CallKit the AudioSession will be activated from it and we
795801
/// shouldn't attempt another activation.
796-
shouldSetActive: source != .callKit
802+
shouldSetActive: source == .inApp
797803
)
798804
}
799805

StreamVideoTests/Call/Call_Tests.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -587,16 +587,17 @@ final class Call_Tests: StreamVideoTestCase, @unchecked Sendable {
587587
let call = MockCall(.dummy(callController: mockCallController))
588588
call.stub(for: \.state, with: .init(.dummy()))
589589
mockCallController.stub(for: .join, with: JoinCallResponse.dummy())
590+
let expectedJoinSource = JoinSource.callKit(.init {})
590591

591-
call.state.joinSource = .callKit
592+
call.state.joinSource = expectedJoinSource
592593
_ = try await call.join()
593594

594595
XCTAssertEqual(
595596
mockCallController.recordedInputPayload(
596597
(Bool, CallSettings?, CreateCallOptions?, Bool, Bool, JoinSource).self,
597598
for: .join
598599
)?.first?.5,
599-
.callKit
600+
expectedJoinSource
600601
)
601602
}
602603

StreamVideoTests/Controllers/CallController_Tests.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable {
9999
func test_joinCall_coordinatorTransitionsToConnecting() async throws {
100100
let callSettings = CallSettings(cameraPosition: .back)
101101
let options = CreateCallOptions(team: .unique)
102+
let expectedJoinSource = JoinSource.callKit(.init {})
102103

103104
try await assertTransitionToStage(
104105
.connecting,
@@ -113,7 +114,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable {
113114
options: options,
114115
ring: true,
115116
notify: true,
116-
source: .callKit
117+
source: expectedJoinSource
117118
)
118119
}
119120
}
@@ -122,7 +123,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable {
122123
XCTAssertEqual(expectedStage.options?.team, options.team)
123124
XCTAssertTrue(expectedStage.ring)
124125
XCTAssertTrue(expectedStage.notify)
125-
XCTAssertEqual(expectedStage.context.joinSource, .callKit)
126+
XCTAssertEqual(expectedStage.context.joinSource, expectedJoinSource)
126127
await self.assertEqualAsync(
127128
await self
128129
.mockWebRTCCoordinatorFactory

StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
8787
startsAt: .init(timeIntervalSince1970: 100),
8888
team: .unique
8989
)
90+
let expectedJoinSource = JoinSource.callKit(.init {})
9091

9192
try await assertTransitionToStage(
9293
.connecting,
@@ -98,7 +99,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
9899
options: expectedOptions,
99100
ring: true,
100101
notify: true,
101-
source: .callKit
102+
source: expectedJoinSource
102103
)
103104
}
104105
) { stage in
@@ -111,7 +112,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable {
111112
XCTAssertEqual(expectedStage.options?.team, expectedOptions.team)
112113
XCTAssertTrue(expectedStage.ring)
113114
XCTAssertTrue(expectedStage.notify)
114-
XCTAssertEqual(expectedStage.context.joinSource, .callKit)
115+
XCTAssertEqual(expectedStage.context.joinSource, expectedJoinSource)
115116
await self.assertEqualAsync(
116117
await self.subject.stateAdapter.initialCallSettings,
117118
expectedCallSettings

StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,25 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable {
651651
}
652652
}
653653

654+
func test_configureAudioSession_callKitSource_completesActionCompletion() async throws {
655+
let completionExpectation = expectation(
656+
description: "CallKit action completion invoked."
657+
)
658+
let sfuStack = MockSFUStack()
659+
sfuStack.setConnectionState(to: .connected(healthCheckInfo: .init()))
660+
await subject.set(sfuAdapter: sfuStack.adapter)
661+
let ownCapabilities = Set<OwnCapability>([OwnCapability.blockUsers])
662+
await subject.enqueueOwnCapabilities { ownCapabilities }
663+
664+
try await subject.configureAudioSession(
665+
source: .callKit(.init {
666+
completionExpectation.fulfill()
667+
})
668+
)
669+
670+
await safeFulfillment(of: [completionExpectation], timeout: 1)
671+
}
672+
654673
// MARK: - cleanUp
655674

656675
func test_cleanUp_shouldResetProperties() async throws {

0 commit comments

Comments
 (0)