From 0671dd4cd9e9ba9eb08fc557c1241fa3bcea0609 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Fri, 1 Nov 2024 18:01:18 +0200 Subject: [PATCH] [Improvement]SpeakerManager callSettings observation --- .../CallSettings/SpeakerManager.swift | 38 ++++++++- .../Controllers/CallController.swift | 2 +- .../Utils/DisposableBag/DisposableBag.swift | 5 +- .../Utils/PublishedWeak/PublishedWeak.swift | 44 ++++++++++ StreamVideo.xcodeproj/project.pbxproj | 24 ++++++ .../CallSettings/SpeakerManager_Tests.swift | 72 ++++++++++++---- .../PublishedWeak/PublishedWeak_Tests.swift | 84 +++++++++++++++++++ 7 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 Sources/StreamVideo/Utils/PublishedWeak/PublishedWeak.swift create mode 100644 StreamVideoTests/Utils/PublishedWeak/PublishedWeak_Tests.swift diff --git a/Sources/StreamVideo/CallSettings/SpeakerManager.swift b/Sources/StreamVideo/CallSettings/SpeakerManager.swift index 0566b415a..12855a5f4 100644 --- a/Sources/StreamVideo/CallSettings/SpeakerManager.swift +++ b/Sources/StreamVideo/CallSettings/SpeakerManager.swift @@ -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, @@ -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. @@ -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) + } } diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index 556f488af..5a843f4f1 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -29,7 +29,7 @@ class CallController: @unchecked Sendable { } } - weak var call: Call? { + @PublishedWeak var call: Call? { didSet { subscribeToParticipantsCountUpdatesEvent(call) } } 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/Sources/StreamVideo/Utils/PublishedWeak/PublishedWeak.swift b/Sources/StreamVideo/Utils/PublishedWeak/PublishedWeak.swift new file mode 100644 index 000000000..bdd8b611b --- /dev/null +++ b/Sources/StreamVideo/Utils/PublishedWeak/PublishedWeak.swift @@ -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 { + + private weak var _value: Value? + private let subject = PassthroughSubject() + + /// The published publisher for observing changes to the wrapped value. + var projectedValue: AnyPublisher { + 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 + } +} diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index e167b9448..441e4a4fb 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1772,6 +1774,8 @@ 40E110462B5A9DF4007DF492 /* CallDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallDurationView.swift; sourceTree = ""; }; 40E110482B5A9F03007DF492 /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; 40E1104B2B5A9F6D007DF492 /* MediaDurationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDurationFormatter.swift; sourceTree = ""; }; + 40E18AB62CD5297E00A65C9F /* PublishedWeak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedWeak.swift; sourceTree = ""; }; + 40E18AB92CD5307700A65C9F /* PublishedWeak_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedWeak_Tests.swift; sourceTree = ""; }; 40E9B3B02BCD755F00ACF18F /* MemberResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemberResponse+Dummy.swift"; sourceTree = ""; }; 40E9B3B22BCD93AE00ACF18F /* JoinCallResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JoinCallResponse+Dummy.swift"; sourceTree = ""; }; 40E9B3B42BCD93F500ACF18F /* Credentials+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Credentials+Dummy.swift"; sourceTree = ""; }; @@ -3854,6 +3858,22 @@ path = Formatters; sourceTree = ""; }; + 40E18AB52CD5297A00A65C9F /* PublishedWeak */ = { + isa = PBXGroup; + children = ( + 40E18AB62CD5297E00A65C9F /* PublishedWeak.swift */, + ); + path = PublishedWeak; + sourceTree = ""; + }; + 40E18AB82CD5307300A65C9F /* PublishedWeak */ = { + isa = PBXGroup; + children = ( + 40E18AB92CD5307700A65C9F /* PublishedWeak_Tests.swift */, + ); + path = PublishedWeak; + sourceTree = ""; + }; 40F0173C2BBEB85F00E89FD1 /* Utilities */ = { isa = PBXGroup; children = ( @@ -4359,6 +4379,7 @@ 842747F429EEDACB00E063AD /* Utils */ = { isa = PBXGroup; children = ( + 40E18AB82CD5307300A65C9F /* PublishedWeak */, 4029E9562CB943E800E1D571 /* CollectionDelayedUpdateObserver_Tests.swift */, 40C2B5C42C2D7ADE00EC2C2D /* RejectionReasonProvider */, 40C4DF4E2C1C41470035DBC2 /* ParticipantAutoLeavePolicy */, @@ -4837,6 +4858,7 @@ 84AF64D3287C79220012A503 /* Utils */ = { isa = PBXGroup; children = ( + 40E18AB52CD5297A00A65C9F /* PublishedWeak */, 408CF9C42CAEC24500F56833 /* ScreenPropertiesAdapter */, 40C9E44F2C9880D300802B28 /* Unwrap */, 40382F2C2C88B87500C2D00F /* ReflectiveStringConvertible */, @@ -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 */, @@ -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 */, 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 } + } } diff --git a/StreamVideoTests/Utils/PublishedWeak/PublishedWeak_Tests.swift b/StreamVideoTests/Utils/PublishedWeak/PublishedWeak_Tests.swift new file mode 100644 index 000000000..3f87be3cf --- /dev/null +++ b/StreamVideoTests/Utils/PublishedWeak/PublishedWeak_Tests.swift @@ -0,0 +1,84 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Combine +@testable import StreamVideo +import XCTest + +final class PublishedWeak_Tests: XCTestCase { + private var disposableBag: DisposableBag! = .init() + private lazy var object: AnyObject! = NSObject() + private lazy var subject: MockWrapper! = .init(initialValue: object) + + // MARK: - Lifecycle + + override func tearDown() { + subject = nil + object = nil + disposableBag = nil + super.tearDown() + } + + // MARK: - init + + func test_publishedWeak_initialValue() { + XCTAssertTrue(subject.weakValue === object) + } + + // MARK: - reference count is 0 + + func test_publishedWeak_becomesNil_whenReleased() { + _ = subject + object = nil // Release the strong reference to `object` + + XCTAssertNil(subject.weakValue) + } + + func test_publishedWeak_publishesChanges() { + let expectation = XCTestExpectation(description: "PublishedWeak publishes changes") + var receivedValues: [AnyObject?] = [] + subject + .$weakValue + .sink { value in + receivedValues.append(value) + if receivedValues.count == 2 { + expectation.fulfill() + } + } + .store(in: disposableBag) + + let newObject = NSObject() + subject.weakValue = newObject + subject.weakValue = nil + + wait(for: [expectation], timeout: defaultTimeout) + XCTAssertEqual(receivedValues.count, 2) + XCTAssertTrue(receivedValues[0] === newObject) + XCTAssertNil(receivedValues[1]) + } + + func test_publishedWeak_noRetainCycle() async { + // Given + weak var weakObject = subject?.weakValue // Capture a weak reference to test deallocation + let expectation = XCTestExpectation(description: "Object is deallocated") + expectation.isInverted = true + + subject? + .$weakValue + .sink { _ in expectation.fulfill() } + .store(in: disposableBag) + + // When + subject = nil + object = nil + + // Then + XCTAssertNil(weakObject) + } +} + +private class MockWrapper { + @PublishedWeak var weakValue: AnyObject? + init(initialValue: AnyObject? = nil) { weakValue = initialValue } +}