Skip to content

Commit 10f9d21

Browse files
authored
[Fix]CallKit AudioSession speaker sync (#807)
1 parent a852236 commit 10f9d21

File tree

14 files changed

+352
-6
lines changed

14 files changed

+352
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7-
### 🔄 Changed
7+
### 🐞 Fixed
8+
- Synchronize CallKit audioSession with the audioSession in the app. [#807](https://github.com/GetStream/stream-video-swift/pull/807)
89

910
# [1.22.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.22.1)
1011
_May 08, 2025_

Sources/StreamVideo/Call.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,20 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
14281428
)
14291429
}
14301430

1431+
/// Notifies the `Call` instance that CallKit has activated the system audio
1432+
/// session.
1433+
///
1434+
/// This method should be called when the system activates the `AVAudioSession`
1435+
/// as a result of an incoming or outgoing CallKit-managed call. It allows the
1436+
/// call to update the provided CallKit AVAudioSession based on the internal CallSettings.
1437+
///
1438+
/// - Parameter audioSession: The active `AVAudioSession` instance provided by
1439+
/// CallKit.
1440+
/// - Throws: An error if the call controller fails to handle the activation.
1441+
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
1442+
try callController.callKitActivated(audioSession)
1443+
}
1444+
14311445
// MARK: - private
14321446

