| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import Foundation
- 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
- // TODO: Replace with the production telemetry endpoint
- // and bearer token. While these placeholders remain, `send()` no-ops at
- // debug-log level — consent, persistence, scheduling, and the UI work
- // unchanged for testing against any mock server.
- private static let endpoint: URL? = nil
- private static let writeToken = ""
- private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
- private static let dailyInterval: TimeInterval = 24 * 60 * 60
- private static let retryAfterFailureInterval: TimeInterval = 60
- // 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
- let useHealth = settings?.useAppleHealth ?? false
- let healthAuthorized = healthKitManager?.hasGrantedFullWritePermissions ?? false
- payload["appleHealthEnabled"] = useHealth && healthAuthorized
- 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, POST it, update last-sent state on 2xx. Fire-and-forget;
- /// errors are logged at debug level only and never surfaced to the UI.
- func send() async {
- guard let endpoint = Self.endpoint else {
- debug(.telemetry, "skip send: endpoint not configured (TODO)") // FIXME: adjust debug statement once backend is set up
- return
- }
- let payload = buildPayload()
- guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
- debug(.telemetry, "skip send: payload not JSON-serializable")
- return
- }
- var request = URLRequest(url: endpoint)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- if !Self.writeToken.isEmpty {
- request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization")
- }
- 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
- }
- if (200 ..< 300).contains(http.statusCode) {
- PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
- PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
- debug(.telemetry, "send ok status=\(http.statusCode)")
- } else {
- 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
- }
- }
|