Skip to content

Commit

Permalink
[Improvement]SpeakerManager callSettings observation
Browse files Browse the repository at this point in the history
  • Loading branch information
ipavlidakis committed Nov 1, 2024
1 parent 6448985 commit 0671dd4
Show file tree
Hide file tree
Showing 7 changed files with 249 additions and 20 deletions.
38 changes: 35 additions & 3 deletions Sources/StreamVideo/CallSettings/SpeakerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ 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

internal let callController: CallController
internal let state = CallSettingsState()


private let disposableBag = DisposableBag()

init(
callController: CallController,
initialSpeakerStatus: CallSettingsStatus,
Expand All @@ -21,6 +24,11 @@ public final class SpeakerManager: ObservableObject, CallSettingsManager, @unche
self.callController = callController
status = initialSpeakerStatus
audioOutputStatus = initialAudioOutputStatus

callController
.$call
.sinkTask(storeIn: disposableBag) { @MainActor [weak self] in self?.didUpdateCall($0) }
.store(in: disposableBag)
}

/// Toggles the speaker during a call.
Expand Down Expand Up @@ -75,4 +83,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)
}
}
2 changes: 1 addition & 1 deletion Sources/StreamVideo/Controllers/CallController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class CallController: @unchecked Sendable {
}
}

weak var call: Call? {
@PublishedWeak var call: Call? {
didSet { subscribeToParticipantsCountUpdatesEvent(call) }
}

Expand Down
5 changes: 4 additions & 1 deletion Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions Sources/StreamVideo/Utils/PublishedWeak/PublishedWeak.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Combine
import Foundation

/// A property wrapper that publishes updates to a weakly referenced value.
///
/// `PublishedWeak` uses a weak reference for the wrapped value, meaning it does not retain
/// the value, and the value can become `nil` when there are no strong references left.
///
/// This property wrapper is useful for weakly-referenced objects where you want to
/// broadcast changes without retaining the object.
///
/// - Note: This wrapper only works with classes because it relies on weak references.
@propertyWrapper
final class PublishedWeak<Value: AnyObject> {

private weak var _value: Value?
private let subject = PassthroughSubject<Value?, Never>()

/// The published publisher for observing changes to the wrapped value.
var projectedValue: AnyPublisher<Value?, Never> {
subject.eraseToAnyPublisher()
}

/// The wrapped value, weakly referenced. Assigning a new value triggers the publisher.
var wrappedValue: Value? {
get { _value }

set {
/// We send first the newValue to subscribers to emulate how property wrappers work
/// using `willSet`.
subject.send(newValue)
_value = newValue
}
}

/// Initializes the property wrapper with an initial value.
init(wrappedValue: Value?) {
_value = wrappedValue
}
}
24 changes: 24 additions & 0 deletions StreamVideo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@
40E110472B5A9DF4007DF492 /* CallDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E110462B5A9DF4007DF492 /* CallDurationView.swift */; };
40E110492B5A9F03007DF492 /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E110482B5A9F03007DF492 /* Formatters.swift */; };
40E1104C2B5A9F6D007DF492 /* MediaDurationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E1104B2B5A9F6D007DF492 /* MediaDurationFormatter.swift */; };
40E18AB72CD5297E00A65C9F /* PublishedWeak.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E18AB62CD5297E00A65C9F /* PublishedWeak.swift */; };
40E18ABA2CD5307700A65C9F /* PublishedWeak_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E18AB92CD5307700A65C9F /* PublishedWeak_Tests.swift */; };
40E9B3B12BCD755F00ACF18F /* MemberResponse+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B02BCD755F00ACF18F /* MemberResponse+Dummy.swift */; };
40E9B3B32BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B22BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift */; };
40E9B3B52BCD93F500ACF18F /* Credentials+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9B3B42BCD93F500ACF18F /* Credentials+Dummy.swift */; };
Expand Down Expand Up @@ -1772,6 +1774,8 @@
40E110462B5A9DF4007DF492 /* CallDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallDurationView.swift; sourceTree = "<group>"; };
40E110482B5A9F03007DF492 /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = "<group>"; };
40E1104B2B5A9F6D007DF492 /* MediaDurationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDurationFormatter.swift; sourceTree = "<group>"; };
40E18AB62CD5297E00A65C9F /* PublishedWeak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedWeak.swift; sourceTree = "<group>"; };
40E18AB92CD5307700A65C9F /* PublishedWeak_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedWeak_Tests.swift; sourceTree = "<group>"; };
40E9B3B02BCD755F00ACF18F /* MemberResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemberResponse+Dummy.swift"; sourceTree = "<group>"; };
40E9B3B22BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JoinCallResponse+Dummy.swift"; sourceTree = "<group>"; };
40E9B3B42BCD93F500ACF18F /* Credentials+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Credentials+Dummy.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3854,6 +3858,22 @@
path = Formatters;
sourceTree = "<group>";
};
40E18AB52CD5297A00A65C9F /* PublishedWeak */ = {
isa = PBXGroup;
children = (
40E18AB62CD5297E00A65C9F /* PublishedWeak.swift */,
);
path = PublishedWeak;
sourceTree = "<group>";
};
40E18AB82CD5307300A65C9F /* PublishedWeak */ = {
isa = PBXGroup;
children = (
40E18AB92CD5307700A65C9F /* PublishedWeak_Tests.swift */,
);
path = PublishedWeak;
sourceTree = "<group>";
};
40F0173C2BBEB85F00E89FD1 /* Utilities */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -4359,6 +4379,7 @@
842747F429EEDACB00E063AD /* Utils */ = {
isa = PBXGroup;
children = (
40E18AB82CD5307300A65C9F /* PublishedWeak */,
4029E9562CB943E800E1D571 /* CollectionDelayedUpdateObserver_Tests.swift */,
40C2B5C42C2D7ADE00EC2C2D /* RejectionReasonProvider */,
40C4DF4E2C1C41470035DBC2 /* ParticipantAutoLeavePolicy */,
Expand Down Expand Up @@ -4837,6 +4858,7 @@
84AF64D3287C79220012A503 /* Utils */ = {
isa = PBXGroup;
children = (
40E18AB52CD5297A00A65C9F /* PublishedWeak */,
408CF9C42CAEC24500F56833 /* ScreenPropertiesAdapter */,
40C9E44F2C9880D300802B28 /* Unwrap */,
40382F2C2C88B87500C2D00F /* ReflectiveStringConvertible */,
Expand Down Expand Up @@ -6189,6 +6211,7 @@
841BAA492BD15CDE000C73E4 /* CollectUserFeedbackRequest.swift in Sources */,
406583902B877A0500B4F979 /* ImageBackgroundVideoFilter.swift in Sources */,
8454A3192AAB374B00A012C6 /* CallStatsReport.swift in Sources */,
40E18AB72CD5297E00A65C9F /* PublishedWeak.swift in Sources */,
84E5C51C2A013C440003A27A /* PushNotificationsConfig.swift in Sources */,
84A7E184288362DF00526C98 /* Atomic.swift in Sources */,
8449824E2C738A830029734D /* StopAllRTMPBroadcastsResponse.swift in Sources */,
Expand Down Expand Up @@ -6650,6 +6673,7 @@
842747EE29EED60600E063AD /* Calendar+GMT.swift in Sources */,
846A06D029E0591D0084C264 /* StringExtensions_Tests.swift in Sources */,
84F58B8929EEAC4400010C4C /* MockFunc.swift in Sources */,
40E18ABA2CD5307700A65C9F /* PublishedWeak_Tests.swift in Sources */,
40F0174B2BBEEFB200E89FD1 /* VideoSettings+Dummy.swift in Sources */,
40E9B3B12BCD755F00ACF18F /* MemberResponse+Dummy.swift in Sources */,
8490031929D2E0DF00AD9BB4 /* Sorting_Tests.swift in Sources */,
Expand Down
72 changes: 57 additions & 15 deletions StreamVideoTests/CallSettings/SpeakerManager_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,56 +16,96 @@ 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(
callController: CallController_Mock.make(),
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 }
}
}
Loading

0 comments on commit 0671dd4

Please sign in to comment.