|
|
@@ -0,0 +1,287 @@
|
|
|
+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
|
|
|
+ }
|
|
|
+}
|