import Foundation import HealthKit import LoopKit import Swinject import UIKit // MARK: - TelemetryClient /// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted /// endpoint at most once every 24 hours, plus once after a new build is installed. /// Consent is collected during onboarding (or via a one-time migration sheet for /// existing users) and editable in Settings → App Diagnostics. /// /// No health data, credentials, or personally-identifying information is sent. /// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView` /// for the in-app inspector that renders the same payload. final class TelemetryClient: Injectable { static let shared = TelemetryClient() // MARK: Endpoint configuration private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org") // MARK: if you fork Trio and keep telemetry enabled, please change the name here // so that we can distinguish forks from mainline Trio builds in our telemetry. private static let telemetryAppName: String = "Trio" /// Effective base URL: respects the debug override in /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to /// `productionBaseURL`. Used by both the registration and `/checkin` paths. private static var baseURL: URL? { if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty, let url = URL(string: override) { return url } return productionBaseURL } private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60 private static let dailyInterval: TimeInterval = 24 * 60 * 60 private static let maxPayloadBytes = 4096 private static let buildDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(identifier: "UTC") return f }() // MARK: Injected services @Injected() private var apsManager: APSManager! @Injected() private var fetchGlucoseManager: FetchGlucoseManager! @Injected() private var settingsManager: SettingsManager! @Injected() private var tidepoolManager: TidepoolManager! @Injected() private var healthKitManager: HealthKitManager! @Injected() private var keychain: Keychain! private let lock = NSRecursiveLock() private var didInjectServices = false private var timer: DispatchTimer? private init() {} private func injectIfNeeded() { lock.lock() defer { lock.unlock() } guard !didInjectServices else { return } injectServices(TrioApp.resolver) didInjectServices = true } // MARK: - Cold launches /// Records a cold launch in a sliding 7-day window of timestamps. The count /// of entries in the window ships as `coldLaunches7d` in every ping — a /// "how often does iOS recycle this process" signal that is directly /// comparable across pings regardless of the cadence between them. func recordColdLaunch(now: Date = Date()) { let cutoff = now.addingTimeInterval(-Self.weeklyInterval) var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? [] recent.removeAll { $0 < cutoff } recent.append(now) PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent } // MARK: - Install identifier /// Stable per-install UUID, generated lazily on first call. IDFV resets if /// the user deletes every Trio-team app at once; this survives /// independently and is wiped only by deleting Trio itself. private func installId() -> String { if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty { return existing } let new = UUID().uuidString PropertyPersistentFlags.shared.telemetryInstallId = new return new } // MARK: - Cadence /// True when the running build's commit SHA differs from the SHA recorded /// at the last successful send. Used at startup to fire one immediate ping /// after an app update — the 24h scheduler can't notice a build change and /// would otherwise wait out the previous interval. func buildShaChangedSinceLastSend() -> Bool { let currentSha = BuildDetails.shared.trioCommitSHA return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha } /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without /// scheduling if the user hasn't decided on consent yet or has opted out /// — there's nothing for the timer to do. /// /// Best-effort fallback only. GCD timers don't advance while the app is /// suspended, so on iOS this effectively means "fires only if the app /// stays foregrounded for 24h." The reliable cadence driver is /// `checkAndSendIfOverdue()` called on every foreground transition and /// cold launch. func scheduleRecurring() { guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true, PropertyPersistentFlags.shared.telemetryEnabled == true else { return } lock.lock() defer { lock.unlock() } if timer == nil { let t = DispatchTimer(timeInterval: Self.dailyInterval) t.eventHandler = { [weak self] in Task.detached { await self?.maybeSend() } } t.resume() timer = t } } /// If consent is set and we haven't successfully sent within the last 24h /// (or have never sent), fire a send. Called on foreground transitions /// and from the cold-launch path so daily cadence is kept. /// /// Mirrors the pattern used by LoopFollow's `TaskScheduler.checkTasksNow()`: /// wall-clock comparison against `telemetryLastSentAt`, fire-and-forget /// if overdue. Safe to call repeatedly — if a send already fired within /// the window, this is a no-op. func checkAndSendIfOverdue() { guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true, PropertyPersistentFlags.shared.telemetryEnabled == true else { return } let lastSent = PropertyPersistentFlags.shared.telemetryLastSentAt let overdue: Bool = { guard let lastSent else { return true } return Date().timeIntervalSince(lastSent) >= Self.dailyInterval }() guard overdue else { return } Task.detached { await self.maybeSend() } } /// Single entry point for all sends (scheduler tick, consent-yes, startup /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's /// decision — startup handles the SHA-change shortcut, the timer handles /// 24h cadence. func maybeSend() async { guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true, PropertyPersistentFlags.shared.telemetryEnabled == true else { return } await send() } // MARK: - Payload /// The exact payload that would be POSTed right now. Pure function: shared /// by `send()` and `TelemetryPreviewView`. func buildPayload() -> [String: Any] { injectIfNeeded() let bd = BuildDetails.shared let info = Bundle.main.infoDictionary ?? [:] var payload: [String: Any] = [:] if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v } payload["appName"] = TelemetryClient.telemetryAppName // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") — // the most precise build identifier we have. Always emit, even when // the Info.plist key is missing, so dashboards can rely on the field. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown" payload["commitSha"] = bd.trioCommitSHA payload["branch"] = bd.trioBranch // Date-only (yyyy-MM-dd, UTC) build identifier, parsed from the // "Tue May 26 12:34:56 UTC 2025" form added in BuildDetails.plist. if let date = bd.buildDate() { payload["buildDate"] = Self.buildDateFormatter.string(from: date) } payload["isTestFlight"] = bd.isTestFlightBuild() if let idfv = UIDevice.current.identifierForVendor?.uuidString { payload["idfv"] = idfv } payload["installId"] = installId() payload["device"] = Self.hardwareIdentifier() payload["platform"] = Self.detectPlatform() payload["osVersion"] = UIDevice.current.systemVersion payload["locale"] = Locale.current.identifier payload["timeZone"] = TimeZone.current.identifier // Pump model — omitted entirely when no pump is paired. if let pump = apsManager?.pumpManager { payload["pumpModel"] = pump.localizedTitle } // CGM: enum tells us the configured *type*; the live manager (if any) // tells us the specific model name. Both are useful — `cgmType` // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc. let settings = settingsManager?.settings payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue if let cgm = fetchGlucoseManager?.cgmManager { payload["cgmModel"] = cgm.localizedTitle } // Nightscout: keys present in keychain ⇒ configured. We never include // the URL or token themselves. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil // Apple Health: report `enabled = true` as soon as *any* per-type write // permission is granted, with the full per-type breakdown in // `appleHealthWrites`. let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [ ("glucose", AppleHealthConfig.healthBGObject), ("insulin", AppleHealthConfig.healthInsulinObject), ("carbs", AppleHealthConfig.healthCarbObject), ("fat", AppleHealthConfig.healthFatObject), ("protein", AppleHealthConfig.healthProteinObject) ] var writePermissions: [String: Bool] = [:] for (name, type) in appleHealthSampleTypes { let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false writePermissions[name] = granted } payload["appleHealthEnabled"] = writePermissions.values.contains(true) if !writePermissions.isEmpty { payload["appleHealthWrites"] = writePermissions } if let settings = settings { payload["closedLoop"] = settings.closedLoop payload["units"] = settings.units.rawValue payload["useLiveActivity"] = settings.useLiveActivity payload["useCalendar"] = settings.useCalendar } payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE / // etc. revision the user is on. Branch is dropped to keep payload size small. let submoduleShas = bd.submodules.mapValues { $0.commitSHA } if !submoduleShas.isEmpty { payload["submodules"] = submoduleShas } return payload } // MARK: - Send /// Build payload, attest it via App Attest, POST it, update last-sent state /// on 2xx. Fire-and-forget; errors are logged at debug level only. /// /// Flow: /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older /// devices). This is the primary opt-out for unsupported hardware — /// sending without attestation would just bounce off the server. /// 2. Skip if the install has been flagged forbidden by a previous 403. /// 3. Register if needed (idempotent; first launch + once on retry after /// transient failures). /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap). /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`. /// 6. POST `/checkin` with the three App Attest headers. /// /// Backoff: failures don't update `telemetryLastSentAt`, so the next /// scheduler tick / cold launch retries naturally. The 24h cadence is the /// natural backoff floor; no per-attempt exponential timer is added. func send() async { guard let baseURL = Self.baseURL else { debug(.telemetry, "skip send: server URL not configured") return } let attestor = TelemetryAttestor.shared guard attestor.isSupported else { debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)") return } guard !attestor.isForbidden else { debug(.telemetry, "skip send: app_id previously rejected (403)") return } do { try await attestor.registerIfNeeded(baseURL: baseURL) } catch TelemetryAttestor.AttestError.forbidden { // Already logged + sticky-flagged in registerIfNeeded. return } catch { debug(.telemetry, "register failed: \(error) — will retry next cycle") return } let payload = buildPayload() guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { debug(.telemetry, "skip send: payload not JSON-serializable") return } guard body.count <= Self.maxPayloadBytes else { debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))") return } let assertion: (assertion: String, keyID: String, challenge: String) do { assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL) } catch { debug(.telemetry, "assertion failed: \(error)") return } var request = URLRequest(url: baseURL.appendingPathComponent("checkin")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId") request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion") request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge") request.httpBody = body request.timeoutInterval = 15 do { let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { debug(.telemetry, "send: non-HTTP response") return } switch http.statusCode { case 200 ..< 300: PropertyPersistentFlags.shared.telemetryLastSentAt = Date() PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA debug(.telemetry, "send ok status=\(http.statusCode)") case 401: // Server doesn't recognize our registration (e.g. its registry // was wiped). Drop the local keyID + registered flag so the // next cycle generates a fresh key and re-attests — `attestKey` // can't be re-run on the existing keyID (one-shot per Apple). attestor.invalidateRegistration() debug(.telemetry, "send 401: stale registration, will re-register next cycle") default: debug(.telemetry, "send non-2xx status=\(http.statusCode)") } } catch { debug(.telemetry, "send error: \(error.localizedDescription)") } } // MARK: - Helpers /// `iPhone15,2`-style identifier from `utsname.machine`. Returns /// `Simulator ` on the simulator so analysis /// can ignore those rows. static func hardwareIdentifier() -> String { #if targetEnvironment(simulator) let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown" return "Simulator \(env)" #else var sys = utsname() uname(&sys) let mirror = Mirror(reflecting: sys.machine) let machine = mirror.children.reduce(into: "") { acc, child in guard let v = child.value as? Int8, v != 0 else { return } acc.append(Character(UnicodeScalar(UInt8(v)))) } return machine.isEmpty ? "Unknown" : machine #endif } static func detectPlatform() -> String { #if targetEnvironment(macCatalyst) return "macCatalyst" #else switch UIDevice.current.userInterfaceIdiom { case .pad: return "iPadOS" default: return "iOS" } #endif } }