diff --git a/Sources/StreamVideo/Utils/ThermalStateObserver.swift b/Sources/StreamVideo/Utils/ThermalStateObserver.swift index ed06699ff..3f12735d9 100644 --- a/Sources/StreamVideo/Utils/ThermalStateObserver.swift +++ b/Sources/StreamVideo/Utils/ThermalStateObserver.swift @@ -10,11 +10,12 @@ extension LogSubsystem { } public final class ThermalStateObserver: ObservableObject { - public static let shared = ThermalStateObserver() - @Published public private(set) var state: ProcessInfo.ThermalState = .nominal { + /// Published property to observe the thermal state + @Published public private(set) var state: ProcessInfo.ThermalState { didSet { + // Determine the appropriate log level based on the thermal state let logLevel: LogLevel switch state { case .nominal, .fair: @@ -26,6 +27,7 @@ public final class ThermalStateObserver: ObservableObject { @unknown default: logLevel = .debug } + // Log the thermal state change with the calculated log level log.log( logLevel, message: "Thermal state changed \(oldValue) → state", @@ -35,20 +37,33 @@ public final class ThermalStateObserver: ObservableObject { } } + /// Cancellable object to manage notifications private var notificationCenterCancellable: AnyCancellable? + private var thermalStateProvider: () -> ProcessInfo.ThermalState + + convenience init() { + self.init { ProcessInfo.processInfo.thermalState } + } + + + init(thermalStateProvider: @escaping () -> ProcessInfo.ThermalState) { + // Initialize the thermal state with the current process's thermal state + self.state = thermalStateProvider() + self.thermalStateProvider = thermalStateProvider - private init() { + // Set up a publisher to monitor thermal state changes notificationCenterCancellable = NotificationCenter .default .publisher(for: ProcessInfo.thermalStateDidChangeNotification) .receive(on: DispatchQueue.global(qos: .utility)) - .map { _ in ProcessInfo.processInfo.thermalState } + .map { [thermalStateProvider] _ in thermalStateProvider() } .receive(on: DispatchQueue.main) .assign(to: \.state, on: self) } /// Depending on the Device's thermal state we adapt the request participants resolution. public var scale: CGFloat { + // Determine the appropriate scaling factor based on the thermal state switch state { case .nominal: return 1 diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 700304eb1..2959423b5 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 400D63F72AC3273F0000BB30 /* ThermalStateObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D63F62AC3273F0000BB30 /* ThermalStateObserverTests.swift */; }; 401480302A5317640029166A /* AudioValuePercentageNormaliser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4014802F2A5317640029166A /* AudioValuePercentageNormaliser.swift */; }; 401480342A5423D60029166A /* AudioValuePercentageNormaliser_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401480312A54238C0029166A /* AudioValuePercentageNormaliser_Tests.swift */; }; 401480362A5447C50029166A /* LocalParticipantViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401480352A5447C50029166A /* LocalParticipantViewModifier.swift */; }; @@ -874,6 +875,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 400D63F62AC3273F0000BB30 /* ThermalStateObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalStateObserverTests.swift; sourceTree = ""; }; 4014802F2A5317640029166A /* AudioValuePercentageNormaliser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioValuePercentageNormaliser.swift; sourceTree = ""; }; 401480312A54238C0029166A /* AudioValuePercentageNormaliser_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioValuePercentageNormaliser_Tests.swift; sourceTree = ""; }; 401480352A5447C50029166A /* LocalParticipantViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalParticipantViewModifier.swift; sourceTree = ""; }; @@ -2222,6 +2224,7 @@ 84BB570D2A20D7BB0002C123 /* Mapping_Tests.swift */, 84A4DCBA2A41DC6E00B1D1BF /* AsyncAssert.swift */, 845E31072A712389004DC470 /* BroadcastUtils_Tests.swift */, + 400D63F62AC3273F0000BB30 /* ThermalStateObserverTests.swift */, ); path = Utils; sourceTree = ""; @@ -4214,6 +4217,7 @@ 8492B87A29081E6600006649 /* StreamVideo_Mock.swift in Sources */, 84D6494729E9F2D0002CA428 /* WebRTCClient_Tests.swift in Sources */, 82E3BA532A0BAF4B001AB93E /* WebSocketClientEnvironment_Mock.swift in Sources */, + 400D63F72AC3273F0000BB30 /* ThermalStateObserverTests.swift in Sources */, 8414081529F28FFC00FF2D7C /* CallSettings_Tests.swift in Sources */, 8492B87829081D1600006649 /* HTTPClient_Mock.swift in Sources */, 842747E729EECF9600E063AD /* ErrorPayload_Tests.swift in Sources */, diff --git a/StreamVideoTests/Utils/ThermalStateObserverTests.swift b/StreamVideoTests/Utils/ThermalStateObserverTests.swift new file mode 100644 index 000000000..6e494ee7d --- /dev/null +++ b/StreamVideoTests/Utils/ThermalStateObserverTests.swift @@ -0,0 +1,95 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest +import Combine + +final class ThermalStateObserverTests: XCTestCase { + + private var stubThermalState: ProcessInfo.ThermalState = .nominal + private lazy var subject: ThermalStateObserver! = .init { self.stubThermalState } + + override func tearDown() { + subject = nil + super.tearDown() + } + + // MARK: - init + + func test_init_stateHasBeenCorrectlySetUp() { + XCTAssertEqual(ThermalStateObserver.shared.state, ProcessInfo.processInfo.thermalState) + } + + // MARK: - notificationObserver + + func test_notificationObserver_stateChangesWhenSystemPostsNotification() { + func assertThermalState( + _ expected: ProcessInfo.ThermalState, + file: StaticString = #file, + line: UInt = #line + ) { + stubThermalState = expected + + let expectation = self.expectation(description: "Notification was received") + var cancellable: AnyCancellable? + cancellable = subject + .$state + .dropFirst() + .sink { + XCTAssertEqual($0, expected, file: file, line: line) + expectation.fulfill() + cancellable?.cancel() + } + + NotificationCenter + .default + .post(.init(name: ProcessInfo.thermalStateDidChangeNotification)) + + wait(for: [expectation], timeout: defaultTimeout) + } + + assertThermalState(.fair) + assertThermalState(.serious) + assertThermalState(.critical) + assertThermalState(.nominal) + } + + // MARK: - scale + + func test_scale_hasExpectedValueForEachThermalState() { + func assertScale( + _ thermalState: ProcessInfo.ThermalState, + expected: CGFloat, + file: StaticString = #file, + line: UInt = #line + ) { + stubThermalState = thermalState + + let expectation = self.expectation(description: "Notification was received") + var cancellable: AnyCancellable? + cancellable = subject + .$state + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [subject] _ in + XCTAssertEqual(subject?.scale, expected, file: file, line: line) + expectation.fulfill() + cancellable?.cancel() + } + + NotificationCenter + .default + .post(.init(name: ProcessInfo.thermalStateDidChangeNotification)) + + wait(for: [expectation], timeout: defaultTimeout) + } + + assertScale(.nominal, expected: 1) + assertScale(.fair, expected: 1.5) + assertScale(.serious, expected: 2) + assertScale(.critical, expected: 4) + } +}