14331447
private func updatePermissions(

Sources/StreamVideo/CallKit/CallKitService.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
327327
""",
328328
subsystems: .callKit
329329
)
330+
331+
if
332+
let active,
333+
let call = callEntry(for: active)?.call {
334+
do {
335+
try call.callKitActivated(audioSession)
336+
} catch {
337+
log.error(error, subsystems: .callKit)
338+
}
339+
}
330340
}
331341

332342
public func provider(

Sources/StreamVideo/Controllers/CallController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,10 @@ class CallController: @unchecked Sendable {
495495
.sink { [weak self] in self?.webRTCClientDidUpdateStage($0) }
496496
}
497497

498+
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
499+
try webRTCCoordinator.callKitActivated(audioSession)
500+
}
501+
498502
// MARK: - private
499503

500504
private func handleParticipantsUpdated() {

Sources/StreamVideo/Utils/AudioSession/Policies/DefaultAudioSessionPolicy.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ public struct DefaultAudioSessionPolicy: AudioSessionPolicy {
3232
speakerOn: callSettings.speakerOn,
3333
appIsInForeground: false
3434
),
35-
overrideOutputAudioPort: nil
35+
overrideOutputAudioPort: callSettings.speakerOn
36+
? .speaker
37+
: AVAudioSession.PortOverride.none
3638
)
3739
}
3840

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Foundation
7+
8+
protocol AVAudioSessionProtocol {
9+
10+
/// Configures the audio session category and options.
11+
/// - Parameters:
12+
/// - category: The audio category (e.g., `.playAndRecord`).
13+
/// - mode: The audio mode (e.g., `.videoChat`).
14+
/// - categoryOptions: The options for the category (e.g., `.allowBluetooth`).
15+
/// - Throws: An error if setting the category fails.
16+
func setCategory(
17+
_ category: AVAudioSession.Category,
18+
mode: AVAudioSession.Mode,
19+
with categoryOptions: AVAudioSession.CategoryOptions
20+
) throws
21+
22+
/// Overrides the audio output port (e.g., to speaker).
23+
/// - Parameter port: The output port override.
24+
/// - Throws: An error if overriding fails.
25+
func setOverrideOutputAudioPort(
26+
_ port: AVAudioSession.PortOverride
27+
) throws
28+
}
29+
30+
extension AVAudioSession: AVAudioSessionProtocol {
31+
func setCategory(
32+
_ category: Category,
33+
mode: Mode,
34+
with categoryOptions: CategoryOptions
35+
) throws {
36+
try setCategory(
37+
category,
38+
mode: mode,
39+
options: categoryOptions
40+
)
41+
}
42+
43+
func setOverrideOutputAudioPort(_ port: PortOverride) throws {
44+
try overrideOutputAudioPort(port)
45+
}
46+
}

Sources/StreamVideo/Utils/AudioSession/StreamAudioSession.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ final class StreamAudioSession: @unchecked Sendable, ObservableObject {
133133
}
134134
}
135135

136+
func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
137+
let configuration = policy.configuration(
138+
for: activeCallSettings,
139+
ownCapabilities: ownCapabilities
140+
)
141+
142+
try audioSession.setCategory(
143+
configuration.category,
144+
mode: configuration.mode,
145+
with: configuration.options
146+
)
147+
148+
if let overrideOutputAudioPort = configuration.overrideOutputAudioPort {
149+
try audioSession.setOverrideOutputAudioPort(overrideOutputAudioPort)
150+
} else {
151+
try audioSession.setOverrideOutputAudioPort(.none)
152+
}
153+
}
154+
136155
// MARK: - OwnCapabilities
137156

138157
/// Updates the audio session with new call settings.

Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,10 @@ final class WebRTCCoordinator: @unchecked Sendable {
432432
try await stateAdapter.audioSession.didUpdatePolicy(policy)
433433
}
434434

435+
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
436+
try stateAdapter.audioSession.callKitActivated(audioSession)
437+
}
438+
435439
// MARK: - Private
436440

437441
/// Creates the state machine for managing WebRTC stages.

StreamVideo.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@
330330
407E67592DC101DF00878FFC /* CallCRUDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407E67582DC101DF00878FFC /* CallCRUDTests.swift */; };
331331
407F29FF2AA6011500C3EAF8 /* MemoryLogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */; };
332332
407F2A002AA6011B00C3EAF8 /* LogQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */; };
333+
40802AE92DD2A7C700B9F970 /* AVAudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40802AE82DD2A7C700B9F970 /* AVAudioSessionProtocol.swift */; };
334+
40802AEB2DD2A92E00B9F970 /* MockAVAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40802AEA2DD2A92E00B9F970 /* MockAVAudioSession.swift */; };
333335
408521E52D661C7600F012B8 /* RawJSON+Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408521E42D661C7500F012B8 /* RawJSON+Double.swift */; };
334336
408521E72D661CA700F012B8 /* ThermalState+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408521E62D661CA700F012B8 /* ThermalState+Comparable.swift */; };
335337
408679F72BD12F1000D027E0 /* AudioFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408679F62BD12F1000D027E0 /* AudioFilter.swift */; };
@@ -1839,6 +1841,8 @@
18391841
407AF7192B6163DD00E9E3E7 /* StreamMediaDurationFormatter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamMediaDurationFormatter_Tests.swift; sourceTree = "<group>"; };
18401842
407D5D3C2ACEF0C500B5044E /* VisibilityThresholdModifier_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityThresholdModifier_Tests.swift; sourceTree = "<group>"; };
18411843
407E67582DC101DF00878FFC /* CallCRUDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallCRUDTests.swift; sourceTree = "<group>"; };
1844+
40802AE82DD2A7C700B9F970 /* AVAudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVAudioSessionProtocol.swift; sourceTree = "<group>"; };
1845+
40802AEA2DD2A92E00B9F970 /* MockAVAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAVAudioSession.swift; sourceTree = "<group>"; };
18421846
408521E42D661C7500F012B8 /* RawJSON+Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RawJSON+Double.swift"; sourceTree = "<group>"; };
18431847
408521E62D661CA700F012B8 /* ThermalState+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThermalState+Comparable.swift"; sourceTree = "<group>"; };
18441848
408679F62BD12F1000D027E0 /* AudioFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFilter.swift; sourceTree = "<group>"; };
@@ -3575,6 +3579,7 @@
35753579
4067F3062CDA32F0002E28BD /* AudioSession */ = {
35763580
isa = PBXGroup;
35773581
children = (
3582+
40802AE72DD2A7BA00B9F970 /* Protocols */,
35783583
40F101632D5A322E00C49481 /* Policies */,
35793584
4067F3092CDA330E002E28BD /* Extensions */,
35803585
40149DCA2B7E813500473176 /* AudioRecorder */,
@@ -3716,6 +3721,14 @@
37163721
path = Stages;
37173722
sourceTree = "<group>";
37183723
};
3724+
40802AE72DD2A7BA00B9F970 /* Protocols */ = {
3725+
isa = PBXGroup;
3726+
children = (
3727+
40802AE82DD2A7C700B9F970 /* AVAudioSessionProtocol.swift */,
3728+
);
3729+
path = Protocols;
3730+
sourceTree = "<group>";
3731+
};
37193732
408679F52BD12EFC00D027E0 /* AudioFilter */ = {
37203733
isa = PBXGroup;
37213734
children = (
@@ -5640,6 +5653,7 @@
56405653
40B48C292D14CF3B002C4EAB /* MockRTCRtpCodecCapability.swift */,
56415654
40B48C2F2D14D308002C4EAB /* MockRTCRtpEncodingParameters.swift */,
56425655
4067F3182CDA469C002E28BD /* MockAudioSession.swift */,
5656+
40802AEA2DD2A92E00B9F970 /* MockAVAudioSession.swift */,
56435657
409774AD2CC1979F00E0D3EE /* MockCallController.swift */,
56445658
404A81302DA3C5F0001F7FA8 /* MockDefaultAPI.swift */,
56455659
40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */,
@@ -7353,6 +7367,7 @@
73537367
40BBC4C42C638789002AEF92 /* RTCPeerConnectionCoordinator.swift in Sources */,
73547368
4067F3152CDA4094002E28BD /* StreamRTCAudioSession.swift in Sources */,
73557369
40BBC4C62C638915002AEF92 /* WebRTCCoordinator.swift in Sources */,
7370+
40802AE92DD2A7C700B9F970 /* AVAudioSessionProtocol.swift in Sources */,
73567371
841BAA392BD15CDE000C73E4 /* UserSessionStats.swift in Sources */,
73577372
406B3BD72C8F332200FC93A1 /* RTCVideoTrack+Sendable.swift in Sources */,
73587373
406128812CF32FEF007F5CDC /* SDPLineVisitor.swift in Sources */,
@@ -7869,6 +7884,7 @@
78697884
40AB34BA2C5D2F6200B5B6B3 /* Task_TimeoutTests.swift in Sources */,
78707885
40AAD1972D2EFED200D10330 /* Stream_Video_Sfu_Event_VideoSender+Dummy.swift in Sources */,
78717886
40A0E9642B88DE830089E8D3 /* SerialActor_Tests.swift in Sources */,
7887+
40802AEB2DD2A92E00B9F970 /* MockAVAudioSession.swift in Sources */,
78727888
40F017692BBEF1DA00E89FD1 /* EgressHLSResponse+Dummy.swift in Sources */,
78737889
40382F3F2C89C14300C2D00F /* RTCMediaStreamTrack+dummy.swift in Sources */,
78747890
40AAD18C2D2EC82700D10330 /* StreamRTCStatisticsReport+Dummy.swift in Sources */,

StreamVideoTests/CallKit/CallKitServiceTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright © 2025 Stream.io Inc. All rights reserved.
33
//
44

5+
import AVFoundation
56
import CallKit
67
import Foundation
78
@testable import StreamVideo
@@ -349,6 +350,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
349350
XCTAssertEqual(callSettings, customCallSettings)
350351
case .updateTrackSize:
351352
XCTFail()
353+
case .callKitActivated:
354+
XCTFail()
352355
}
353356
}
354357

@@ -413,6 +416,8 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
413416
XCTAssertEqual(callSettings, customCallSettings)
414417
case .updateTrackSize:
415418
XCTFail()
419+
case .callKitActivated:
420+
XCTFail()
416421
}
417422
XCTAssertEqual(call.microphone.status, .enabled)
418423

@@ -679,6 +684,41 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable {
679684
}
680685
}
681686

687+
// MARK: - didActivate
688+
689+
@MainActor
690+
func test_didActivate_audioSessionWasConfiguredCorrectly() async throws {
691+
let firstCallUUID = UUID()
692+
uuidFactory.getResult = firstCallUUID
693+
let call = stubCall(response: defaultGetCallResponse)
694+
subject.streamVideo = mockedStreamVideo
695+
696+
subject.reportIncomingCall(
697+
cid,
698+
localizedCallerName: localizedCallerName,
699+
callerId: callerId,
700+
hasVideo: false
701+
) { _ in }
702+
703+
await waitExpectation(timeout: 1)
704+
// Accept call
705+
subject.provider(
706+
callProvider,
707+
perform: CXAnswerCallAction(
708+
call: firstCallUUID
709+
)
710+
)
711+
712+
await waitExpectation(timeout: 1)
713+
call.state.callSettings = .init(speakerOn: true)
714+
715+
let audioSession = AVAudioSession.sharedInstance()
716+
subject.provider(callProvider, didActivate: audioSession)
717+
718+
XCTAssertEqual(call.timesCalled(.callKitActivated), 1)
719+
XCTAssertTrue(call.recordedInputPayload(AVAudioSession.self, for: .callKitActivated)?.first === audioSession)
720+
}
721+
682722
// MARK: - Private Helpers
683723

684724
@MainActor
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Combine
7+
@testable import StreamVideo
8+
import StreamWebRTC
9+
10+
final class MockAVAudioSession: AVAudioSessionProtocol, Mockable, @unchecked Sendable {
11+
12+
// MARK: - Mockable
13+
14+
typealias FunctionKey = MockFunctionKey
15+
typealias FunctionInputKey = MockFunctionInputKey
16+
17+
/// Defines the "functions" or property accesses we want to track or stub.
18+
enum MockFunctionKey: CaseIterable {
19+
case setCategory
20+
case setOverrideOutputAudioPort
21+
}
22+
23+
/// Defines typed payloads passed along with tracked function calls.
24+
enum MockFunctionInputKey: Payloadable {
25+
case setCategory(
26+
category: AVAudioSession.Category,
27+
mode: AVAudioSession.Mode,
28+
options: AVAudioSession.CategoryOptions
29+
)
30+
case setOverrideOutputAudioPort(value: AVAudioSession.PortOverride)
31+
32+
// Return an untyped payload for storage in the base Mockable dictionary.
33+
var payload: Any {
34+
switch self {
35+
case let .setCategory(category, mode, options):
36+
return (category, mode, options)
37+
38+
case let .setOverrideOutputAudioPort(value):
39+
return value
40+
}
41+
}
42+
}
43+
44+
// MARK: - Mockable Storage
45+
46+
var stubbedProperty: [String: Any] = [:]
47+
var stubbedFunction: [FunctionKey: Any] = [:]
48+
@Atomic
49+
var stubbedFunctionInput: [FunctionKey: [FunctionInputKey]] = FunctionKey.allCases
50+
.reduce(into: [FunctionKey: [MockFunctionInputKey]]()) { $0[$1] = [] }
51+
52+
func stub<T>(for keyPath: KeyPath<MockAVAudioSession, T>, with value: T) {
53+
stubbedProperty[propertyKey(for: keyPath)] = value
54+
}
55+
56+
func stub<T>(for function: FunctionKey, with value: T) {
57+
stubbedFunction[function] = value
58+
}
59+
60+
// MARK: - AVAudioSessionProtocol
61+
62+
/// Sets the audio category, mode, and options.
63+
func setCategory(
64+
_ category: AVAudioSession.Category,
65+
mode: AVAudioSession.Mode,
66+
with categoryOptions: AVAudioSession.CategoryOptions
67+
) throws {
68+
record(
69+
.setCategory,
70+
input: .setCategory(
71+
category: category,
72+
mode: mode,
73+
options: categoryOptions
74+
)
75+
)
76+
if let error = stubbedFunction[.setCategory] as? Error {
77+
throw error
78+
}
79+
}
80+
81+
/// Overrides the audio output port.
82+
func setOverrideOutputAudioPort(_ port: AVAudioSession.PortOverride) throws {
83+
record(
84+
.setOverrideOutputAudioPort,
85+
input: .setOverrideOutputAudioPort(value: port)
86+
)
87+
if let error = stubbedFunction[.setOverrideOutputAudioPort] as? Error {
88+
throw error
89+
}
90+
}
91+
92+
// MARK: - Helpers
93+
94+
/// Tracks calls to a specific function/property in the mock.
95+
private func record(
96+
_ function: FunctionKey,
97+
input: FunctionInputKey? = nil
98+
) {
99+
if let input {
100+
stubbedFunctionInput[function]?.append(input)
101+
} else {
102+
// Still record the call, but with no input
103+
stubbedFunctionInput[function]?.append(contentsOf: [])
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)