oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2#if os(iOS)
3 import UIKit
4#endif
5
6// Quality reports (spec §15.26, Paul's ruling of 2026-10-03): every thirty
7// seconds while admitted, and once more at leave, this app tells the
8// server how its connections went over the window — numbers and enums
9// only, in protocol/quality.go's fixed schema. No names, no addresses, no
10// device names, no meeting, nothing typed by a person.
11//
12// Everything here is pure: the statistics come in as a snapshot
13// (`QualityPeerSample`), the notices as a tally (`QualityEventTally`), and
14// the report comes out, so the whole of it is tested from fixtures. Call
15// gathers the snapshot and sends what comes out.
16
17// MARK: - The wire (protocol/quality.go)
18
19/// The schema's bounds, as protocol/quality.go has them. The server
20/// refuses a report over any of them, so a client clamps to them first.
21public enum QualityBounds {
22 public static let version = 1
23 /// Remote peers in one report: the mesh holds three, and one spare for a
24 /// connection on its way out. Past it the line would not fit the
25 /// platform's 8 KB.
26 public static let peers = 4
27 public static let seconds = 600.0
28 public static let millis = 600_000.0
29 public static let count = 1e9
30 public static let kbps = 1e6
31 public static let pixels = 16384.0
32 public static let fps = 240.0
33 public static let body = 16 << 10
34 public static let note = 1000
35 public static let problemBody = 64 << 10
36}
37
38public struct QualityReport: Encodable, Equatable, Sendable {
39 public var v = QualityBounds.version
40 /// The report sent at leave or end.
41 public var isFinal: Bool?
42 public var seconds: Double
43 public var client: QualityClient
44 public var devices: QualityDevices?
45 public var events: QualityCallEvents?
46 public var peers: [QualityPeer]?
47
48 enum CodingKeys: String, CodingKey {
49 case v, seconds, client, devices, events, peers
50 case isFinal = "final"
51 }
52
53 /// The body as it is sent: sorted keys, so two equal reports are the
54 /// same bytes.
55 public func encoded() -> String? {
56 let encoder = JSONEncoder()
57 encoder.outputFormatting = [.sortedKeys]
58 guard let data = try? encoder.encode(self), data.count <= QualityBounds.body else { return nil }
59 return String(data: data, encoding: .utf8)
60 }
61}
62
63/// What is reporting: enums, and versions as digits and dots. Never a
64/// device model or a machine name.
65public struct QualityClient: Codable, Equatable, Sendable {
66 public var app: String
67 public var version: String?
68 public var os: String?
69 public var osVersion: String?
70
71 public init(app: String, version: String? = nil, os: String? = nil, osVersion: String? = nil) {
72 self.app = app
73 self.version = version
74 self.os = os
75 self.osVersion = osVersion
76 }
77}
78
79/// The kinds of microphone and speaker in use, by transport: never a
80/// device's name, which can be personal ("Paul's AirPods Pro").
81public struct QualityDevices: Encodable, Equatable, Sendable {
82 public var input: String?
83 public var output: String?
84}
85
86public struct QualityCallEvents: Encodable, Equatable, Sendable {
87 public var reconnecting: Double?
88 public var reconnectingSeconds: Double?
89}
90
91public struct QualityPeer: Encodable, Equatable, Sendable {
92 public var peer: String
93 public var path: QualityPath?
94 public var rtt: Double?
95 public var out: QualityOutbound?
96 public var `in`: QualityInbound?
97 public var events: QualityPeerEvents?
98}
99
100public struct QualityPath: Encodable, Equatable, Sendable {
101 public var local: String?
102 public var remote: String?
103 public var `protocol`: String?
104}
105
106public struct QualityOutbound: Encodable, Equatable, Sendable {
107 public var audio: QualityOutStream?
108 public var video: QualityOutStream?
109 public var screen: QualityOutStream?
110}
111
112public struct QualityOutStream: Encodable, Equatable, Sendable {
113 public var kbps: Double?
114 public var packets: Double?
115 public var lost: Double?
116 public var rtt: Double?
117 public var jitter: Double?
118 public var limit: String?
119 public var width: Double?
120 public var height: Double?
121 public var fps: Double?
122 public var keyframes: Double?
123 public var rebuilds: Double?
124 public var reshares: Double?
125 public var failures: Double?
126}
127
128public struct QualityInbound: Encodable, Equatable, Sendable {
129 public var audio: QualityInAudio?
130 public var video: QualityInVideo?
131 public var screen: QualityInVideo?
132}
133
134public struct QualityInAudio: Encodable, Equatable, Sendable {
135 public var kbps: Double?
136 public var packets: Double?
137 public var lost: Double?
138 public var jitter: Double?
139 public var samples: Double?
140 public var concealed: Double?
141 public var inserted: Double?
142 public var removed: Double?
143 public var jitterBufferMs: Double?
144}
145
146public struct QualityInVideo: Encodable, Equatable, Sendable {
147 public var kbps: Double?
148 public var packets: Double?
149 public var lost: Double?
150 public var jitter: Double?
151 public var freezes: Double?
152 public var freezeSeconds: Double?
153 public var decoded: Double?
154 public var dropped: Double?
155 public var width: Double?
156 public var height: Double?
157 public var fps: Double?
158 public var reattaches: Double?
159}
160
161public struct QualityPeerEvents: Encodable, Equatable, Sendable {
162 public var reconnecting: Double?
163 public var reconnectingSeconds: Double?
164 public var cantHearSeconds: Double?
165 public var noSound: Bool?
166 public var noSoundFromUs: Bool?
167 public var audioBlocked: Bool?
168 public var screenStalled: Bool?
169}
170
171// MARK: - The snapshot
172
173/// One statistics object from WebRTC's report, its values split by kind:
174/// numbers (NSNumber, booleans among them) and strings. Nothing else in a
175/// report is read.
176public struct QualityStat: Equatable, Sendable {
177 public var type: String
178 public var numbers: [String: Double]
179 public var strings: [String: String]
180
181 public init(type: String, numbers: [String: Double] = [:], strings: [String: String] = [:]) {
182 self.type = type
183 self.numbers = numbers
184 self.strings = strings
185 }
186
187 /// From WebRTC's own values.
188 init(type: String, values: [String: Any]) {
189 var numbers: [String: Double] = [:]
190 var strings: [String: String] = [:]
191 for (key, value) in values {
192 if let n = value as? NSNumber {
193 numbers[key] = n.doubleValue
194 } else if let s = value as? String {
195 strings[key] = s
196 }
197 }
198 self.init(type: type, numbers: numbers, strings: strings)
199 }
200}
201
202/// One connection's statistics at one look, with what is needed to read
203/// them: the connection's generation (a new one starts every count from
204/// nothing), and which mids are screens.
205public struct QualityPeerSample: Equatable, Sendable {
206 public var generation: Int64
207 /// The mid this app's own screen goes out on, while sharing.
208 public var ownScreenMid: String?
209 /// The mid their screen arrives on here, while they share.
210 public var screenMid: String
211 public var stats: [String: QualityStat]
212
213 public init(generation: Int64, ownScreenMid: String? = nil, screenMid: String = "", stats: [String: QualityStat]) {
214 self.generation = generation
215 self.ownScreenMid = ownScreenMid
216 self.screenMid = screenMid
217 self.stats = stats
218 }
219}
220
221// MARK: - Running totals, read from a snapshot
222
223/// The cumulative counters and current gauges of one connection, as the
224/// statistics have them. A report is the difference between two of these.
225struct QualityCounters: Equatable, Sendable {
226 struct Out: Equatable, Sendable {
227 var bytes: Double?
228 var packets: Double?
229 var lost: Double?
230 var rtt: Double?
231 var jitter: Double?
232 var limit: String?
233 var width: Double?
234 var height: Double?
235 var fps: Double?
236 }
237 struct InAudio: Equatable, Sendable {
238 var bytes: Double?
239 var packets: Double?
240 var lost: Double?
241 var jitter: Double?
242 var samples: Double?
243 var concealed: Double?
244 var inserted: Double?
245 var removed: Double?
246 var bufferDelay: Double?
247 var emitted: Double?
248 }
249 struct InVideo: Equatable, Sendable {
250 var bytes: Double?
251 var packets: Double?
252 var lost: Double?
253 var jitter: Double?
254 var freezes: Double?
255 var freezeSeconds: Double?
256 var decoded: Double?
257 var dropped: Double?
258 var width: Double?
259 var height: Double?
260 var fps: Double?
261 }
262 var outAudio: Out?
263 var outVideo: Out?
264 var outScreen: Out?
265 var inAudio: InAudio?
266 var inVideo: InVideo?
267 var inScreen: InVideo?
268 var path: QualityPath?
269 var rtt: Double?
270
271 init() {}
272
273 /// Reads a snapshot. A stream absent from the statistics is absent
274 /// here; a value WebRTC does not give is nil, never zero.
275 init(_ sample: QualityPeerSample) {
276 let stats = sample.stats
277 for (id, stat) in stats {
278 let kind = stat.strings["kind"] ?? stat.strings["mediaType"] ?? ""
279 let mid = stat.strings["mid"] ?? ""
280 switch stat.type {
281 case "outbound-rtp":
282 var out = Out()
283 out.bytes = stat.numbers["bytesSent"]
284 out.packets = stat.numbers["packetsSent"]
285 out.limit = stat.strings["qualityLimitationReason"]
286 out.width = stat.numbers["frameWidth"]
287 out.height = stat.numbers["frameHeight"]
288 out.fps = stat.numbers["framesPerSecond"]
289 // The receiver's own account of this stream.
290 if let remote = stats.values.first(where: { $0.type == "remote-inbound-rtp" && $0.strings["localId"] == id }) {
291 out.lost = remote.numbers["packetsLost"]
292 out.rtt = remote.numbers["roundTripTime"].map { $0 * 1000 }
293 out.jitter = remote.numbers["jitter"].map { $0 * 1000 }
294 }
295 if kind == "audio" {
296 outAudio = Self.add(outAudio, out)
297 } else if kind == "video" {
298 if let own = sample.ownScreenMid, !own.isEmpty, own == mid {
299 outScreen = Self.add(outScreen, out)
300 } else {
301 outVideo = Self.add(outVideo, out)
302 }
303 }
304 case "inbound-rtp":
305 if kind == "audio" {
306 var a = inAudio ?? InAudio()
307 a.bytes = Self.sum(a.bytes, stat.numbers["bytesReceived"])
308 a.packets = Self.sum(a.packets, stat.numbers["packetsReceived"])
309 a.lost = Self.sum(a.lost, stat.numbers["packetsLost"])
310 a.jitter = Self.most(a.jitter, stat.numbers["jitter"].map { $0 * 1000 })
311 a.samples = Self.sum(a.samples, stat.numbers["totalSamplesReceived"])
312 a.concealed = Self.sum(a.concealed, stat.numbers["concealedSamples"])
313 a.inserted = Self.sum(a.inserted, stat.numbers["insertedSamplesForDeceleration"])
314 a.removed = Self.sum(a.removed, stat.numbers["removedSamplesForAcceleration"])
315 a.bufferDelay = Self.sum(a.bufferDelay, stat.numbers["jitterBufferDelay"])
316 a.emitted = Self.sum(a.emitted, stat.numbers["jitterBufferEmittedCount"])
317 inAudio = a
318 } else if kind == "video" {
319 var v = InVideo()
320 v.bytes = stat.numbers["bytesReceived"]
321 v.packets = stat.numbers["packetsReceived"]
322 v.lost = stat.numbers["packetsLost"]
323 v.jitter = stat.numbers["jitter"].map { $0 * 1000 }
324 v.freezes = stat.numbers["freezeCount"]
325 v.freezeSeconds = stat.numbers["totalFreezesDuration"]
326 v.decoded = stat.numbers["framesDecoded"]
327 v.dropped = stat.numbers["framesDropped"]
328 v.width = stat.numbers["frameWidth"]
329 v.height = stat.numbers["frameHeight"]
330 v.fps = stat.numbers["framesPerSecond"]
331 // Told apart by mid, as the statistics elsewhere are: a peer
332 // sharing its screen sends two video streams.
333 if !sample.screenMid.isEmpty, mid == sample.screenMid {
334 inScreen = Self.add(inScreen, v)
335 } else {
336 inVideo = Self.add(inVideo, v)
337 }
338 }
339 default: break
340 }
341 }
342 readPath(stats)
343 }
344
345 /// The selected candidate pair: each end's kind of candidate and the
346 /// transport, and its round trip. Addresses and ports are in the same
347 /// objects and are never read.
348 private mutating func readPath(_ stats: [String: QualityStat]) {
349 var pairId = stats.values.first { $0.type == "transport" }?.strings["selectedCandidatePairId"]
350 if pairId == nil {
351 pairId = stats.first {
352 $0.value.type == "candidate-pair" && ($0.value.numbers["nominated"] ?? 0) != 0
353 && $0.value.strings["state"] == "succeeded"
354 }?.key
355 }
356 guard let pairId, let pair = stats[pairId] else { return }
357 let local = pair.strings["localCandidateId"].flatMap { stats[$0] }
358 let remote = pair.strings["remoteCandidateId"].flatMap { stats[$0] }
359 var path = QualityPath()
360 path.local = local.map { QualityWords.candidate($0.strings["candidateType"]) }
361 path.remote = remote.map { QualityWords.candidate($0.strings["candidateType"]) }
362 if let local {
363 path.protocol = QualityWords.transport(
364 local.strings["protocol"], relayProtocol: local.strings["relayProtocol"],
365 relay: local.strings["candidateType"] == "relay")
366 }
367 self.path = path
368 rtt = pair.numbers["currentRoundTripTime"].map { $0 * 1000 }
369 }
370
371 private static func sum(_ a: Double?, _ b: Double?) -> Double? {
372 guard let b else { return a }
373 return (a ?? 0) + b
374 }
375 private static func most(_ a: Double?, _ b: Double?) -> Double? {
376 guard let b else { return a }
377 return max(a ?? b, b)
378 }
379 /// Two streams of the same kind (a stream replaced on a fresh
380 /// transceiver, briefly beside the one it replaces): counts add, gauges
381 /// are the larger.
382 private static func add(_ a: Out?, _ b: Out) -> Out {
383 guard var a else { return b }
384 a.bytes = sum(a.bytes, b.bytes)
385 a.packets = sum(a.packets, b.packets)
386 a.lost = sum(a.lost, b.lost)
387 a.rtt = most(a.rtt, b.rtt)
388 a.jitter = most(a.jitter, b.jitter)
389 a.limit = a.limit ?? b.limit
390 a.width = most(a.width, b.width)
391 a.height = most(a.height, b.height)
392 a.fps = most(a.fps, b.fps)
393 return a
394 }
395 private static func add(_ a: InVideo?, _ b: InVideo) -> InVideo {
396 guard var a else { return b }
397 a.bytes = sum(a.bytes, b.bytes)
398 a.packets = sum(a.packets, b.packets)
399 a.lost = sum(a.lost, b.lost)
400 a.jitter = most(a.jitter, b.jitter)
401 a.freezes = sum(a.freezes, b.freezes)
402 a.freezeSeconds = sum(a.freezeSeconds, b.freezeSeconds)
403 a.decoded = sum(a.decoded, b.decoded)
404 a.dropped = sum(a.dropped, b.dropped)
405 a.width = most(a.width, b.width)
406 a.height = most(a.height, b.height)
407 a.fps = most(a.fps, b.fps)
408 return a
409 }
410}
411
412/// The schema's enums, from WebRTC's words.
413enum QualityWords {
414 static func candidate(_ type: String?) -> String {
415 switch type {
416 case "host", "srflx", "prflx", "relay": return type!
417 default: return "unknown"
418 }
419 }
420
421 /// udp or tcp; tls for a relay reached over TURN/TLS.
422 static func transport(_ proto: String?, relayProtocol: String?, relay: Bool) -> String {
423 if relay, relayProtocol?.lowercased() == "tls" { return "tls" }
424 switch proto?.lowercased() {
425 case "udp": return "udp"
426 case "tcp": return "tcp"
427 default: return "unknown"
428 }
429 }
430
431 static func limit(_ reason: String?) -> String? {
432 guard let reason, !reason.isEmpty else { return nil }
433 return ["none", "cpu", "bandwidth"].contains(reason) ? reason : "other"
434 }
435}
436
437// MARK: - The notices, tallied
438
439/// What the call said during a window (§15.20, §15.21, §15.23, §15.24), and
440/// the screen watchdogs' steps (§15.22), fed from the level watch's looks.
441/// A notice still showing when the window ends carries into the next
442/// without being counted again.
443public struct QualityEventTally: Equatable, Sendable {
444 /// A screen recovery step, toward a peer (the sharer's) or for their
445 /// screen here (the receiver's).
446 public enum Step: Sendable { case keyframe, rebuild, reshare, failure, reattach }
447
448 struct Peer: Equatable, Sendable {
449 var reconnecting = 0
450 var reconnectingSeconds = 0.0
451 var cantHearSeconds = 0.0
452 var noSound = false
453 var noSoundFromUs = false
454 var audioBlocked = false
455 var screenStalled = false
456 var keyframes = 0
457 var rebuilds = 0
458 var reshares = 0
459 var failures = 0
460 var reattaches = 0
461 }
462
463 /// The longest a look is taken to cover: the level watch looks five
464 /// times a second, and a gap longer than this is the process asleep, not
465 /// a notice on screen.
466 static let longestLook = 1.0
467
468 private var last: Double?
469 private var callShown = false
470 private var peersShown: Set<String> = []
471 private(set) var callReconnecting = 0
472 private(set) var callSeconds = 0.0
473 private(set) var peers: [String: Peer] = [:]
474
475 public init() {}
476
477 /// One look. `reconnecting` and `cantHear` are the people whose tiles
478 /// say so now; the rest are the people each fact is true of now.
479 public mutating func observe(
480 at now: Double, call: Bool, reconnecting: Set<String>, cantHear: Set<String>,
481 noSound: Set<String> = [], noSoundFromUs: Set<String> = [], audioBlocked: Set<String> = [],
482 screenStalled: Set<String> = []
483 ) {
484 let dt = last.map { min(max(now - $0, 0), Self.longestLook) } ?? 0
485 last = now
486 if call {
487 if !callShown { callReconnecting += 1 }
488 callSeconds += dt
489 }
490 callShown = call
491 for id in reconnecting {
492 if !peersShown.contains(id) { peers[id, default: Peer()].reconnecting += 1 }
493 peers[id, default: Peer()].reconnectingSeconds += dt
494 }
495 peersShown = reconnecting
496 for id in cantHear { peers[id, default: Peer()].cantHearSeconds += dt }
497 for id in noSound { peers[id, default: Peer()].noSound = true }
498 for id in noSoundFromUs { peers[id, default: Peer()].noSoundFromUs = true }
499 for id in audioBlocked { peers[id, default: Peer()].audioBlocked = true }
500 for id in screenStalled { peers[id, default: Peer()].screenStalled = true }
501 }
502
503 public mutating func count(_ step: Step, for peer: String) {
504 switch step {
505 case .keyframe: peers[peer, default: Peer()].keyframes += 1
506 case .rebuild: peers[peer, default: Peer()].rebuilds += 1
507 case .reshare: peers[peer, default: Peer()].reshares += 1
508 case .failure: peers[peer, default: Peer()].failures += 1
509 case .reattach: peers[peer, default: Peer()].reattaches += 1
510 }
511 }
512
513 /// The window's tally, and a fresh one for the next window that
514 /// remembers what is still showing.
515 mutating func take() -> QualityEventTally {
516 let window = self
517 callReconnecting = 0
518 callSeconds = 0
519 peers = [:]
520 return window
521 }
522}
523
524// MARK: - The report
525
526/// Turns snapshots into reports: keeps each connection's running totals
527/// from the last report, so a report's counts are what happened during
528/// its window. A new connection generation, or a counter that went
529/// backwards (a stream rebuilt on a fresh transceiver), starts from
530/// nothing.
531public struct QualityReportBuilder: Equatable, Sendable {
532 private var since: Double?
533 private var previous: [String: (generation: Int64, counters: QualityCounters)] = [:]
534
535 public init() {}
536
537 public var started: Bool { since != nil }
538
539 /// Seconds since the window began; zero before it has.
540 public func elapsed(at now: Double) -> Double { since.map { now - $0 } ?? 0 }
541
542 /// The first window begins: at admission.
543 public mutating func start(at now: Double) {
544 since = now
545 previous = [:]
546 }
547
548 public static func == (a: Self, b: Self) -> Bool {
549 a.since == b.since && a.previous.keys == b.previous.keys
550 }
551
552 /// One report, for the window from the last one (or `start`) to `now`.
553 public mutating func build(
554 at now: Double, samples: [String: QualityPeerSample], events tally: inout QualityEventTally,
555 client: QualityClient, devices: QualityDevices?, final: Bool
556 ) -> QualityReport {
557 let seconds = max(now - (since ?? now), 0.1)
558 since = now
559 let events = tally.take()
560 var report = QualityReport(
561 isFinal: final ? true : nil, seconds: Self.bound(seconds, QualityBounds.seconds) ?? QualityBounds.seconds,
562 client: client, devices: devices)
563 if events.callReconnecting > 0 || events.callSeconds > 0 {
564 report.events = QualityCallEvents(
565 reconnecting: Self.steps(events.callReconnecting),
566 reconnectingSeconds: events.callSeconds > 0 ? Self.bound(events.callSeconds, QualityBounds.seconds) : nil)
567 }
568 let ids = Set(samples.keys).union(events.peers.keys).sorted()
569 var peers: [QualityPeer] = []
570 for id in ids.prefix(QualityBounds.peers) {
571 var peer = QualityPeer(peer: id)
572 if let sample = samples[id] {
573 let now = QualityCounters(sample)
574 let before = previous[id].flatMap { $0.generation == sample.generation ? $0.counters : nil }
575 previous[id] = (sample.generation, now)
576 peer.path = now.path
577 peer.rtt = Self.bound(now.rtt, QualityBounds.millis)
578 var out = QualityOutbound()
579 out.audio = now.outAudio.map { Self.out($0, before?.outAudio, seconds) }
580 out.video = now.outVideo.map { Self.out($0, before?.outVideo, seconds) }
581 out.screen = now.outScreen.map { Self.out($0, before?.outScreen, seconds) }
582 if out != QualityOutbound() { peer.out = out }
583 var inbound = QualityInbound()
584 inbound.audio = now.inAudio.map { Self.inAudio($0, before?.inAudio, seconds) }
585 inbound.video = now.inVideo.map { Self.inVideo($0, before?.inVideo, seconds) }
586 inbound.screen = now.inScreen.map { Self.inVideo($0, before?.inScreen, seconds) }
587 if inbound != QualityInbound() { peer.in = inbound }
588 }
589 if let e = events.peers[id] { Self.apply(e, to: &peer) }
590 peers.append(peer)
591 }
592 // A connection gone is forgotten; if it comes back it starts again.
593 for id in Array(previous.keys) where samples[id] == nil { previous.removeValue(forKey: id) }
594 report.peers = peers.isEmpty ? nil : peers
595 return report
596 }
597
598 private static func apply(_ e: QualityEventTally.Peer, to peer: inout QualityPeer) {
599 var events = QualityPeerEvents()
600 events.reconnecting = steps(e.reconnecting)
601 if e.reconnectingSeconds > 0 { events.reconnectingSeconds = bound(e.reconnectingSeconds, QualityBounds.seconds) }
602 if e.cantHearSeconds > 0 { events.cantHearSeconds = bound(e.cantHearSeconds, QualityBounds.seconds) }
603 if e.noSound { events.noSound = true }
604 if e.noSoundFromUs { events.noSoundFromUs = true }
605 if e.audioBlocked { events.audioBlocked = true }
606 if e.screenStalled { events.screenStalled = true }
607 if events != QualityPeerEvents() { peer.events = events }
608 // The sharer's recovery steps toward this peer belong with the screen
609 // going out to them, even in a window where it sent nothing.
610 if e.keyframes + e.rebuilds + e.reshares + e.failures > 0 {
611 var out = peer.out ?? QualityOutbound()
612 var screen = out.screen ?? QualityOutStream()
613 screen.keyframes = steps(e.keyframes)
614 screen.rebuilds = steps(e.rebuilds)
615 screen.reshares = steps(e.reshares)
616 screen.failures = steps(e.failures)
617 out.screen = screen
618 peer.out = out
619 }
620 if e.reattaches > 0 {
621 var inbound = peer.in ?? QualityInbound()
622 var screen = inbound.screen ?? QualityInVideo()
623 screen.reattaches = steps(e.reattaches)
624 inbound.screen = screen
625 peer.in = inbound
626 }
627 }
628
629 private static func steps(_ n: Int) -> Double? { n > 0 ? bound(Double(n), QualityBounds.count) : nil }
630
631 /// What happened during the window: the difference, or the whole count
632 /// when there is nothing to take it from or it went backwards.
633 static func delta(_ now: Double?, _ before: Double?) -> Double? {
634 // A running total below zero (RTCP's packetsLost can be, with
635 // duplicates) is not a count of anything.
636 guard let now, now.isFinite, now >= 0 else { return nil }
637 guard let before, before.isFinite, now >= before else { return now }
638 return now - before
639 }
640
641 /// Finite, not negative, under the ceiling, to a tenth; nil otherwise.
642 static func bound(_ value: Double?, _ ceiling: Double) -> Double? {
643 guard let value, value.isFinite, value >= 0 else { return nil }
644 return (min(value, ceiling) * 10).rounded() / 10
645 }
646
647 private static func kbps(_ bytes: Double?, _ seconds: Double) -> Double? {
648 guard let bytes, seconds > 0 else { return nil }
649 return bound(bytes * 8 / 1000 / seconds, QualityBounds.kbps)
650 }
651
652 private static func out(_ now: QualityCounters.Out, _ before: QualityCounters.Out?, _ seconds: Double)
653 -> QualityOutStream
654 {
655 var s = QualityOutStream()
656 s.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
657 s.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
658 s.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
659 s.rtt = bound(now.rtt, QualityBounds.millis)
660 s.jitter = bound(now.jitter, QualityBounds.millis)
661 s.limit = QualityWords.limit(now.limit)
662 s.width = bound(now.width, QualityBounds.pixels)
663 s.height = bound(now.height, QualityBounds.pixels)
664 s.fps = bound(now.fps, QualityBounds.fps)
665 return s
666 }
667
668 private static func inAudio(
669 _ now: QualityCounters.InAudio, _ before: QualityCounters.InAudio?, _ seconds: Double
670 ) -> QualityInAudio {
671 var a = QualityInAudio()
672 a.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
673 a.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
674 a.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
675 a.jitter = bound(now.jitter, QualityBounds.millis)
676 a.samples = bound(delta(now.samples, before?.samples), QualityBounds.count)
677 a.concealed = bound(delta(now.concealed, before?.concealed), QualityBounds.count)
678 a.inserted = bound(delta(now.inserted, before?.inserted), QualityBounds.count)
679 a.removed = bound(delta(now.removed, before?.removed), QualityBounds.count)
680 // jitterBufferDelay is seconds summed over every sample emitted, so the
681 // window's mean is one delta over the other.
682 if let delay = delta(now.bufferDelay, before?.bufferDelay),
683 let emitted = delta(now.emitted, before?.emitted), emitted > 0
684 {
685 a.jitterBufferMs = bound(delay / emitted * 1000, QualityBounds.millis)
686 }
687 return a
688 }
689
690 private static func inVideo(
691 _ now: QualityCounters.InVideo, _ before: QualityCounters.InVideo?, _ seconds: Double
692 ) -> QualityInVideo {
693 var v = QualityInVideo()
694 v.kbps = kbps(delta(now.bytes, before?.bytes), seconds)
695 v.packets = bound(delta(now.packets, before?.packets), QualityBounds.count)
696 v.lost = bound(delta(now.lost, before?.lost), QualityBounds.count)
697 v.jitter = bound(now.jitter, QualityBounds.millis)
698 v.freezes = bound(delta(now.freezes, before?.freezes), QualityBounds.count)
699 v.freezeSeconds = bound(delta(now.freezeSeconds, before?.freezeSeconds), QualityBounds.seconds)
700 v.decoded = bound(delta(now.decoded, before?.decoded), QualityBounds.count)
701 v.dropped = bound(delta(now.dropped, before?.dropped), QualityBounds.count)
702 v.width = bound(now.width, QualityBounds.pixels)
703 v.height = bound(now.height, QualityBounds.pixels)
704 v.fps = bound(now.fps, QualityBounds.fps)
705 return v
706 }
707}
708
709// MARK: - Who is reporting, and on what
710
711/// The app, its version and the system's, as the schema allows them.
712public enum QualityClientInfo {
713 /// This process: "mac", or "iphone"/"ipad" by idiom.
714 @MainActor public static func current() -> QualityClient {
715 let version = self.version(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
716 let os = osVersion(ProcessInfo.processInfo.operatingSystemVersion)
717 #if os(macOS)
718 return QualityClient(app: "mac", version: version, os: "macos", osVersion: os)
719 #else
720 let pad = UIDevice.current.userInterfaceIdiom == .pad
721 return QualityClient(app: pad ? "ipad" : "iphone", version: version, os: pad ? "ipados" : "ios", osVersion: os)
722 #endif
723 }
724
725 /// Digits and dots, or nothing: a version is never free text.
726 public static func version(_ raw: String?) -> String? {
727 guard let raw, !raw.isEmpty, raw.count <= 24,
728 raw.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }),
729 raw.first?.isNumber == true, raw.last?.isNumber == true, !raw.contains("..")
730 else { return nil }
731 return raw
732 }
733
734 public static func osVersion(_ v: OperatingSystemVersion) -> String {
735 "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
736 }
737}
738
739/// A microphone's or speaker's kind, from how it is connected. Never its
740/// name.
741public enum DeviceKind {
742 /// From CoreAudioDevices.transport's words.
743 public static func mac(transport: String?) -> String {
744 switch transport {
745 case nil, "": return "unknown"
746 case "built-in": return "built-in"
747 case "bluetooth": return "bluetooth"
748 case "usb": return "usb"
749 case "airplay": return "airplay"
750 case "virtual", "aggregate": return "virtual"
751 case "continuity": return "continuity"
752 default: return "other"
753 }
754 }
755
756 /// From an AVAudioSession port type's raw value (AVAudioSession.Port).
757 public static func ios(port: String?) -> String {
758 switch port {
759 case nil, "": return "none"
760 case "MicrophoneBuiltIn", "Speaker", "Receiver": return "built-in"
761 case "BluetoothHFP", "BluetoothA2DPOutput", "BluetoothLEOutput": return "bluetooth"
762 case "USBAudio": return "usb"
763 case "Headphones", "MicrophoneWired": return "wired"
764 case "AirPlay": return "airplay"
765 case "Virtual": return "virtual"
766 case "ContinuityMicrophone": return "continuity"
767 default: return "other"
768 }
769 }
770}
771
772/// The automatic reports' switch, in Settings (spec §15.26). On unless
773/// turned off; "Report a problem" works either way.
774public enum QualityReporting {
775 public static let enabledKey = "sendQualityReports"
776 public static func enabled(_ defaults: UserDefaults = .standard) -> Bool {
777 defaults.object(forKey: enabledKey) as? Bool ?? true
778 }
779 /// The settings line, both platforms.
780 public static let disclosure = "Calls sends anonymous connection quality reports to help fix call problems."
781}
782