import Foundation #if os(iOS) import UIKit #endif // Quality reports (spec §15.26, Paul's ruling of 2026-10-03): every thirty // seconds while admitted, and once more at leave, this app tells the // server how its connections went over the window — numbers and enums // only, in protocol/quality.go's fixed schema. No names, no addresses, no // device names, no meeting, nothing typed by a person. // // Everything here is pure: the statistics come in as a snapshot // (`QualityPeerSample`), the notices as a tally (`QualityEventTally`), and // the report comes out, so the whole of it is tested from fixtures. Call // gathers the snapshot and sends what comes out. // MARK: - The wire (protocol/quality.go) /// The schema's bounds, as protocol/quality.go has them. The server /// refuses a report over any of them, so a client clamps to them first. public enum QualityBounds { public static let version = 1 /// Remote peers in one report: the mesh holds three, and one spare for a /// connection on its way out. Past it the line would not fit the /// platform's 8 KB. public static let peers = 4 public static let seconds = 600.0 public static let millis = 600_000.0 public static let count = 1e9 public static let kbps = 1e6 public static let pixels = 16384.0 public static let fps = 240.0 public static let body = 16 << 10 public static let note = 1000 public static let problemBody = 64 << 10 } public struct QualityReport: Encodable, Equatable, Sendable { public var v = QualityBounds.version /// The report sent at leave or end. public var isFinal: Bool? public var seconds: Double public var client: QualityClient public var devices: QualityDevices? public var events: QualityCallEvents? public var peers: [QualityPeer]? enum CodingKeys: String, CodingKey { case v, seconds, client, devices, events, peers case isFinal = "final" } /// The body as it is sent: sorted keys, so two equal reports are the /// same bytes. public func encoded() -> String? { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(self), data.count <= QualityBounds.body else { return nil } return String(data: data, encoding: .utf8) } } /// What is reporting: enums, and versions as digits and dots. Never a /// device model or a machine name. public struct QualityClient: Codable, Equatable, Sendable { public var app: String public var version: String? public var os: String? public var osVersion: String? public init(app: String, version: String? = nil, os: String? = nil, osVersion: String? = nil) { self.app = app self.version = version self.os = os self.osVersion = osVersion } } /// The kinds of microphone and speaker in use, by transport: never a /// device's name, which can be personal ("Paul's AirPods Pro"). public struct QualityDevices: Encodable, Equatable, Sendable { public var input: String? public var output: String? } public struct QualityCallEvents: Encodable, Equatable, Sendable { public var reconnecting: Double? public var reconnectingSeconds: Double? } public struct QualityPeer: Encodable, Equatable, Sendable { public var peer: String public var path: QualityPath? public var rtt: Double? public var out: QualityOutbound? public var `in`: QualityInbound? public var events: QualityPeerEvents? } public struct QualityPath: Encodable, Equatable, Sendable { public var local: String? public var remote: String? public var `protocol`: String? } public struct QualityOutbound: Encodable, Equatable, Sendable { public var audio: QualityOutStream? public var video: QualityOutStream? public var screen: QualityOutStream? } public struct QualityOutStream: Encodable, Equatable, Sendable { public var kbps: Double? public var packets: Double? public var lost: Double? public var rtt: Double? public var jitter: Double? public var limit: String? public var width: Double? public var height: Double? public var fps: Double? public var keyframes: Double? public var rebuilds: Double? public var reshares: Double? public var failures: Double? } public struct QualityInbound: Encodable, Equatable, Sendable { public var audio: QualityInAudio? public var video: QualityInVideo? public var screen: QualityInVideo? } public struct QualityInAudio: Encodable, Equatable, Sendable { public var kbps: Double? public var packets: Double? public var lost: Double? public var jitter: Double? public var samples: Double? public var concealed: Double? public var inserted: Double? public var removed: Double? public var jitterBufferMs: Double? } public struct QualityInVideo: Encodable, Equatable, Sendable { public var kbps: Double? public var packets: Double? public var lost: Double? public var jitter: Double? public var freezes: Double? public var freezeSeconds: Double? public var decoded: Double? public var dropped: Double? public var width: Double? public var height: Double? public var fps: Double? public var reattaches: Double? } public struct QualityPeerEvents: Encodable, Equatable, Sendable { public var reconnecting: Double? public var reconnectingSeconds: Double? public var cantHearSeconds: Double? public var noSound: Bool? public var noSoundFromUs: Bool? public var audioBlocked: Bool? public var screenStalled: Bool? } // MARK: - The snapshot /// One statistics object from WebRTC's report, its values split by kind: /// numbers (NSNumber, booleans among them) and strings. Nothing else in a /// report is read. public struct QualityStat: Equatable, Sendable { public var type: String public var numbers: [String: Double] public var strings: [String: String] public init(type: String, numbers: [String: Double] = [:], strings: [String: String] = [:]) { self.type = type self.numbers = numbers self.strings = strings } /// From WebRTC's own values. init(type: String, values: [String: Any]) { var numbers: [String: Double] = [:] var strings: [String: String] = [:] for (key, value) in values { if let n = value as? NSNumber { numbers[key] = n.doubleValue } else if let s = value as? String { strings[key] = s } } self.init(type: type, numbers: numbers, strings: strings) } } /// One connection's statistics at one look, with what is needed to read /// them: the connection's generation (a new one starts every count from /// nothing), and which mids are screens. public struct QualityPeerSample: Equatable, Sendable { public var generation: Int64 /// The mid this app's own screen goes out on, while sharing. public var ownScreenMid: String? /// The mid their screen arrives on here, while they share. public var screenMid: String public var stats: [String: QualityStat] public init(generation: Int64, ownScreenMid: String? = nil, screenMid: String = "", stats: [String: QualityStat]) { self.generation = generation self.ownScreenMid = ownScreenMid self.screenMid = screenMid self.stats = stats } } // MARK: - Running totals, read from a snapshot /// The cumulative counters and current gauges of one connection, as the /// statistics have them. A report is the difference between two of these. struct QualityCounters: Equatable, Sendable { struct Out: Equatable, Sendable { var bytes: Double? var packets: Double? var lost: Double? var rtt: Double? var jitter: Double? var limit: String? var width: Double? var height: Double? var fps: Double? } struct InAudio: Equatable, Sendable { var bytes: Double? var packets: Double? var lost: Double? var jitter: Double? var samples: Double? var concealed: Double? var inserted: Double? var removed: Double? var bufferDelay: Double? var emitted: Double? } struct InVideo: Equatable, Sendable { var bytes: Double? var packets: Double? var lost: Double? var jitter: Double? var freezes: Double? var freezeSeconds: Double? var decoded: Double? var dropped: Double? var width: Double? var height: Double? var fps: Double? } var outAudio: Out? var outVideo: Out? var outScreen: Out? var inAudio: InAudio? var inVideo: InVideo? var inScreen: InVideo? var path: QualityPath? var rtt: Double? init() {} /// Reads a snapshot. A stream absent from the statistics is absent /// here; a value WebRTC does not give is nil, never zero. init(_ sample: QualityPeerSample) { let stats = sample.stats for (id, stat) in stats { let kind = stat.strings["kind"] ?? stat.strings["mediaType"] ?? "" let mid = stat.strings["mid"] ?? "" switch stat.type { case "outbound-rtp": var out = Out() out.bytes = stat.numbers["bytesSent"] out.packets = stat.numbers["packetsSent"] out.limit = stat.strings["qualityLimitationReason"] out.width = stat.numbers["frameWidth"] out.height = stat.numbers["frameHeight"] out.fps = stat.numbers["framesPerSecond"] // The receiver's own account of this stream. if let remote = stats.values.first(where: { $0.type == "remote-inbound-rtp" && $0.strings["localId"] == id }) { out.lost = remote.numbers["packetsLost"] out.rtt = remote.numbers["roundTripTime"].map { $0 * 1000 } out.jitter = remote.numbers["jitter"].map { $0 * 1000 } } if kind == "audio" { outAudio = Self.add(outAudio, out) } else if kind == "video" { if let own = sample.ownScreenMid, !own.isEmpty, own == mid { outScreen = Self.add(outScreen, out) } else { outVideo = Self.add(outVideo, out) } } case "inbound-rtp": if kind == "audio" { var a = inAudio ?? InAudio() a.bytes = Self.sum(a.bytes, stat.numbers["bytesReceived"]) a.packets = Self.sum(a.packets, stat.numbers["packetsReceived"]) a.lost = Self.sum(a.lost, stat.numbers["packetsLost"]) a.jitter = Self.most(a.jitter, stat.numbers["jitter"].map { $0 * 1000 }) a.samples = Self.sum(a.samples, stat.numbers["totalSamplesReceived"]) a.concealed = Self.sum(a.concealed, stat.numbers["concealedSamples"]) a.inserted = Self.sum(a.inserted, stat.numbers["insertedSamplesForDeceleration"]) a.removed = Self.sum(a.removed, stat.numbers["removedSamplesForAcceleration"]) a.bufferDelay = Self.sum(a.bufferDelay, stat.numbers["jitterBufferDelay"]) a.emitted = Self.sum(a.emitted, stat.numbers["jitterBufferEmittedCount"]) inAudio = a } else if kind == "video" { var v = InVideo() v.bytes = stat.numbers["bytesReceived"] v.packets = stat.numbers["packetsReceived"] v.lost = stat.numbers["packetsLost"] v.jitter = stat.numbers["jitter"].map { $0 * 1000 } v.freezes = stat.numbers["freezeCount"] v.freezeSeconds = stat.numbers["totalFreezesDuration"] v.decoded = stat.numbers["framesDecoded"] v.dropped = stat.numbers["framesDropped"] v.width = stat.numbers["frameWidth"] v.height = stat.numbers["frameHeight"] v.fps = stat.numbers["framesPerSecond"] // Told apart by mid, as the statistics elsewhere are: a peer // sharing its screen sends two video streams. if !sample.screenMid.isEmpty, mid == sample.screenMid { inScreen = Self.add(inScreen, v) } else { inVideo = Self.add(inVideo, v) } } default: break } } readPath(stats) } /// The selected candidate pair: each end's kind of candidate and the /// transport, and its round trip. Addresses and ports are in the same /// objects and are never read. private mutating func readPath(_ stats: [String: QualityStat]) { var pairId = stats.values.first { $0.type == "transport" }?.strings["selectedCandidatePairId"] if pairId == nil { pairId = stats.first { $0.value.type == "candidate-pair" && ($0.value.numbers["nominated"] ?? 0) != 0 && $0.value.strings["state"] == "succeeded" }?.key } guard let pairId, let pair = stats[pairId] else { return } let local = pair.strings["localCandidateId"].flatMap { stats[$0] } let remote = pair.strings["remoteCandidateId"].flatMap { stats[$0] } var path = QualityPath() path.local = local.map { QualityWords.candidate($0.strings["candidateType"]) } path.remote = remote.map { QualityWords.candidate($0.strings["candidateType"]) } if let local { path.protocol = QualityWords.transport( local.strings["protocol"], relayProtocol: local.strings["relayProtocol"], relay: local.strings["candidateType"] == "relay") } self.path = path rtt = pair.numbers["currentRoundTripTime"].map { $0 * 1000 } } private static func sum(_ a: Double?, _ b: Double?) -> Double? { guard let b else { return a } return (a ?? 0) + b } private static func most(_ a: Double?, _ b: Double?) -> Double? { guard let b else { return a } return max(a ?? b, b) } /// Two streams of the same kind (a stream replaced on a fresh /// transceiver, briefly beside the one it replaces): counts add, gauges /// are the larger. private static func add(_ a: Out?, _ b: Out) -> Out { guard var a else { return b } a.bytes = sum(a.bytes, b.bytes) a.packets = sum(a.packets, b.packets) a.lost = sum(a.lost, b.lost) a.rtt = most(a.rtt, b.rtt) a.jitter = most(a.jitter, b.jitter) a.limit = a.limit ?? b.limit a.width = most(a.width, b.width) a.height = most(a.height, b.height) a.fps = most(a.fps, b.fps) return a } private static func add(_ a: InVideo?, _ b: InVideo) -> InVideo { guard var a else { return b } a.bytes = sum(a.bytes, b.bytes) a.packets = sum(a.packets, b.packets) a.lost = sum(a.lost, b.lost) a.jitter = most(a.jitter, b.jitter) a.freezes = sum(a.freezes, b.freezes) a.freezeSeconds = sum(a.freezeSeconds, b.freezeSeconds) a.decoded = sum(a.decoded, b.decoded) a.dropped = sum(a.dropped, b.dropped) a.width = most(a.width, b.width) a.height = most(a.height, b.height) a.fps = most(a.fps, b.fps) return a } } /// The schema's enums, from WebRTC's words. enum QualityWords { static func candidate(_ type: String?) -> String { switch type { case "host", "srflx", "prflx", "relay": return type! default: return "unknown" } } /// udp or tcp; tls for a relay reached over TURN/TLS. static func transport(_ proto: String?, relayProtocol: String?, relay: Bool) -> String { if relay, relayProtocol?.lowercased() == "tls" { return "tls" } switch proto?.lowercased() { case "udp": return "udp" case "tcp": return "tcp" default: return "unknown" } } static func limit(_ reason: String?) -> String? { guard let reason, !reason.isEmpty else { return nil } return ["none", "cpu", "bandwidth"].contains(reason) ? reason : "other" } } // MARK: - The notices, tallied /// What the call said during a window (§15.20, §15.21, §15.23, §15.24), and /// the screen watchdogs' steps (§15.22), fed from the level watch's looks. /// A notice still showing when the window ends carries into the next /// without being counted again. public struct QualityEventTally: Equatable, Sendable { /// A screen recovery step, toward a peer (the sharer's) or for their /// screen here (the receiver's). public enum Step: Sendable { case keyframe, rebuild, reshare, failure, reattach } struct Peer: Equatable, Sendable { var reconnecting = 0 var reconnectingSeconds = 0.0 var cantHearSeconds = 0.0 var noSound = false var noSoundFromUs = false var audioBlocked = false var screenStalled = false var keyframes = 0 var rebuilds = 0 var reshares = 0 var failures = 0 var reattaches = 0 } /// The longest a look is taken to cover: the level watch looks five /// times a second, and a gap longer than this is the process asleep, not /// a notice on screen. static let longestLook = 1.0 private var last: Double? private var callShown = false private var peersShown: Set = [] private(set) var callReconnecting = 0 private(set) var callSeconds = 0.0 private(set) var peers: [String: Peer] = [:] public init() {} /// One look. `reconnecting` and `cantHear` are the people whose tiles /// say so now; the rest are the people each fact is true of now. public mutating func observe( at now: Double, call: Bool, reconnecting: Set, cantHear: Set, noSound: Set = [], noSoundFromUs: Set = [], audioBlocked: Set = [], screenStalled: Set = [] ) { let dt = last.map { min(max(now - $0, 0), Self.longestLook) } ?? 0 last = now if call { if !callShown { callReconnecting += 1 } callSeconds += dt } callShown = call for id in reconnecting { if !peersShown.contains(id) { peers[id, default: Peer()].reconnecting += 1 } peers[id, default: Peer()].reconnectingSeconds += dt } peersShown = reconnecting for id in cantHear { peers[id, default: Peer()].cantHearSeconds += dt } for id in noSound { peers[id, default: Peer()].noSound = true } for id in noSoundFromUs { peers[id, default: Peer()].noSoundFromUs = true } for id in audioBlocked { peers[id, default: Peer()].audioBlocked = true } for id in screenStalled { peers[id, default: Peer()].screenStalled = true } } public mutating func count(_ step: Step, for peer: String) { switch step { case .keyframe: peers[peer, default: Peer()].keyframes += 1 case .rebuild: peers[peer, default: Peer()].rebuilds += 1 case .reshare: peers[peer, default: Peer()].reshares += 1 case .failure: peers[peer, default: Peer()].failures += 1 case .reattach: peers[peer, default: Peer()].reattaches += 1 } } /// The window's tally, and a fresh one for the next window that /// remembers what is still showing. mutating func take() -> QualityEventTally { let window = self callReconnecting = 0 callSeconds = 0 peers = [:] return window } } // MARK: - The report /// Turns snapshots into reports: keeps each connection's running totals /// from the last report, so a report's counts are what happened during /// its window. A new connection generation, or a counter that went /// backwards (a stream rebuilt on a fresh transceiver), starts from /// nothing. public struct QualityReportBuilder: Equatable, Sendable { private var since: Double? private var previous: [String: (generation: Int64, counters: QualityCounters)] = [:] public init() {} public var started: Bool { since != nil } /// Seconds since the window began; zero before it has. public func elapsed(at now: Double) -> Double { since.map { now - $0 } ?? 0 } /// The first window begins: at admission. public mutating func start(at now: Double) { since = now previous = [:] } public static func == (a: Self, b: Self) -> Bool { a.since == b.since && a.previous.keys == b.previous.keys } /// One report, for the window from the last one (or `start`) to `now`. public mutating func build( at now: Double, samples: [String: QualityPeerSample], events tally: inout QualityEventTally, client: QualityClient, devices: QualityDevices?, final: Bool ) -> QualityReport { let seconds = max(now - (since ?? now), 0.1) since = now let events = tally.take() var report = QualityReport( isFinal: final ? true : nil, seconds: Self.bound(seconds, QualityBounds.seconds) ?? QualityBounds.seconds, client: client, devices: devices) if events.callReconnecting > 0 || events.callSeconds > 0 { report.events = QualityCallEvents( reconnecting: Self.steps(events.callReconnecting), reconnectingSeconds: events.callSeconds > 0 ? Self.bound(events.callSeconds, QualityBounds.seconds) : nil) } let ids = Set(samples.keys).union(events.peers.keys).sorted() var peers: [QualityPeer] = [] for id in ids.prefix(QualityBounds.peers) { var peer = QualityPeer(peer: id) if let sample = samples[id] { let now = QualityCounters(sample) let before = previous[id].flatMap { $0.generation == sample.generation ? $0.counters : nil } previous[id] = (sample.generation, now) peer.path = now.path peer.rtt = Self.bound(now.rtt, QualityBounds.millis) var out = QualityOutbound() out.audio = now.outAudio.map { Self.out($0, before?.outAudio, seconds) } out.video = now.outVideo.map { Self.out($0, before?.outVideo, seconds) } out.screen = now.outScreen.map { Self.out($0, before?.outScreen, seconds) } if out != QualityOutbound() { peer.out = out } var inbound = QualityInbound() inbound.audio = now.inAudio.map { Self.inAudio($0, before?.inAudio, seconds) } inbound.video = now.inVideo.map { Self.inVideo($0, before?.inVideo, seconds) } inbound.screen = now.inScreen.map { Self.inVideo($0, before?.inScreen, seconds) } if inbound != QualityInbound() { peer.in = inbound } } if let e = events.peers[id] { Self.apply(e, to: &peer) } peers.append(peer) } // A connection gone is forgotten; if it comes back it starts again. for id in Array(previous.keys) where samples[id] == nil { previous.removeValue(forKey: id) } report.peers = peers.isEmpty ? nil : peers return report } private static func apply(_ e: QualityEventTally.Peer, to peer: inout QualityPeer) { var events = QualityPeerEvents() events.reconnecting = steps(e.reconnecting) if e.reconnectingSeconds > 0 { events.reconnectingSeconds = bound(e.reconnectingSeconds, QualityBounds.seconds) } if e.cantHearSeconds > 0 { events.cantHearSeconds = bound(e.cantHearSeconds, QualityBounds.seconds) } if e.noSound { events.noSound = true } if e.noSoundFromUs { events.noSoundFromUs = true } if e.audioBlocked { events.audioBlocked = true } if e.screenStalled { events.screenStalled = true } if events != QualityPeerEvents() { peer.events = events } // The sharer's recovery steps toward this peer belong with the screen // going out to them, even in a window where it sent nothing. if e.keyframes + e.rebuilds + e.reshares + e.failures > 0 { var out = peer.out ?? QualityOutbound() var screen = out.screen ?? QualityOutStream() screen.keyframes = steps(e.keyframes) screen.rebuilds = steps(e.rebuilds) screen.reshares = steps(e.reshares) screen.failures = steps(e.failures) out.screen = screen peer.out = out } if e.reattaches > 0 { var inbound = peer.in ?? QualityInbound() var screen = inbound.screen ?? QualityInVideo() screen.reattaches = steps(e.reattaches) inbound.screen = screen peer.in = inbound } } private static func steps(_ n: Int) -> Double? { n > 0 ? bound(Double(n), QualityBounds.count) : nil } /// What happened during the window: the difference, or the whole count /// when there is nothing to take it from or it went backwards. static func delta(_ now: Double?, _ before: Double?) -> Double? { // A running total below zero (RTCP's packetsLost can be, with // duplicates) is not a count of anything. guard let now, now.isFinite, now >= 0 else { return nil } guard let before, before.isFinite, now >= before else { return now } return now - before } /// Finite, not negative, under the ceiling, to a tenth; nil otherwise. static func bound(_ value: Double?, _ ceiling: Double) -> Double? { guard let value, value.isFinite, value >= 0 else { return nil } return (min(value, ceiling) * 10).rounded() / 10 } private static func kbps(_ bytes: Double?, _ seconds: Double) -> Double? { guard let bytes, seconds > 0 else { return nil } return bound(bytes * 8 / 1000 / seconds, QualityBounds.kbps) } private static func out(_ now: QualityCounters.Out, _ before: QualityCounters.Out?, _ seconds: Double) -> QualityOutStream { var s = QualityOutStream() s.kbps = kbps(delta(now.bytes, before?.bytes), seconds) s.packets = bound(delta(now.packets, before?.packets), QualityBounds.count) s.lost = bound(delta(now.lost, before?.lost), QualityBounds.count) s.rtt = bound(now.rtt, QualityBounds.millis) s.jitter = bound(now.jitter, QualityBounds.millis) s.limit = QualityWords.limit(now.limit) s.width = bound(now.width, QualityBounds.pixels) s.height = bound(now.height, QualityBounds.pixels) s.fps = bound(now.fps, QualityBounds.fps) return s } private static func inAudio( _ now: QualityCounters.InAudio, _ before: QualityCounters.InAudio?, _ seconds: Double ) -> QualityInAudio { var a = QualityInAudio() a.kbps = kbps(delta(now.bytes, before?.bytes), seconds) a.packets = bound(delta(now.packets, before?.packets), QualityBounds.count) a.lost = bound(delta(now.lost, before?.lost), QualityBounds.count) a.jitter = bound(now.jitter, QualityBounds.millis) a.samples = bound(delta(now.samples, before?.samples), QualityBounds.count) a.concealed = bound(delta(now.concealed, before?.concealed), QualityBounds.count) a.inserted = bound(delta(now.inserted, before?.inserted), QualityBounds.count) a.removed = bound(delta(now.removed, before?.removed), QualityBounds.count) // jitterBufferDelay is seconds summed over every sample emitted, so the // window's mean is one delta over the other. if let delay = delta(now.bufferDelay, before?.bufferDelay), let emitted = delta(now.emitted, before?.emitted), emitted > 0 { a.jitterBufferMs = bound(delay / emitted * 1000, QualityBounds.millis) } return a } private static func inVideo( _ now: QualityCounters.InVideo, _ before: QualityCounters.InVideo?, _ seconds: Double ) -> QualityInVideo { var v = QualityInVideo() v.kbps = kbps(delta(now.bytes, before?.bytes), seconds) v.packets = bound(delta(now.packets, before?.packets), QualityBounds.count) v.lost = bound(delta(now.lost, before?.lost), QualityBounds.count) v.jitter = bound(now.jitter, QualityBounds.millis) v.freezes = bound(delta(now.freezes, before?.freezes), QualityBounds.count) v.freezeSeconds = bound(delta(now.freezeSeconds, before?.freezeSeconds), QualityBounds.seconds) v.decoded = bound(delta(now.decoded, before?.decoded), QualityBounds.count) v.dropped = bound(delta(now.dropped, before?.dropped), QualityBounds.count) v.width = bound(now.width, QualityBounds.pixels) v.height = bound(now.height, QualityBounds.pixels) v.fps = bound(now.fps, QualityBounds.fps) return v } } // MARK: - Who is reporting, and on what /// The app, its version and the system's, as the schema allows them. public enum QualityClientInfo { /// This process: "mac", or "iphone"/"ipad" by idiom. @MainActor public static func current() -> QualityClient { let version = self.version(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) let os = osVersion(ProcessInfo.processInfo.operatingSystemVersion) #if os(macOS) return QualityClient(app: "mac", version: version, os: "macos", osVersion: os) #else let pad = UIDevice.current.userInterfaceIdiom == .pad return QualityClient(app: pad ? "ipad" : "iphone", version: version, os: pad ? "ipados" : "ios", osVersion: os) #endif } /// Digits and dots, or nothing: a version is never free text. public static func version(_ raw: String?) -> String? { guard let raw, !raw.isEmpty, raw.count <= 24, raw.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }), raw.first?.isNumber == true, raw.last?.isNumber == true, !raw.contains("..") else { return nil } return raw } public static func osVersion(_ v: OperatingSystemVersion) -> String { "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" } } /// A microphone's or speaker's kind, from how it is connected. Never its /// name. public enum DeviceKind { /// From CoreAudioDevices.transport's words. public static func mac(transport: String?) -> String { switch transport { case nil, "": return "unknown" case "built-in": return "built-in" case "bluetooth": return "bluetooth" case "usb": return "usb" case "airplay": return "airplay" case "virtual", "aggregate": return "virtual" case "continuity": return "continuity" default: return "other" } } /// From an AVAudioSession port type's raw value (AVAudioSession.Port). public static func ios(port: String?) -> String { switch port { case nil, "": return "none" case "MicrophoneBuiltIn", "Speaker", "Receiver": return "built-in" case "BluetoothHFP", "BluetoothA2DPOutput", "BluetoothLEOutput": return "bluetooth" case "USBAudio": return "usb" case "Headphones", "MicrophoneWired": return "wired" case "AirPlay": return "airplay" case "Virtual": return "virtual" case "ContinuityMicrophone": return "continuity" default: return "other" } } } /// The automatic reports' switch, in Settings (spec §15.26). On unless /// turned off; "Report a problem" works either way. public enum QualityReporting { public static let enabledKey = "sendQualityReports" public static func enabled(_ defaults: UserDefaults = .standard) -> Bool { defaults.object(forKey: enabledKey) as? Bool ?? true } /// The settings line, both platforms. public static let disclosure = "Calls sends anonymous connection quality reports to help fix call problems." }