Skip to content

Commit 365b984

Browse files
authored
[Enhancement]Improved audio session management (#906)
1 parent 46d403a commit 365b984

File tree

87 files changed

+2787
-2180
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+2787
-2180
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+
- AudioSession management issues that were causing audio not being recorded during calls. [#906](https://github.com/GetStream/stream-video-swift/pull/906)
89

910
# [1.29.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.29.1)
1011
_July 25, 2025_

DemoApp/Sources/ViewModifiers/MoreControls/DemoMoreControlsViewModifier.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ struct DemoMoreControlsViewModifier: ViewModifier {
5252
)
5353
}
5454

55+
DemoMoreControlListButtonView(
56+
action: { viewModel.toggleAudioOutput() },
57+
label: viewModel.callSettings.audioOutputOn ? "Disable audio output" : "Enable audio output"
58+
) {
59+
Image(
60+
systemName: viewModel.callSettings.audioOutputOn
61+
? "speaker.fill"
62+
: "speaker.slash"
63+
)
64+
}
65+
5566
DemoTranscriptionAndClosedCaptionsButtonView(viewModel: viewModel)
5667

5768
DemoMoreThermalStateButtonView()

Sources/StreamVideo/Call.swift

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
147147
notify: Bool = false,
148148
callSettings: CallSettings? = nil
149149
) async throws -> JoinCallResponse {
150+
/// Determines the source from which the join action was initiated.
151+
///
152+
/// This block checks if the `joinSource` has already been set in the current
153+
/// call state. If not, it assigns `.inApp` as the default join source,
154+
/// indicating the call was joined from within the app UI. The resolved
155+
/// `JoinSource` value is then used to record how the call was joined,
156+
/// enabling analytics and behavioral branching based on entry point.
157+
let joinSource = await {
158+
if let joinSource = await state.joinSource {
159+
return joinSource
160+
} else {
161+
return await Task { @MainActor in
162+
state.joinSource = .inApp
163+
return .inApp
164+
}.value
165+
}
166+
}()
167+
150168
let result: Any? = stateMachine.withLock { currentStage, transitionHandler in
151169
if
152170
currentStage.id == .joined,
@@ -194,6 +212,7 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
194212
options: options,
195213
ring: ring,
196214
notify: notify,
215+
source: joinSource,
197216
deliverySubject: deliverySubject
198217
)
199218
)
@@ -1371,8 +1390,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
13711390
/// - Parameter policy: A conforming `AudioSessionPolicy` that defines
13721391
/// the audio session configuration to be applied.
13731392
/// - Throws: An error if the update fails.
1374-
public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
1375-
try await callController.updateAudioSessionPolicy(policy)
1393+
public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async {
1394+
await callController.updateAudioSessionPolicy(policy)
13761395
}
13771396

13781397
/// Adds a proximity policy to manage device proximity behavior during the call.
@@ -1473,22 +1492,6 @@ public class Call: @unchecked Sendable, WSEventsSubscriber {
14731492
)
14741493
}
14751494

