diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index c218c1990..11bb444ab 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -72,7 +72,8 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { } } - self.callController.call = self + callController.call = self + speaker.call = self // It's important to instantiate the stateMachine as soon as possible // to ensure it's uniqueness. _ = stateMachine diff --git a/Sources/StreamVideo/CallSettings/SpeakerManager.swift b/Sources/StreamVideo/CallSettings/SpeakerManager.swift index 0566b415a..83ebc6f72 100644 --- a/Sources/StreamVideo/CallSettings/SpeakerManager.swift +++ b/Sources/StreamVideo/CallSettings/SpeakerManager.swift @@ -7,12 +7,19 @@ import Foundation /// Handles the speaker state during a call. public final class SpeakerManager: ObservableObject, CallSettingsManager, @unchecked Sendable { - - internal let callController: CallController + @Published public internal(set) var status: CallSettingsStatus @Published public internal(set) var audioOutputStatus: CallSettingsStatus + + weak var call: Call? { + didSet { Task { await didUpdateCall(call) } } + } + + internal let callController: CallController internal let state = CallSettingsState() - + + private let disposableBag = DisposableBag() + init( callController: CallController, initialSpeakerStatus: CallSettingsStatus, @@ -75,4 +82,28 @@ public final class SpeakerManager: ObservableObject, CallSettingsManager, @unche } ) } + + @MainActor + private func didUpdateCall(_ call: Call?) { + let observationKey = "call-settings-cancellable" + disposableBag.remove(observationKey) + + guard let call else { + return + } + + let typeOfSelf = type(of: self) + call + .state + .$callSettings + .removeDuplicates() + .map { (speakerOn: $0.speakerOn, audioOutputOn: $0.audioOutputOn) } + .log(.debug) { "\(typeOfSelf) callSettings updated speakerOn:\($0.speakerOn) audioOutputOn:\($0.audioOutputOn)." } + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.status = $0.speakerOn ? .enabled : .disabled + self?.audioOutputStatus = $0.audioOutputOn ? .enabled : .disabled + } + .store(in: disposableBag, key: observationKey) + } } diff --git a/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift b/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift index be9bd3fe6..cb482783c 100644 --- a/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift +++ b/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift @@ -65,7 +65,10 @@ public final class DisposableBag: @unchecked Sendable { } extension AnyCancellable { - public func store(in disposableBag: DisposableBag?) { disposableBag?.insert(self) } + public func store( + in disposableBag: DisposableBag?, + key: String = UUID().uuidString + ) { disposableBag?.insert(self, with: key) } } extension Task { diff --git a/StreamVideoTests/CallSettings/SpeakerManager_Tests.swift b/StreamVideoTests/CallSettings/SpeakerManager_Tests.swift index 541abf31e..8252988f2 100644 --- a/StreamVideoTests/CallSettings/SpeakerManager_Tests.swift +++ b/StreamVideoTests/CallSettings/SpeakerManager_Tests.swift @@ -3,9 +3,11 @@ // @testable import StreamVideo -import XCTest +@preconcurrency import XCTest -final class SpeakerManager_Tests: XCTestCase { +final class SpeakerManager_Tests: XCTestCase, @unchecked Sendable { + + // MARK: - disable func test_speaker_disable() async throws { // Given @@ -14,14 +16,16 @@ final class SpeakerManager_Tests: XCTestCase { initialSpeakerStatus: .enabled, initialAudioOutputStatus: .enabled ) - + // When try await speakerManager.disableSpeakerPhone() - + // Then XCTAssert(speakerManager.status == .disabled) } - + + // MARK: - enable + func test_speaker_enable() async throws { // Given let speakerManager = SpeakerManager( @@ -29,41 +33,79 @@ final class SpeakerManager_Tests: XCTestCase { initialSpeakerStatus: .disabled, initialAudioOutputStatus: .enabled ) - + // When try await speakerManager.enableSpeakerPhone() - + // Then XCTAssert(speakerManager.status == .enabled) } - - func test_speaker_disableSound() async throws { + + // MARK: - disableAudioOutput + + func test_speaker_disableAudioOutput() async throws { // Given let speakerManager = SpeakerManager( callController: CallController_Mock.make(), initialSpeakerStatus: .enabled, initialAudioOutputStatus: .enabled ) - + // When try await speakerManager.disableAudioOutput() - + // Then XCTAssert(speakerManager.audioOutputStatus == .disabled) } - - func test_speaker_enableSound() async throws { + + // MARK: - enableAudioOutput + + func test_speaker_enableAudioOutput() async throws { // Given let speakerManager = SpeakerManager( callController: CallController_Mock.make(), initialSpeakerStatus: .enabled, initialAudioOutputStatus: .disabled ) - + // When try await speakerManager.enableAudioOutput() - + // Then XCTAssert(speakerManager.audioOutputStatus == .enabled) } + + // MARK: - didUpdate callSettings + + @MainActor + func test_didUpdateCall_updatesStatus() async throws { + // Given + let streamVideo = MockStreamVideo() + _ = streamVideo + let call = Call.dummy() + + await wait(for: 0.5) + call.state.update(callSettings: .init(speakerOn: false, audioOutputOn: false)) + + await fulfillment { + call.speaker.status == .disabled + && call.speaker.audioOutputStatus == .disabled + } + } + + @MainActor + func test_toggleSpeaker_afterDidUpdateCall_updatesCorrectly() async throws { + // Given + let streamVideo = MockStreamVideo() + _ = streamVideo + let call = Call.dummy() + await wait(for: 0.5) + await fulfillment { call.speaker.status == .enabled && call.speaker.status == .enabled } + call.state.update(callSettings: .init(speakerOn: false, audioOutputOn: false)) + await fulfillment { call.speaker.status == .disabled && call.speaker.status == .disabled } + + try await call.speaker.toggleSpeakerPhone() + + await fulfillment { call.speaker.status == .enabled } + } }