| 1 | import 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. |
| 21 | public 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 | |
| 38 | public 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. |
| 65 | public 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"). |
| 81 | public struct QualityDevices: Encodable, Equatable, Sendable { |
| 82 | public var input: String? |
| 83 | public var output: String? |
| 84 | } |
| 85 | |
| 86 | public struct QualityCallEvents: Encodable, Equatable, Sendable { |
| 87 | public var reconnecting: Double? |
| 88 | public var reconnectingSeconds: Double? |
| 89 | } |
| 90 | |
| 91 | public 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 | |
| 100 | public struct QualityPath: Encodable, Equatable, Sendable { |
| 101 | public var local: String? |
| 102 | public var remote: String? |
| 103 | public var `protocol`: String? |
| 104 | } |
| 105 | |
| 106 | public struct QualityOutbound: Encodable, Equatable, Sendable { |
| 107 | public var audio: QualityOutStream? |
| 108 | public var video: QualityOutStream? |
| 109 | public var screen: QualityOutStream? |
| 110 | } |
| 111 | |
| 112 | public 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 | |
| 128 | public struct QualityInbound: Encodable, Equatable, Sendable { |
| 129 | public var audio: QualityInAudio? |
| 130 | public var video: QualityInVideo? |
| 131 | public var screen: QualityInVideo? |
| 132 | } |
| 133 | |
| 134 | public 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 | |
| 146 | public 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 | |
| 161 | public 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. |
| 176 | public 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. |
| 205 | public 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. |
| 225 | struct 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. |
| 413 | enum 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. |
| 443 | public 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. |
| 531 | public 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. |
| 712 | public 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. |
| 741 | public 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. |
| 774 | public 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 | |