1476-
// MARK: - CallKit
1477-
1478-
/// Notifies the `Call` instance that CallKit has activated the system audio
1479-
/// session.
1480-
///
1481-
/// This method should be called when the system activates the `AVAudioSession`
1482-
/// as a result of an incoming or outgoing CallKit-managed call. It allows the
1483-
/// call to update the provided CallKit AVAudioSession based on the internal CallSettings.
1484-
///
1485-
/// - Parameter audioSession: The active `AVAudioSession` instance provided by
1486-
/// CallKit.
1487-
/// - Throws: An error if the call controller fails to handle the activation.
1488-
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
1489-
try callController.callKitActivated(audioSession)
1490-
}
1491-
14921495
internal func didPerform(_ action: WebRTCTrace.CallKitAction) {
14931496
Task(disposableBag: disposableBag) { [weak callController] in
14941497
await callController?.didPerform(action)

Sources/StreamVideo/CallKit/CallKitService.swift

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
1515
@Injected(\.callCache) private var callCache
1616
@Injected(\.uuidFactory) private var uuidFactory
1717
@Injected(\.currentDevice) private var currentDevice
18+
@Injected(\.audioStore) private var audioStore
1819
private let disposableBag = DisposableBag()
1920

2021
/// Represents a call that is being managed by the service.
@@ -95,6 +96,13 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
9596
private var callEndedNotificationCancellable: AnyCancellable?
9697
private var ringingTimerCancellable: AnyCancellable?
9798

99+
/// A reducer responsible for handling audio session changes triggered by CallKit.
100+
///
101+
/// The `callKitAudioReducer` manages updates to the audio session state in
102+
/// response to CallKit events, ensuring proper activation and deactivation
103+
/// of the audio system when calls are handled through CallKit.
104+
private lazy var callKitAudioReducer = CallKitAudioSessionReducer(store: audioStore)
105+
98106
/// Initializes the `CallKitService` instance.
99107
override public init() {
100108
super.init()
@@ -164,6 +172,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
164172
return
165173
}
166174
do {
175+
167176
if streamVideo.state.connection != .connected {
168177
let result = await Task(disposableBag: disposableBag) { [weak self] in
169178
try await self?.streamVideo?.connect()
@@ -392,17 +401,11 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
392401
subsystems: .callKit
393402
)
394403

395-
if
396-
let active,
397-
let call = callEntry(for: active)?.call {
398-
call.didPerform(.didActivateAudioSession)
399-
400-
do {
401-
try call.callKitActivated(audioSession)
402-
} catch {
403-
log.error(error, subsystems: .callKit)
404-
}
405-
}
404+
/// Activates the audio session for CallKit. This line notifies the audio store
405+
/// to activate the provided AVAudioSession, ensuring that the app's audio
406+
/// routing and configuration are correctly handled when CallKit takes control
407+
/// of the audio session during a call.
408+
audioStore.dispatch(.callKit(.activate(audioSession)))
406409
}
407410

408411
public func provider(
@@ -421,11 +424,11 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
421424
""",
422425
subsystems: .callKit
423426
)
424-
if
425-
let active,
426-
let call = callEntry(for: active)?.call {
427-
call.didPerform(.didDeactivateAudioSession)
428-
}
427+
428+
/// Notifies the audio store to deactivate the provided AVAudioSession.
429+
/// This ensures that when CallKit relinquishes control of the audio session,
430+
/// the app's audio routing and configuration are updated appropriately.
431+
audioStore.dispatch(.callKit(.deactivate(audioSession)))
429432
}
430433

431434
open func provider(
@@ -460,6 +463,10 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
460463
}
461464

462465
do {
466+
/// Sets the join source to `.callKit` to indicate that the call was
467+
/// joined via CallKit. This helps with audioSession management.
468+
callToJoinEntry.call.state.joinSource = .callKit
469+
463470
try await callToJoinEntry.call.join(callSettings: callSettings)
464471
action.fulfill()
465472
} catch {
@@ -640,9 +647,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable {
640647
/// A method that's being called every time the StreamVideo instance is getting updated.
641648
/// - Parameter streamVideo: The new StreamVideo instance (nil if none)
642649
open func didUpdate(_ streamVideo: StreamVideo?) {
650+
if streamVideo != nil {
651+
audioStore.add(callKitAudioReducer)
652+
} else {
653+
audioStore.remove(callKitAudioReducer)
654+
}
655+
643656
guard currentDevice.deviceType != .simulator else {
644657
return
645658
}
659+
646660
subscribeToCallEvents()
647661
}
648662

Sources/StreamVideo/CallState.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,14 @@ public class CallState: ObservableObject {
155155
}
156156
}
157157
}
158-
158+
159+
/// Describes the source from which the join action was triggered for this call.
160+
///
161+
/// Use this property to determine whether the current call was joined from
162+
/// the app's UI or via a system-level integration such as CallKit. This can
163+
/// help customize logic, analytics, and UI based on how the call was started.
164+
var joinSource: JoinSource?
165+
159166
private var localCallSettingsUpdate = false
160167
private var durationCancellable: AnyCancellable?
161168
private nonisolated let disposableBag = DisposableBag()

Sources/StreamVideo/CallStateMachine/Stages/Call+JoiningStage.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ extension Call.StateMachine.Stage {
122122
callSettings: input.callSettings,
123123
options: input.options,
124124
ring: input.ring,
125-
notify: input.notify
125+
notify: input.notify,
126+
source: input.source
126127
)
127128

128129
if let callSettings = input.callSettings {

Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ extension Call.StateMachine {
3030
var options: CreateCallOptions?
3131
var ring: Bool
3232
var notify: Bool
33+
var source: JoinSource
3334
var deliverySubject: PassthroughSubject<JoinCallResponse, Error>
3435

3536
var currentNumberOfRetries = 0

Sources/StreamVideo/Controllers/CallController.swift

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,24 +105,29 @@ class CallController: @unchecked Sendable {
105105
.sinkTask(storeIn: disposableBag) { [weak self] in await self?.didFetch($0) }
106106
}
107107

108-
/// Joins a call with the provided information.
108+
/// Joins a call with the provided information and join source.
109+
///
109110
/// - Parameters:
110-
/// - callType: the type of the call
111-
/// - callId: the id of the call
112-
/// - callSettings: the current call settings
113-
/// - videoOptions: configuration options about the video
114-
/// - options: create call options
115-
/// - migratingFrom: if SFU migration is being performed
116-
/// - ring: whether ringing events should be handled
117-
/// - notify: whether uses should be notified about the call
118-
/// - Returns: a newly created `Call`.
111+
/// - callType: The type of the call.
112+
/// - callId: The id of the call.
113+
/// - callSettings: The current call settings.
114+
/// - videoOptions: Configuration options about the video.
115+
/// - options: Create call options.
116+
/// - migratingFrom: If SFU migration is being performed.
117+
/// - ring: Whether ringing events should be handled.
118+
/// - notify: Whether users should be notified about the call.
119+
/// - source: Describes the source from which the join action was triggered.
120+
/// Use this to indicate if the call was joined from in-app UI or
121+
/// via CallKit.
122+
/// - Returns: A newly created `JoinCallResponse`.
119123
@discardableResult
120124
func joinCall(
121125
create: Bool = true,
122126
callSettings: CallSettings?,
123127
options: CreateCallOptions? = nil,
124128
ring: Bool = false,
125-
notify: Bool = false
129+
notify: Bool = false,
130+
source: JoinSource
126131
) async throws -> JoinCallResponse {
127132
joinCallResponseSubject = .init(nil)
128133

@@ -131,7 +136,8 @@ class CallController: @unchecked Sendable {
131136
callSettings: callSettings,
132137
options: options,
133138
ring: ring,
134-
notify: notify
139+
notify: notify,
140+
source: source
135141
)
136142

137143
guard
@@ -479,8 +485,8 @@ class CallController: @unchecked Sendable {
479485
///
480486
/// - Parameter policy: The audio session policy to apply
481487
/// - Throws: An error if the policy update fails
482-
func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async throws {
483-
try await webRTCCoordinator.updateAudioSessionPolicy(policy)
488+
func updateAudioSessionPolicy(_ policy: AudioSessionPolicy) async {
489+
await webRTCCoordinator.updateAudioSessionPolicy(policy)
484490
}
485491

486492
/// Sets up observation of WebRTC state changes.
@@ -501,10 +507,6 @@ class CallController: @unchecked Sendable {
501507
.sink { [weak self] in self?.webRTCClientDidUpdateStage($0) }
502508
}
503509

504-
internal func callKitActivated(_ audioSession: AVAudioSessionProtocol) throws {
505-
try webRTCCoordinator.callKitActivated(audioSession)
506-
}
507-
508510
// MARK: - Client Capabilities
509511

510512
func enableClientCapabilities(_ capabilities: Set<ClientCapability>) async {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
/// An enumeration that describes the source from which a call was joined.
8+
///
9+
/// Use `JoinSource` to indicate whether the join action originated from within
10+
/// the app's own UI or through a system-level interface such as CallKit.
11+
/// This helps distinguish the user's entry point and can be used to customize
12+
/// behavior or analytics based on how the call was initiated.
13+
enum JoinSource {
14+
/// Indicates that the call was joined from within the app's UI.
15+
case inApp
16+
17+
/// Indicates that the call was joined via CallKit integration.
18+
case callKit
19+
}

Sources/StreamVideo/StreamVideo.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {
1616

1717
@Injected(\.callCache) private var callCache
1818
@Injected(\.screenProperties) private var screenProperties
19+
@Injected(\.audioStore) private var audioStore
1920

2021
private enum DisposableKey: String { case ringEventReceived }
2122

0 commit comments

Comments
 (0)