Skip to content

Commit f5efacf

Browse files
authored
[Enhancement]Battery traces (#978)
1 parent bb93c72 commit f5efacf

File tree

14 files changed

+698
-1
lines changed

14 files changed

+698
-1
lines changed

Sources/StreamVideo/StreamVideo.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {
1717
@Injected(\.callCache) private var callCache
1818
@Injected(\.screenProperties) private var screenProperties
1919
@Injected(\.audioStore) private var audioStore
20+
@Injected(\.battery) private var battery
2021

2122
private enum DisposableKey: String { case ringEventReceived }
2223

@@ -203,6 +204,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {
203204
// Warm up
204205
_ = eventNotificationCenter
205206
_ = idleTimerAdapter
207+
_ = battery
206208

207209
if user.type != .anonymous {
208210
let userAuth = UserAuth { [weak self] in
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import Foundation
7+
#if canImport(UIKit)
8+
import UIKit
9+
#endif
10+
11+
/// Monitors the device battery state and exposes the latest readings through
12+
/// the shared store pipeline.
13+
final class BatteryStore: CustomStringConvertible, @unchecked Sendable {
14+
15+
var state: Namespace.State { store.state }
16+
17+
var description: String {
18+
var result = "Battery {"
19+
result += " isMonitoring:\(store.state.isMonitoringEnabled)"
20+
result += " state:\(store.state.state)"
21+
result += " level:\(store.state.level)"
22+
result += " }"
23+
return result
24+
}
25+
26+
private let store: Store<Namespace>
27+
private let disposableBag = DisposableBag()
28+
29+
init(
30+
store: Store<Namespace> = Namespace.store(
31+
initialState: .init(
32+
isMonitoringEnabled: false,
33+
state: .unknown,
34+
level: 0
35+
)
36+
)
37+
) {
38+
self.store = store
39+
40+
self.store.dispatch(.setMonitoringEnabled(true))
41+
42+
#if canImport(UIKit)
43+
self.store
44+
.publisher(\.isMonitoringEnabled)
45+
.filter { $0 }
46+
.removeDuplicates()
47+
.receive(on: DispatchQueue.main)
48+
.map { _ in
49+
MainActor.assumeIsolated {
50+
(
51+
UIDevice.current.batteryState,
52+
UIDevice.current.batteryLevel
53+
)
54+
}
55+
}
56+
.sink { [weak self] stateAndLevel in
57+
let (state, level) = stateAndLevel
58+
self?.store.dispatch([
59+
.setState(.init(state)),
60+
.setLevel(level)
61+
])
62+
}
63+
.store(in: disposableBag)
64+
#endif
65+
}
66+
67+
func publisher<V: Equatable>(
68+
_ keyPath: KeyPath<Namespace.State, V>
69+
) -> AnyPublisher<V, Never> {
70+
store.publisher(keyPath)
71+
}
72+
73+
func dispatch(
74+
_ actions: [Namespace.Action],
75+
file: StaticString = #file,
76+
function: StaticString = #function,
77+
line: UInt = #line
78+
) -> StoreTask<Namespace> {
79+
store.dispatch(
80+
actions,
81+
file: file,
82+
function: function,
83+
line: line
84+
)
85+
}
86+
}
87+
88+
extension BatteryStore: Encodable {
89+
private enum CodingKeys: String, CodingKey {
90+
case isMonitoringEnabled
91+
case state
92+
case level
93+
}
94+
95+
/// Encodes a snapshot of the store's observable state.
96+
func encode(to encoder: Encoder) throws {
97+
var container = encoder.container(keyedBy: CodingKeys.self)
98+
let state = store.state
99+
try container.encode(
100+
state.isMonitoringEnabled,
101+
forKey: .isMonitoringEnabled
102+
)
103+
try container.encode(state.state, forKey: .state)
104+
try container.encode(state.level, forKey: .level)
105+
}
106+
}
107+
108+
extension BatteryStore: InjectionKey {
109+
/// The default recorder instance used when no custom recorder is
110+
/// provided.
111+
nonisolated(unsafe) static var currentValue: BatteryStore = .init()
112+
}
113+
114+
extension InjectedValues {
115+
var battery: BatteryStore {
116+
get {
117+
Self[BatteryStore.self]
118+
}
119+
set {
120+
Self[BatteryStore.self] = newValue
121+
}
122+
}
123+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
extension BatteryStore.Namespace {
8+
/// Actions that drive battery monitoring state transitions.
9+
///
10+
/// These actions mirror updates from `UIDevice` and user controlled
11+
/// monitoring preferences.
12+
enum StoreAction: Sendable, Equatable, StoreActionBoxProtocol {
13+
case setMonitoringEnabled(Bool)
14+
case setLevel(Float)
15+
case setState(StoreState.BatteryState)
16+
}
17+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
extension BatteryStore {
8+
9+
/// Namespace configuration for the battery monitoring store.
10+
enum Namespace: StoreNamespace {
11+
12+
/// The state type for this store namespace.
13+
typealias State = StoreState
14+
15+
/// The action type for this store namespace.
16+
typealias Action = StoreAction
17+
18+
/// Unique identifier for this store instance.
19+
///
20+
/// Used for logging and debugging purposes.
21+
static let identifier: String = "battery.store"
22+
23+
static func reducers() -> [Reducer<Namespace>] {
24+
[
25+
DefaultReducer()
26+
]
27+
}
28+
29+
static func middleware() -> [Middleware<BatteryStore.Namespace>] {
30+
[
31+
ObservationMiddleware()
32+
]
33+
}
34+
}
35+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
#if canImport(UIKit)
7+
import UIKit
8+
#endif
9+
10+
extension BatteryStore.Namespace {
11+
12+
/// Snapshot of the battery monitoring configuration and readings.
13+
struct StoreState: Equatable {
14+
var isMonitoringEnabled: Bool
15+
var state: BatteryState
16+
var level: Int
17+
}
18+
}
19+
20+
extension BatteryStore.Namespace.StoreState {
21+
22+
/// Represents the high-level battery state emitted by `UIDevice`.
23+
enum BatteryState: CustomStringConvertible {
24+
case unknown
25+
case unplugged
26+
case charging
27+
case full
28+
29+
var description: String {
30+
switch self {
31+
case .unknown:
32+
return ".unknown"
33+
case .unplugged:
34+
return ".unplugged"
35+
case .charging:
36+
return ".charging"
37+
case .full:
38+
return ".full"
39+
}
40+
}
41+
42+
#if canImport(UIKit)
43+
/// Creates a battery state from the UIKit battery representation.
44+
init(_ rawValue: UIDevice.BatteryState) {
45+
switch rawValue {
46+
case .unknown:
47+
self = .unknown
48+
case .unplugged:
49+
self = .unplugged
50+
case .charging:
51+
self = .charging
52+
case .full:
53+
self = .full
54+
@unknown default:
55+
self = .unknown
56+
}
57+
}
58+
#endif
59+
}
60+
}
61+
62+
extension BatteryStore.Namespace.StoreState: Encodable {}
63+
64+
extension BatteryStore.Namespace.StoreState.BatteryState: Encodable {
65+
func encode(to encoder: Encoder) throws {
66+
var container = encoder.singleValueContainer()
67+
switch self {
68+
case .unknown:
69+
try container.encode("unknown")
70+
case .unplugged:
71+
try container.encode("unplugged")
72+
case .charging:
73+
try container.encode("charging")
74+
case .full:
75+
try container.encode("full")
76+
}
77+
}
78+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import Foundation
7+
#if canImport(UIKit)
8+
import UIKit
9+
#endif
10+
11+
extension BatteryStore.Namespace {
12+
13+
/// Observes UIKit battery notifications and forwards updates to the store.
14+
final class ObservationMiddleware: Middleware<BatteryStore.Namespace>, @unchecked Sendable {
15+
16+
private let disposableBag = DisposableBag()
17+
18+
/// Initializes the middleware and sets up interruption monitoring.
19+
override init() {
20+
super.init()
21+
22+
#if canImport(UIKit)
23+
NotificationCenter
24+
.default
25+
.publisher(for: UIDevice.batteryStateDidChangeNotification)
26+
.receive(on: DispatchQueue.main)
27+
.map { _ in MainActor.assumeIsolated { UIDevice.current.batteryState } }
28+
.sink { [weak self] in self?.dispatcher?.dispatch(.setState(.init($0))) }
29+
.store(in: disposableBag)
30+
31+
NotificationCenter
32+
.default
33+
.publisher(for: UIDevice.batteryLevelDidChangeNotification)
34+
.receive(on: DispatchQueue.main)
35+
.map { _ in MainActor.assumeIsolated { UIDevice.current.batteryLevel } }
36+
.sink { [weak self] in self?.dispatcher?.dispatch(.setLevel($0)) }
37+
.store(in: disposableBag)
38+
#endif
39+
}
40+
}
41+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
#if canImport(UIKit)
7+
import UIKit
8+
#endif
9+
10+
extension BatteryStore.Namespace {
11+
12+
final class DefaultReducer: Reducer<BatteryStore.Namespace>, @unchecked Sendable {
13+
/// Processes an action to produce a new state.
14+
///
15+
/// This method creates a copy of the current state, applies the
16+
/// action's changes, and returns the updated state.
17+
///
18+
/// - Parameters:
19+
/// - state: The current state before the action.
20+
/// - action: The action to process.
21+
/// - file: Source file where the action was dispatched.
22+
/// - function: Function name where the action was dispatched.
23+
/// - line: Line number where the action was dispatched.
24+
///
25+
/// - Returns: A new state reflecting the action's changes.
26+
///
27+
/// - Throws: This implementation doesn't throw, but the protocol
28+
/// allows for error handling in complex reducers.
29+
override func reduce(
30+
state: State,
31+
action: Action,
32+
file: StaticString,
33+
function: StaticString,
34+
line: UInt
35+
) async throws -> State {
36+
var updatedState = state
37+
38+
switch action {
39+
case let .setMonitoringEnabled(value):
40+
#if canImport(UIKit)
41+
await MainActor.run { UIDevice.current.isBatteryMonitoringEnabled = value }
42+
updatedState.isMonitoringEnabled = value
43+
#else
44+
break
45+
#endif
46+
47+
case let .setLevel(value):
48+
if value >= 0 {
49+
let percentage = Double(value) * 100
50+
let rounded = percentage.rounded()
51+
updatedState.level = Int(max(0, min(rounded, 100)))
52+
} else {
53+
updatedState.level = 0
54+
}
55+
56+
case let .setState(value):
57+
updatedState.state = value
58+
}
59+
60+
return updatedState
61+
}
62+
}
63+
}

Sources/StreamVideo/WebRTC/v2/Stats/Models/WebRTCTrace.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,15 @@ extension WebRTCTrace {
217217
)
218218
}
219219
}
220+
221+
extension WebRTCTrace {
222+
init(
223+
_ battery: BatteryStore
224+
) {
225+
self.init(
226+
id: nil,
227+
tag: "device.battery.\(battery.state.state)",
228+
data: .init(battery)
229+
)
230+
}
231+
}

0 commit comments

Comments
 (0)