| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- 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")
- /// 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
- // 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.
- 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
- }
- }
- /// 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 }
- // 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 prefix of the build-date string. Keeps the field a
- // low-resolution build identifier, not a precise timestamp.
- if let raw = bd.buildDateString, raw.count >= 10 {
- payload["buildDate"] = String(raw.prefix(10))
- }
- 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
- // 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 <SIMULATOR_MODEL_IDENTIFIER>` 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
- }
- }
|