diff --git a/CHANGELOG.md b/CHANGELOG.md index b0880ca11..0dc9d6944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_ diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index c1e4ee379..2129e39a8 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -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() 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/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index ecea42262..0844f0565 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -785,7 +785,13 @@ 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(), @@ -793,7 +799,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: source == .inApp ) } diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index 2cb53cf7c..27250b6e4 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -587,8 +587,9 @@ 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( @@ -596,7 +597,7 @@ final class Call_Tests: StreamVideoTestCase, @unchecked Sendable { (Bool, CallSettings?, CreateCallOptions?, Bool, Bool, JoinSource).self, for: .join )?.first?.5, - .callKit + expectedJoinSource ) } diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index ce895cbe0..696cc2230 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -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, @@ -113,7 +114,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { options: options, ring: true, notify: true, - source: .callKit + source: expectedJoinSource ) } } @@ -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 diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index c51b681f9..121a22585 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -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, @@ -98,7 +99,7 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { options: expectedOptions, ring: true, notify: true, - source: .callKit + source: expectedJoinSource ) } ) { stage in @@ -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 diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index 1d06a0141..87217b92e 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -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.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 {