TelemetryClient.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import Foundation
  2. import HealthKit
  3. import LoopKit
  4. import Swinject
  5. import UIKit
  6. // MARK: - TelemetryClient
  7. /// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted
  8. /// endpoint at most once every 24 hours, plus once after a new build is installed.
  9. /// Consent is collected during onboarding (or via a one-time migration sheet for
  10. /// existing users) and editable in Settings → App Diagnostics.
  11. ///
  12. /// No health data, credentials, or personally-identifying information is sent.
  13. /// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView`
  14. /// for the in-app inspector that renders the same payload.
  15. final class TelemetryClient: Injectable {
  16. static let shared = TelemetryClient()
  17. // MARK: Endpoint configuration
  18. private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
  19. /// Effective base URL: respects the debug override in
  20. /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
  21. /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
  22. private static var baseURL: URL? {
  23. if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
  24. .trimmingCharacters(in: .whitespacesAndNewlines),
  25. !override.isEmpty,
  26. let url = URL(string: override)
  27. {
  28. return url
  29. }
  30. return productionBaseURL
  31. }
  32. private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
  33. private static let dailyInterval: TimeInterval = 24 * 60 * 60
  34. private static let maxPayloadBytes = 4096
  35. // MARK: Injected services
  36. @Injected() private var apsManager: APSManager!
  37. @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
  38. @Injected() private var settingsManager: SettingsManager!
  39. @Injected() private var tidepoolManager: TidepoolManager!
  40. @Injected() private var healthKitManager: HealthKitManager!
  41. @Injected() private var keychain: Keychain!
  42. private let lock = NSRecursiveLock()
  43. private var didInjectServices = false
  44. private var timer: DispatchTimer?
  45. private init() {}
  46. private func injectIfNeeded() {
  47. lock.lock()
  48. defer { lock.unlock() }
  49. guard !didInjectServices else { return }
  50. injectServices(TrioApp.resolver)
  51. didInjectServices = true
  52. }
  53. // MARK: - Cold launches
  54. /// Records a cold launch in a sliding 7-day window of timestamps. The count
  55. /// of entries in the window ships as `coldLaunches7d` in every ping — a
  56. /// "how often does iOS recycle this process" signal that is directly
  57. /// comparable across pings regardless of the cadence between them.
  58. func recordColdLaunch(now: Date = Date()) {
  59. let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
  60. var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
  61. recent.removeAll { $0 < cutoff }
  62. recent.append(now)
  63. PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
  64. }
  65. // MARK: - Install identifier
  66. /// Stable per-install UUID, generated lazily on first call. IDFV resets if
  67. /// the user deletes every Trio-team app at once; this survives
  68. /// independently and is wiped only by deleting Trio itself.
  69. private func installId() -> String {
  70. if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
  71. return existing
  72. }
  73. let new = UUID().uuidString
  74. PropertyPersistentFlags.shared.telemetryInstallId = new
  75. return new
  76. }
  77. // MARK: - Cadence
  78. /// True when the running build's commit SHA differs from the SHA recorded
  79. /// at the last successful send. Used at startup to fire one immediate ping
  80. /// after an app update — the 24h scheduler can't notice a build change and
  81. /// would otherwise wait out the previous interval.
  82. func buildShaChangedSinceLastSend() -> Bool {
  83. let currentSha = BuildDetails.shared.trioCommitSHA
  84. return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
  85. }
  86. /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
  87. /// scheduling if the user hasn't decided on consent yet or has opted out
  88. /// — there's nothing for the timer to do.
  89. func scheduleRecurring() {
  90. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  91. PropertyPersistentFlags.shared.telemetryEnabled == true
  92. else {
  93. return
  94. }
  95. lock.lock()
  96. defer { lock.unlock() }
  97. if timer == nil {
  98. let t = DispatchTimer(timeInterval: Self.dailyInterval)
  99. t.eventHandler = { [weak self] in
  100. Task.detached { await self?.maybeSend() }
  101. }
  102. t.resume()
  103. timer = t
  104. }
  105. }
  106. /// Single entry point for all sends (scheduler tick, consent-yes, startup
  107. /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
  108. /// decision — startup handles the SHA-change shortcut, the timer handles
  109. /// 24h cadence.
  110. func maybeSend() async {
  111. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  112. PropertyPersistentFlags.shared.telemetryEnabled == true
  113. else {
  114. return
  115. }
  116. await send()
  117. }
  118. // MARK: - Payload
  119. /// The exact payload that would be POSTed right now. Pure function: shared
  120. /// by `send()` and `TelemetryPreviewView`.
  121. func buildPayload() -> [String: Any] {
  122. injectIfNeeded()
  123. let bd = BuildDetails.shared
  124. let info = Bundle.main.infoDictionary ?? [:]
  125. var payload: [String: Any] = [:]
  126. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  127. // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
  128. // the most precise build identifier we have. Always emit, even when
  129. // the Info.plist key is missing, so dashboards can rely on the field.
  130. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
  131. payload["commitSha"] = bd.trioCommitSHA
  132. payload["branch"] = bd.trioBranch
  133. // Date-only prefix of the build-date string. Keeps the field a
  134. // low-resolution build identifier, not a precise timestamp.
  135. if let raw = bd.buildDateString, raw.count >= 10 {
  136. payload["buildDate"] = String(raw.prefix(10))
  137. }
  138. payload["isTestFlight"] = bd.isTestFlightBuild()
  139. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  140. payload["idfv"] = idfv
  141. }
  142. payload["installId"] = installId()
  143. payload["device"] = Self.hardwareIdentifier()
  144. payload["platform"] = Self.detectPlatform()
  145. payload["osVersion"] = UIDevice.current.systemVersion
  146. // Pump model — omitted entirely when no pump is paired.
  147. if let pump = apsManager?.pumpManager {
  148. payload["pumpModel"] = pump.localizedTitle
  149. }
  150. // CGM: enum tells us the configured *type*; the live manager (if any)
  151. // tells us the specific model name. Both are useful — `cgmType`
  152. // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
  153. let settings = settingsManager?.settings
  154. payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
  155. if let cgm = fetchGlucoseManager?.cgmManager {
  156. payload["cgmModel"] = cgm.localizedTitle
  157. }
  158. // Nightscout: keys present in keychain ⇒ configured. We never include
  159. // the URL or token themselves.
  160. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
  161. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  162. let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
  163. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  164. payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
  165. payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
  166. // Apple Health: report `enabled = true` as soon as *any* per-type write
  167. // permission is granted, with the full per-type breakdown in
  168. // `appleHealthWrites`.
  169. let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [
  170. ("glucose", AppleHealthConfig.healthBGObject),
  171. ("insulin", AppleHealthConfig.healthInsulinObject),
  172. ("carbs", AppleHealthConfig.healthCarbObject),
  173. ("fat", AppleHealthConfig.healthFatObject),
  174. ("protein", AppleHealthConfig.healthProteinObject)
  175. ]
  176. var writePermissions: [String: Bool] = [:]
  177. for (name, type) in appleHealthSampleTypes {
  178. let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
  179. writePermissions[name] = granted
  180. }
  181. payload["appleHealthEnabled"] = writePermissions.values.contains(true)
  182. if !writePermissions.isEmpty {
  183. payload["appleHealthWrites"] = writePermissions
  184. }
  185. if let settings = settings {
  186. payload["closedLoop"] = settings.closedLoop
  187. payload["units"] = settings.units.rawValue
  188. payload["useLiveActivity"] = settings.useLiveActivity
  189. payload["useCalendar"] = settings.useCalendar
  190. }
  191. payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
  192. // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
  193. // etc. revision the user is on. Branch is dropped to keep payload size small.
  194. let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
  195. if !submoduleShas.isEmpty {
  196. payload["submodules"] = submoduleShas
  197. }
  198. return payload
  199. }
  200. // MARK: - Send
  201. /// Build payload, attest it via App Attest, POST it, update last-sent state
  202. /// on 2xx. Fire-and-forget; errors are logged at debug level only.
  203. ///
  204. /// Flow:
  205. /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
  206. /// devices). This is the primary opt-out for unsupported hardware —
  207. /// sending without attestation would just bounce off the server.
  208. /// 2. Skip if the install has been flagged forbidden by a previous 403.
  209. /// 3. Register if needed (idempotent; first launch + once on retry after
  210. /// transient failures).
  211. /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
  212. /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
  213. /// 6. POST `/checkin` with the three App Attest headers.
  214. ///
  215. /// Backoff: failures don't update `telemetryLastSentAt`, so the next
  216. /// scheduler tick / cold launch retries naturally. The 24h cadence is the
  217. /// natural backoff floor; no per-attempt exponential timer is added.
  218. func send() async {
  219. guard let baseURL = Self.baseURL else {
  220. debug(.telemetry, "skip send: server URL not configured")
  221. return
  222. }
  223. let attestor = TelemetryAttestor.shared
  224. guard attestor.isSupported else {
  225. debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
  226. return
  227. }
  228. guard !attestor.isForbidden else {
  229. debug(.telemetry, "skip send: app_id previously rejected (403)")
  230. return
  231. }
  232. do {
  233. try await attestor.registerIfNeeded(baseURL: baseURL)
  234. } catch TelemetryAttestor.AttestError.forbidden {
  235. // Already logged + sticky-flagged in registerIfNeeded.
  236. return
  237. } catch {
  238. debug(.telemetry, "register failed: \(error) — will retry next cycle")
  239. return
  240. }
  241. let payload = buildPayload()
  242. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  243. debug(.telemetry, "skip send: payload not JSON-serializable")
  244. return
  245. }
  246. guard body.count <= Self.maxPayloadBytes else {
  247. debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
  248. return
  249. }
  250. let assertion: (assertion: String, keyID: String, challenge: String)
  251. do {
  252. assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
  253. } catch {
  254. debug(.telemetry, "assertion failed: \(error)")
  255. return
  256. }
  257. var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
  258. request.httpMethod = "POST"
  259. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  260. request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
  261. request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
  262. request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
  263. request.httpBody = body
  264. request.timeoutInterval = 15
  265. do {
  266. let (_, response) = try await URLSession.shared.data(for: request)
  267. guard let http = response as? HTTPURLResponse else {
  268. debug(.telemetry, "send: non-HTTP response")
  269. return
  270. }
  271. switch http.statusCode {
  272. case 200 ..< 300:
  273. PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
  274. PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
  275. debug(.telemetry, "send ok status=\(http.statusCode)")
  276. case 401:
  277. // Server doesn't recognize our registration (e.g. its registry
  278. // was wiped). Drop the local keyID + registered flag so the
  279. // next cycle generates a fresh key and re-attests — `attestKey`
  280. // can't be re-run on the existing keyID (one-shot per Apple).
  281. attestor.invalidateRegistration()
  282. debug(.telemetry, "send 401: stale registration, will re-register next cycle")
  283. default:
  284. debug(.telemetry, "send non-2xx status=\(http.statusCode)")
  285. }
  286. } catch {
  287. debug(.telemetry, "send error: \(error.localizedDescription)")
  288. }
  289. }
  290. // MARK: - Helpers
  291. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  292. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  293. /// can ignore those rows.
  294. static func hardwareIdentifier() -> String {
  295. #if targetEnvironment(simulator)
  296. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  297. return "Simulator \(env)"
  298. #else
  299. var sys = utsname()
  300. uname(&sys)
  301. let mirror = Mirror(reflecting: sys.machine)
  302. let machine = mirror.children.reduce(into: "") { acc, child in
  303. guard let v = child.value as? Int8, v != 0 else { return }
  304. acc.append(Character(UnicodeScalar(UInt8(v))))
  305. }
  306. return machine.isEmpty ? "Unknown" : machine
  307. #endif
  308. }
  309. static func detectPlatform() -> String {
  310. #if targetEnvironment(macCatalyst)
  311. return "macCatalyst"
  312. #else
  313. switch UIDevice.current.userInterfaceIdiom {
  314. case .pad: return "iPadOS"
  315. default: return "iOS"
  316. }
  317. #endif
  318. }
  319. }