Pārlūkot izejas kodu

Merge branch 'feat/telemetry' of github.com:nightscout/Trio into local-test-telemetry

Deniz Cengiz 23 stundas atpakaļ
vecāks
revīzija
74cd9a0123

+ 63 - 2
PRIVACY_POLICY.md

@@ -34,6 +34,60 @@ The following information may be sent to Crashlytics when Trio crashes:
 - Device model and OS version (example: "iPhone 14 Pro running iOS 17.4.1")
 - A generated unique identifier (a random code like "A7B2C9D3" that doesn't identify you personally)
 
+### Anonymous Usage Telemetry (Opt-In by default, with ability to Opt-Out)
+
+Trio can periodically send a small anonymous usage report to a
+self-hosted telemetry endpoint operated by the Trio team. No
+third-party analytics service is involved. You are asked about this
+choice during onboarding (alongside crash reporting); existing users
+upgrading from a pre-telemetry build are prompted once on the first
+app launch after the update. You can change your choice at any time
+in Settings → App Diagnostics, and you can inspect the exact JSON
+that would be sent under "What's sent" on that same screen.
+
+Telemetry requests are authenticated with Apple App Attest. This
+means Apple cryptographically vouches for the fact that the request
+came from a genuine, unmodified copy of Trio running on a real
+Apple device. App Attest does not transmit any personal data,
+device identifiers, or location information; it produces a one-way
+attestation that the server validates with Apple. Devices that do
+not support App Attest (e.g. the iOS Simulator) silently skip
+sending telemetry.
+
+The diagnostics-sharing selection offers three options:
+
+- **Enable Full Sharing** — crash reports AND anonymous usage telemetry.
+- **Crash Reports Only** — crash reports, no usage telemetry.
+- **Disable Sharing** — neither.
+
+The following information is included in the telemetry payload:
+
+- App version, build date, branch, and commit SHA
+- Whether the build is a TestFlight or App Store / sideload build
+- An Apple-supplied per-vendor identifier (IDFV) and a per-install UUID
+- Device hardware identifier (e.g. "iPhone15,2"), platform, and iOS version
+- The paired pump model (when a pump is configured)
+- The paired CGM type and model (when a CGM is configured)
+- Whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs, tokens, or credentials)
+- A small set of preference flags: units (mg/dL or mmol/L), closed-loop
+  on/off, Live Activity enabled, calendar integration enabled
+- A rolling 7-day count of how often the app was cold-launched
+- The commit SHAs of pinned submodules (e.g. LoopKit, OmniBLE)
+
+The payload sends once every 24 hours while the app is running, plus
+once after a new build is installed. Sending failures simply retry on
+the next launch or scheduler tick — there is no continued retry.
+
+### What Telemetry Does NOT Include
+
+- Glucose readings, insulin doses, carb entries, or any therapy data
+- Therapy settings (basal rates, ISF, carb ratio, glucose targets, max bolus, max basal)
+- Your Nightscout URL or API token
+- Your Tidepool email, password, or session token
+- Remote-command secrets or APNS keys
+- Time zone or location
+- App logs — log sharing remains a separate, user-initiated flow under Settings
+
 ### Debug Symbols (dSYMs)
 
 When we build the Trio app, we create special files called debug
@@ -77,12 +131,19 @@ and handle any data responsibly.
 
 ## Opting Out and Data Retention
 
-You can opt out of crash reporting at any time through the Trio
-settings. If you opt out:
+You can opt out of crash reporting and/or anonymous usage telemetry
+at any time through Settings → App Diagnostics in Trio. The three
+options ("Enable Full Sharing", "Crash Reports Only", "Disable
+Sharing") apply to both data streams. If you opt out of crash
+reporting:
 
 - No new crash data will be collected or sent to us
 - Previously collected crash data will still be retained for approximately 90 days
 
+If you opt out of anonymous usage telemetry, no new telemetry data
+will be collected or sent. Previously sent telemetry rows are retained
+on the Trio team's telemetry endpoint per its own retention policy.
+
 To avoid sending dSYMs to Crashlytics, you can delete the Trio target
 Build Phase script, titled "Copy dSYMs to Crashlytics".
 

+ 56 - 0
Trio.xcodeproj/project.pbxproj

@@ -693,6 +693,14 @@
 		DD758EDE2ECC656500EF5D54 /* DetermineBasalSmbMicroBolusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD758EDD2ECC656500EF5D54 /* DetermineBasalSmbMicroBolusTests.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
+		DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000001 /* TelemetryClient.swift */; };
+		DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000013 /* TelemetryAttestor.swift */; };
+		DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000003 /* TelemetryPreviewView.swift */; };
+		DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */; };
+		DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */; };
+		DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000D /* TelemetryDataFlow.swift */; };
+		DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E30000000000000000F /* TelemetryProvider.swift */; };
+		DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000011 /* TelemetryStateModel.swift */; };
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
@@ -1627,6 +1635,14 @@
 		DD758EDD2ECC656500EF5D54 /* DetermineBasalSmbMicroBolusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalSmbMicroBolusTests.swift; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000001 /* TelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryClient.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000013 /* TelemetryAttestor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryAttestor.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000003 /* TelemetryPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPreviewView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPrivacyView.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryMigrationSheetView.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000D /* TelemetryDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryDataFlow.swift; sourceTree = "<group>"; };
+		DD7E1E30000000000000000F /* TelemetryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryProvider.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000011 /* TelemetryStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryStateModel.swift; sourceTree = "<group>"; };
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
@@ -2105,6 +2121,7 @@
 				DDD163032C4C67B400CD525A /* Adjustments */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */,
+				DD7E1E30000000000000000B /* Telemetry */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */,
 				3811DE0425C9D32E00A708ED /* Base */,
@@ -2271,6 +2288,7 @@
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
+				DD7E1E30000000000000000A /* Telemetry */,
 				3811DEA525C9D88300A708ED /* UnlockManager */,
 				38E87406274F9AA500975559 /* UserNotifications */,
 				38E8754D275556E100975559 /* WatchManager */,
@@ -2278,6 +2296,36 @@
 			path = Services;
 			sourceTree = "<group>";
 		};
+		DD7E1E30000000000000000A /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000013 /* TelemetryAttestor.swift */,
+				DD7E1E300000000000000001 /* TelemetryClient.swift */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000B /* Telemetry */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E30000000000000000D /* TelemetryDataFlow.swift */,
+				DD7E1E30000000000000000F /* TelemetryProvider.swift */,
+				DD7E1E300000000000000011 /* TelemetryStateModel.swift */,
+				DD7E1E30000000000000000C /* View */,
+			);
+			path = Telemetry;
+			sourceTree = "<group>";
+		};
+		DD7E1E30000000000000000C /* View */ = {
+			isa = PBXGroup;
+			children = (
+				DD7E1E300000000000000003 /* TelemetryPreviewView.swift */,
+				DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */,
+				DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		3811DE9225C9D88200A708ED /* Appearance */ = {
 			isa = PBXGroup;
 			children = (
@@ -4698,6 +4746,14 @@
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
+				DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */,
+				DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */,
+				DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */,
+				DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */,
+				DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */,
+				DD7E1E30000000000000000E /* TelemetryDataFlow.swift in Sources */,
+				DD7E1E300000000000000010 /* TelemetryProvider.swift in Sources */,
+				DD7E1E300000000000000012 /* TelemetryStateModel.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,

+ 12 - 0
Trio/Sources/Application/AppDelegate.swift

@@ -20,6 +20,18 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
         Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashReportingEnabled)
         Crashlytics.crashlytics().setCustomValue(Bundle.main.appDevVersion ?? "unknown", forKey: "app_dev_version")
 
+        // Telemetry: record this cold launch into the sliding 7-day window. If
+        // consent is set and the build SHA changed since the last successful
+        // send, fire an immediate ping — the 24h scheduler can't notice a
+        // build update on its own. Then arm the recurring 24h timer.
+        TelemetryClient.shared.recordColdLaunch()
+        Task.detached {
+            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                await TelemetryClient.shared.maybeSend()
+            }
+            TelemetryClient.shared.scheduleRecurring()
+        }
+
         return true
     }
 

+ 30 - 0
Trio/Sources/Application/TrioApp.swift

@@ -40,6 +40,11 @@ extension Notification.Name {
     @State private var showOnboardingCompletedSplash = false
     @State private var showMigrationError: Bool = false
 
+    // Telemetry: one-shot guard so the consent migration sheet is presented
+    // at most once per process even if scene activates repeatedly.
+    @State private var showTelemetryMigrationSheet = false
+    @State private var hasCheckedTelemetryMigration = false
+
     // Dependencies Assembler
     // contain all dependencies Assemblies
     // TODO: Remove static key after update "Use Dependencies" logic
@@ -340,6 +345,10 @@ extension Notification.Name {
                     self.showOnboardingCompletedSplash = true
                 }
             }
+            .sheet(isPresented: $showTelemetryMigrationSheet) {
+                TelemetryMigrationSheetView()
+                    .interactiveDismissDisabled(true)
+            }
         }
         .onChange(of: scenePhase) { _, newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")
@@ -358,10 +367,31 @@ extension Notification.Name {
                 if initState.complete {
                     performCleanupIfNecessary()
                 }
+                presentTelemetryMigrationSheetIfNeeded()
             }
         }
     }
 
+    /// Presents the one-time telemetry consent sheet for users who completed
+    /// onboarding before telemetry existed. The condition (`onboardingCompleted
+    /// == true` and no telemetry decision yet) is checked once per process —
+    /// the in-app dismiss handler sets `telemetryConsentDecisionMade`, so a
+    /// re-foreground after the user picks will no longer match.
+    private func presentTelemetryMigrationSheetIfNeeded() {
+        guard !hasCheckedTelemetryMigration else { return }
+        hasCheckedTelemetryMigration = true
+
+        let onboarded = PropertyPersistentFlags.shared.onboardingCompleted == true
+        let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+        guard onboarded, !telemetryDecided else { return }
+
+        // Defer one runloop so SwiftUI has finished settling on whatever root
+        // view was just shown (loading screen, splash, main view).
+        DispatchQueue.main.async {
+            showTelemetryMigrationSheet = true
+        }
+    }
+
     func configureTabBarAppearance() {
         let appearance = UITabBarAppearance()
         appearance.configureWithDefaultBackground()

+ 26 - 0
Trio/Sources/Helpers/PropertyPersistentFlags.swift

@@ -26,4 +26,30 @@ final class PropertyPersistentFlags {
 
     // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
     @PersistedProperty(key: "hasSeenFatProteinOrderChange") var hasSeenFatProteinOrderChange: Bool?
+
+    // MARK: - Telemetry
+
+    //
+    // See Trio/Sources/Services/Telemetry/TelemetryClient.swift.
+    // `telemetryEnabled` gates the anonymous-usage POST. `diagnosticsSharingEnabled`
+    // remains the Crashlytics gate. Both flags `nil` means the user has not yet
+    // chosen — used to surface the one-time migration sheet to existing users.
+    @PersistedProperty(key: "telemetryEnabled") var telemetryEnabled: Bool?
+    @PersistedProperty(key: "telemetryConsentDecisionMade") var telemetryConsentDecisionMade: Bool?
+    @PersistedProperty(key: "telemetryLastSentAt") var telemetryLastSentAt: Date?
+    @PersistedProperty(key: "telemetryLastSentSha") var telemetryLastSentSha: String?
+    // Sliding 7-day window of cold-launch timestamps; count is sent as `coldLaunches7d`.
+    @PersistedProperty(key: "telemetryColdLaunchTimes") var telemetryColdLaunchTimes: [Date]?
+    // Stable per-install UUID. IDFV resets when the user removes all Trio-team apps;
+    // this survives independently and is wiped only by deleting Trio itself.
+    @PersistedProperty(key: "telemetryInstallId") var telemetryInstallId: String?
+
+    // App Attest "give up" signal — set on a 403 from /api/attest/register, meaning
+    // the server has rejected this app_id and there's no point retrying.
+    @PersistedProperty(key: "telemetryAttestForbidden") var telemetryAttestForbidden: Bool?
+
+    // Debug override for the telemetry server base URL. Empty/unset → use the
+    // production constant in TelemetryClient. Surfaced as a hidden field in
+    // App Diagnostics for local testing against a dev server.
+    @PersistedProperty(key: "telemetryDebugServerURL") var telemetryDebugServerURL: String?
 }

+ 9 - 1
Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift

@@ -123,7 +123,15 @@ enum FileProtectionFixer {
             "onboardingCompleted.plist",
             "diagnosticsSharing.plist",
             "lastCleanupDate.plist",
-            "hasSeenFatProteinOrderChange.plist"
+            "hasSeenFatProteinOrderChange.plist",
+            "telemetryEnabled.plist",
+            "telemetryConsentDecisionMade.plist",
+            "telemetryLastSentAt.plist",
+            "telemetryLastSentSha.plist",
+            "telemetryColdLaunchTimes.plist",
+            "telemetryInstallId.plist",
+            "telemetryAttestForbidden.plist",
+            "telemetryDebugServerURL.plist"
         ]
 
         let fileManager = FileManager.default

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 115 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 4 - 0
Trio/Sources/Logger/Logger.swift

@@ -140,6 +140,7 @@ final class Logger {
     static let watchManager = Logger(category: .watchManager, reporter: baseReporter)
     static let coreData = Logger(category: .coreData, reporter: baseReporter)
     static let storage = Logger(category: .storage, reporter: baseReporter)
+    static let telemetry = Logger(category: .telemetry, reporter: baseReporter)
 
     enum Category: String {
         case `default`
@@ -154,6 +155,7 @@ final class Logger {
         case watchManager
         case coreData
         case storage
+        case telemetry
 
         var name: String {
             rawValue.capitalizingFirstLetter()
@@ -173,6 +175,7 @@ final class Logger {
             case .watchManager: return .watchManager
             case .coreData: return .coreData
             case .storage: return .storage
+            case .telemetry: return .telemetry
             }
         }
 
@@ -190,6 +193,7 @@ final class Logger {
                  .remoteControl,
                  .service,
                  .storage,
+                 .telemetry,
                  .watchManager:
                 return OSLog(subsystem: subsystem, category: name)
             }

+ 30 - 11
Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift

@@ -6,26 +6,45 @@ extension AppDiagnostics {
     @Observable final class StateModel: BaseStateModel<Provider> {
         // MARK: - Diagnostics Sharing Option
 
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
 
         override func subscribe() {
             loadDiagnostics()
         }
 
-        /// Loads the diagnostics sharing option from UserDefaults as a boolean.
+        /// Derives the 3-state option from the two underlying flags. Defaults
+        /// to `.full` for fresh installs (opt-out). For pre-telemetry users
+        /// who have Crashlytics on but haven't seen the migration sheet, we
+        /// surface `.crashOnly` until they pick — never auto-upgrade to
+        /// `.full` without an explicit decision.
         func loadDiagnostics() {
-            if let storedDiagnosticsSharingOption = PropertyPersistentFlags.shared.diagnosticsSharingEnabled {
-                diagnosticsSharingOption = storedDiagnosticsSharingOption ? .enabled : .disabled
-            } else {
-                diagnosticsSharingOption = .enabled
-            }
+            let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+            let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
+            let telemetry = telemetryDecided
+                ? (PropertyPersistentFlags.shared.telemetryEnabled ?? false)
+                : false
+            diagnosticsSharingOption = DiagnosticsSharingOption(
+                crashlyticsEnabled: crashlytics,
+                telemetryEnabled: telemetry
+            )
         }
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option to both underlying flags
+        /// and applies it to Crashlytics + the telemetry sender.
         func applyDiagnostics() {
-            let booleanValue: Bool = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+
+            // Fire an inaugural send on a fresh opt-in so the first data point
+            // arrives at the moment of consent rather than 24h later.
+            if diagnosticsSharingOption.telemetryEnabled, !wasTelemetryOn {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
     }
 }

+ 19 - 8
Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift

@@ -21,7 +21,7 @@ extension AppDiagnostics {
                                 Button(action: {
                                     state.diagnosticsSharingOption = option
                                 }) {
-                                    HStack {
+                                    HStack(alignment: .top, spacing: 12) {
                                         Image(
                                             systemName: state
                                                 .diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle"
@@ -29,8 +29,14 @@ extension AppDiagnostics {
                                         .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                                         .imageScale(.large)
 
-                                        Text(option.displayName)
-                                            .foregroundColor(.primary)
+                                        VStack(alignment: .leading, spacing: 4) {
+                                            Text(option.displayName)
+                                                .foregroundColor(.primary)
+                                                .bold()
+                                            Text(option.caption)
+                                                .font(.footnote)
+                                                .foregroundColor(.secondary)
+                                        }
 
                                         Spacer()
                                     }
@@ -48,32 +54,37 @@ extension AppDiagnostics {
                 ).listRowBackground(Color.chart)
 
                 Section {
+                    NavigationLink("What's sent") { TelemetryPreviewView() }
+                    NavigationLink("Privacy details") { TelemetryPrivacyView() }
+                }.listRowBackground(Color.chart)
+
+                Section {
                     VStack(alignment: .leading, spacing: 8) {
                         Text("Why does Trio collect this data?").bold()
                         VStack(alignment: .leading, spacing: 4) {
                             BulletPoint(
                                 String(
-                                    localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                                    localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                                    localized: "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                                    localized: "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint."
                                 )
                             )
                             BulletPoint(
                                 String(
-                                    localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                                    localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar."
                                 )
                             )
                         }
                         Text(
-                            "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
+                            "Use \"What's sent\" above to inspect the exact JSON payload before deciding."
                         )
                     }
                     .font(.footnote)

+ 27 - 12
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -26,21 +26,31 @@ extension Onboarding {
 
         // MARK: - App Diagnostics
 
-        private var persistedDiagnosticsSharing: Bool? {
-            get { PropertyPersistentFlags.shared.diagnosticsSharingEnabled }
-            set { PropertyPersistentFlags.shared.diagnosticsSharingEnabled = newValue }
-        }
-
-        var diagnosticsSharingOption: DiagnosticsSharingOption = .enabled
+        var diagnosticsSharingOption: DiagnosticsSharingOption = .full
         var hasAcceptedPrivacyPolicy: Bool = false
 
         func syncDiagnosticsOptionFromStorage() {
-            diagnosticsSharingOption = (persistedDiagnosticsSharing ?? true) ? .enabled : .disabled
+            // Onboarding *is* the consent decision point, so a fresh install
+            // sees `.full` (truly opt-out). If the user has already picked
+            // something — e.g. backed out of this step and returned — restore
+            // their saved selection so they see their current choice.
+            if PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true {
+                let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
+                let telemetry = PropertyPersistentFlags.shared.telemetryEnabled ?? false
+                diagnosticsSharingOption = DiagnosticsSharingOption(
+                    crashlyticsEnabled: crashlytics,
+                    telemetryEnabled: telemetry
+                )
+            } else {
+                diagnosticsSharingOption = .full
+            }
         }
 
         func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
             diagnosticsSharingOption = option
-            persistedDiagnosticsSharing = (option == .enabled)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = option.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = option.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
         }
 
         // MARK: - Determine Initial Build State
@@ -695,11 +705,16 @@ extension Onboarding {
             saveISFValues()
         }
 
-        /// Persists the current diagnostics sharing option to UserDefaults as a boolean.
+        /// Persists the current diagnostics sharing option and applies it to Crashlytics + telemetry.
         func applyDiagnostics() {
-            let booleanValue = diagnosticsSharingOption == .enabled
-            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = booleanValue
-            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(booleanValue)
+            PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
+            PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
+            PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+            Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
+            if diagnosticsSharingOption.telemetryEnabled {
+                TelemetryClient.shared.scheduleRecurring()
+                Task.detached { await TelemetryClient.shared.maybeSend() }
+            }
         }
 
         /// Applies the selected glucose units to the app's settings.

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -71,7 +71,7 @@ extension Onboarding {
 
         // Next button conditional
         private var shouldDisableNextButton: Bool {
-            (currentStep == .diagnostics && state.diagnosticsSharingOption == .enabled && !state.hasAcceptedPrivacyPolicy)
+            (currentStep == .diagnostics && state.diagnosticsSharingOption != .disabled && !state.hasAcceptedPrivacyPolicy)
                 ||
                 (currentStep == .nightscout && didSelectNightscoutSetupOption)
                 ||

+ 22 - 11
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift

@@ -7,7 +7,7 @@ struct DiagnosticsStepView: View {
 
     var body: some View {
         VStack(alignment: .leading, spacing: 20) {
-            Text("If you prefer not to share this anonymized data, you can opt-out of data sharing.")
+            Text("Help us improve Trio. Pick how much you'd like to share — or opt out entirely.")
                 .font(.headline)
                 .padding(.horizontal)
                 .multilineTextAlignment(.leading)
@@ -16,13 +16,19 @@ struct DiagnosticsStepView: View {
                 Button(action: {
                     state.updateDiagnosticsOption(to: option)
                 }) {
-                    HStack {
+                    HStack(alignment: .top, spacing: 12) {
                         Image(systemName: state.diagnosticsSharingOption == option ? "largecircle.fill.circle" : "circle")
                             .foregroundColor(state.diagnosticsSharingOption == option ? .accentColor : .secondary)
                             .imageScale(.large)
 
-                        Text(option.displayName)
-                            .foregroundColor(.primary)
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(option.displayName)
+                                .foregroundColor(.primary)
+                                .bold()
+                            Text(option.caption)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+                        }
 
                         Spacer()
                     }
@@ -33,6 +39,14 @@ struct DiagnosticsStepView: View {
                 .buttonStyle(.plain)
             }
 
+            NavigationLink {
+                TelemetryPreviewView()
+            } label: {
+                Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    .font(.footnote)
+            }
+            .padding(.horizontal)
+
             Toggle(isOn: $state.hasAcceptedPrivacyPolicy) {
                 HStack {
                     Text("I have read and accept the")
@@ -59,28 +73,25 @@ struct DiagnosticsStepView: View {
                 VStack(alignment: .leading, spacing: 4) {
                     BulletPoint(
                         String(
-                            localized: "App diagnostic insights help us enhance app stability, ensure safety for all users, and enable us to quickly identify and resolve critical issues."
+                            localized: "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio collects the app's state on crash, device, iOS and general system info, and a stack trace."
+                            localized: "Crash reports include the app's state on crash, device, iOS info, and a stack trace. They are sent to Google Firebase Crashlytics, maintained by the Trio team."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar."
+                            localized: "Anonymous usage statistics include the app version, your device and iOS version, your paired pump and CGM, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no). No URLs, tokens, or credentials are included."
                         )
                     )
                     BulletPoint(
                         String(
-                            localized: "Trio does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version."
+                            localized: "Trio never collects glucose readings, insulin rates or doses, meal data, therapy setting values, or any other health information."
                         )
                     )
                 }
-                Text(
-                    "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Trio team."
-                )
             }
             .multilineTextAlignment(.leading)
             .padding(.horizontal)

+ 47 - 3
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -483,20 +483,64 @@ enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
     }
 }
 
+/// Three-state diagnostics-sharing consent.
+///
+/// Maps to a pair of independent `Bool?` flags in `PropertyPersistentFlags`:
+/// `diagnosticsSharingEnabled` (Crashlytics) and `telemetryEnabled` (the
+/// anonymous-usage POST). See `TelemetryClient`.
 enum DiagnosticsSharingOption: String, Equatable, CaseIterable, Identifiable {
-    case enabled
+    case full
+    case crashOnly
     case disabled
 
     var id: String { rawValue }
 
     var displayName: String {
         switch self {
-        case .enabled:
-            return String(localized: "Enable Sharing")
+        case .full:
+            return String(localized: "Enable Full Sharing")
+        case .crashOnly:
+            return String(localized: "Crash Reports Only")
         case .disabled:
             return String(localized: "Disable Sharing")
         }
     }
+
+    var caption: String {
+        switch self {
+        case .full:
+            return String(localized: "Share anonymous crash reports + usage data.")
+        case .crashOnly:
+            return String(localized: "Share only crash reports — no usage data.")
+        case .disabled:
+            return String(localized: "Do not share any diagnostic data.")
+        }
+    }
+
+    var crashlyticsEnabled: Bool {
+        switch self {
+        case .crashOnly,
+             .full: return true
+        case .disabled: return false
+        }
+    }
+
+    var telemetryEnabled: Bool {
+        switch self {
+        case .full: return true
+        case .crashOnly,
+             .disabled: return false
+        }
+    }
+
+    init(crashlyticsEnabled: Bool, telemetryEnabled: Bool) {
+        switch (crashlyticsEnabled, telemetryEnabled) {
+        case (true, true): self = .full
+        case (true, false): self = .crashOnly
+        case (false, true): self = .full // unreachable in normal flow
+        case (false, false): self = .disabled
+        }
+    }
 }
 
 enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {

+ 5 - 0
Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift

@@ -0,0 +1,5 @@
+enum Telemetry {
+    enum Config {}
+}
+
+protocol TelemetryProvider {}

+ 3 - 0
Trio/Sources/Modules/Telemetry/TelemetryProvider.swift

@@ -0,0 +1,3 @@
+extension Telemetry {
+    final class Provider: BaseProvider, TelemetryProvider {}
+}

+ 9 - 0
Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift

@@ -0,0 +1,9 @@
+import Observation
+
+extension Telemetry {
+    @Observable final class StateModel: BaseStateModel<Provider> {}
+}
+
+extension Telemetry.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {}
+}

+ 145 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift

@@ -0,0 +1,145 @@
+import FirebaseCrashlytics
+import SwiftUI
+
+/// One-shot sheet shown on first foreground for users who completed onboarding
+/// before telemetry existed. Mirrors the onboarding `DiagnosticsStepView`
+/// chooser but is presented standalone, with a Privacy-Policy acceptance gate
+/// and no "skip" path — the user must explicitly pick one of the three options.
+///
+/// Once dismissed, `telemetryConsentDecisionMade` is set to `true` so the sheet
+/// never re-appears for this install.
+struct TelemetryMigrationSheetView: View {
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
+
+    @State private var selectedOption: DiagnosticsSharingOption = .full
+    // User already accepted the Privacy Policy during onboarding. This toggle
+    // is a re-acknowledgment that the policy has been updated to cover the new
+    // telemetry section — pre-checked so Continue works out of the box; users
+    // who want to read the updated policy can uncheck and tap the link.
+    @State private var hasAcceptedPrivacyPolicy: Bool = false
+
+    var onDecision: (() -> Void)?
+
+    var body: some View {
+        NavigationView {
+            ScrollView {
+                VStack(alignment: .leading, spacing: 20) {
+                    Text("Help us improve Trio")
+                        .font(.title2)
+                        .bold()
+
+                    Text(
+                        "Until now, Trio could only sent crash reports. You can now also share anonymous usage statistics — things like your iPhone and iOS version, and which pump and CGM you have paired. This helps the Trio team prioritize what to fix and improve next."
+                    )
+                    .font(.subheadline)
+
+                    Text(
+                        "Your glucose data, therapy settings, credentials, and logs always stay on your device. Pick what you'd like to share — you can change this any time in Settings → App Diagnostics."
+                    )
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+
+                    ForEach(DiagnosticsSharingOption.allCases, id: \.self) { option in
+                        Button(action: {
+                            selectedOption = option
+                        }) {
+                            HStack(alignment: .top, spacing: 12) {
+                                Image(systemName: selectedOption == option ? "largecircle.fill.circle" : "circle")
+                                    .foregroundColor(selectedOption == option ? .accentColor : .secondary)
+                                    .imageScale(.large)
+
+                                VStack(alignment: .leading, spacing: 4) {
+                                    Text(option.displayName)
+                                        .foregroundColor(.primary)
+                                        .bold()
+                                    Text(option.caption)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+
+                                Spacer()
+                            }
+                            .padding()
+                            .background(Color(.secondarySystemBackground))
+                            .cornerRadius(10)
+                        }
+                        .buttonStyle(.plain)
+                    }
+
+                    Toggle(isOn: $hasAcceptedPrivacyPolicy) {
+                        HStack {
+                            Text("I have read and accept the")
+                            Button("Privacy Policy") {
+                                if let url = URL(string: "https://github.com/nightscout/Trio/blob/dev/PRIVACY_POLICY.md") {
+                                    openURL(url)
+                                }
+                            }
+                            .foregroundColor(.accentColor)
+                            .underline()
+                        }
+                        .font(.footnote)
+                    }
+                    .toggleStyle(CheckboxToggleStyle(tint: Color.accentColor))
+                    .disabled(selectedOption == .disabled)
+                    .opacity(selectedOption == .disabled ? 0.35 : 1)
+
+                    NavigationLink {
+                        TelemetryPreviewView()
+                    } label: {
+                        Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
+                    }
+                    .padding(.top, 4)
+                }
+                .padding()
+
+                Spacer()
+
+                Button {
+                    confirm()
+                } label: {
+                    Text("Confirm").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+                }
+                .buttonStyle(.borderedProminent)
+                .disabled(selectedOption != .disabled && !hasAcceptedPrivacyPolicy)
+                .padding(.top)
+                .padding(.horizontal)
+            }
+            .navigationTitle("Improved Diagnostics")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .principal) {
+                    HStack(spacing: 6) {
+                        Text("NEW")
+                            .font(.caption2)
+                            .bold()
+                            .foregroundColor(.white)
+                            .padding(.horizontal, 6)
+                            .padding(.vertical, 2)
+                            .background(Color.accentColor)
+                            .clipShape(Capsule())
+                        Text("Improved Diagnostics")
+                            .font(.headline)
+                    }
+                }
+            }
+            .interactiveDismissDisabled(true)
+        }
+    }
+
+    private func confirm() {
+        let wasTelemetryOn = PropertyPersistentFlags.shared.telemetryEnabled == true
+        PropertyPersistentFlags.shared.diagnosticsSharingEnabled = selectedOption.crashlyticsEnabled
+        PropertyPersistentFlags.shared.telemetryEnabled = selectedOption.telemetryEnabled
+        PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
+        Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(selectedOption.crashlyticsEnabled)
+
+        if selectedOption.telemetryEnabled, !wasTelemetryOn {
+            TelemetryClient.shared.scheduleRecurring()
+            Task.detached { await TelemetryClient.shared.maybeSend() }
+        }
+
+        onDecision?()
+        dismiss()
+    }
+}

+ 53 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift

@@ -0,0 +1,53 @@
+import SwiftUI
+
+/// Renders the exact payload that would be sent right now, with a copy button.
+/// Linked to from Settings → App Diagnostics and from the migration sheet.
+struct TelemetryPreviewView: View {
+    @State private var jsonText: String = ""
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 12) {
+                Text(
+                    "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included."
+                )
+                .font(.subheadline)
+                .foregroundColor(.secondary)
+                .padding(.bottom, 4)
+
+                Text(jsonText)
+                    .font(.system(.footnote, design: .monospaced))
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .textSelection(.enabled)
+                    .padding(8)
+                    .background(Color(.secondarySystemBackground))
+                    .cornerRadius(6)
+
+                Button {
+                    UIPasteboard.general.string = jsonText
+                } label: {
+                    Label("Copy JSON", systemImage: "doc.on.doc")
+                }
+                .buttonStyle(.bordered)
+            }
+            .padding()
+        }
+        .navigationTitle("What's sent")
+        .navigationBarTitleDisplayMode(.inline)
+        .onAppear { jsonText = Self.renderPayload() }
+    }
+
+    private static func renderPayload() -> String {
+        let payload = TelemetryClient.shared.buildPayload()
+        guard
+            let data = try? JSONSerialization.data(
+                withJSONObject: payload,
+                options: [.prettyPrinted, .sortedKeys]
+            ),
+            let text = String(data: data, encoding: .utf8)
+        else {
+            return String(localized: "Unable to render payload.")
+        }
+        return text
+    }
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 54 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift


+ 273 - 0
Trio/Sources/Services/Telemetry/TelemetryAttestor.swift

@@ -0,0 +1,273 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import Swinject
+
+// MARK: - TelemetryAttestor
+
+/// Apple App Attest wrapper for the telemetry uploader. Owns:
+///   - the per-install App Attest key (generated once, persisted in Keychain)
+///   - the "this install has been registered with the server" flag (Keychain)
+///   - challenge fetch + assertion generation per send cycle
+///
+/// Designed to fail soft: if the device doesn't support App Attest
+/// (simulators, older iOS, etc.), `isSupported` is false and the caller
+/// should silently skip the send. Server-side rejections (403 from the
+/// register endpoint) are sticky — recorded in PropertyPersistentFlags so
+/// subsequent cycles don't retry indefinitely.
+///
+/// Wire protocol matches `nightscout/trio-telemetry`:
+///   1. POST /api/auth/ios/challenge       → { "challenge": "<base64url>" }
+///   2. POST /api/attest/register          (once per install)
+///   3. /checkin                           (per ping, headers below)
+final class TelemetryAttestor: Injectable {
+    static let shared = TelemetryAttestor()
+
+    @Injected() private var keychain: Keychain!
+
+    private let service = DCAppAttestService.shared
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+
+    private static let keyIDStorageKey = "TelemetryAttest.keyID"
+    private static let registeredStorageKey = "TelemetryAttest.registered"
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    /// True when the running device supports App Attest. Returns false on the
+    /// simulator and on devices that lack a Secure Enclave.
+    var isSupported: Bool {
+        service.isSupported
+    }
+
+    /// True once a 403 from `/api/attest/register` has flagged this install
+    /// as permanently rejected — typically a misconfigured `app_id`. Callers
+    /// should stop attempting to send.
+    var isForbidden: Bool {
+        PropertyPersistentFlags.shared.telemetryAttestForbidden == true
+    }
+
+    // MARK: - Registration
+
+    /// Idempotent: returns immediately if already registered. Otherwise
+    /// performs `generateKey` → fetch challenge → `attestKey` → POST register.
+    /// Throws on transport / server errors; sets the sticky "forbidden" flag
+    /// on a 403 so future cycles short-circuit.
+    func registerIfNeeded(baseURL: URL) async throws {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        if (keychain.getValue(Bool.self, forKey: Self.registeredStorageKey) ?? false) == true {
+            return
+        }
+
+        // generateKey() returns a base64url-encoded key identifier (Apple's docs).
+        // We persist it as-is for use in the assertion path below.
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        // App Attest expects a SHA-256 of the "client data" — for the
+        // attestation step, that's the challenge bytes alone.
+        let challengeBytes = Data(challenge.utf8)
+        let clientDataHash = Data(SHA256.hash(data: challengeBytes))
+
+        let attestationCBOR: Data
+        do {
+            attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+        } catch {
+            debug(.telemetry, "attestKey failed: \(error.localizedDescription)")
+            throw AttestError.attestationFailed(error)
+        }
+
+        guard let appID = Self.currentAppID() else {
+            throw AttestError.unknownAppID
+        }
+
+        let body: [String: Any] = [
+            "attestation": attestationCBOR.base64EncodedString(),
+            "key_id": keyID,
+            "challenge": challenge,
+            "app_id": appID
+        ]
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/attest/register"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+        request.timeoutInterval = 15
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+
+        switch http.statusCode {
+        case 200,
+             201:
+            keychain.setValue(true, forKey: Self.registeredStorageKey)
+            debug(.telemetry, "register ok status=\(http.statusCode)")
+        case 403:
+            // app_id rejected. Sticky — flag the install and surface to caller.
+            PropertyPersistentFlags.shared.telemetryAttestForbidden = true
+            debug(.telemetry, "register forbidden — app_id=\(appID) rejected; no further attempts")
+            throw AttestError.forbidden
+        case 400 ..< 500:
+            throw AttestError.clientError(http.statusCode)
+        case 500 ..< 600:
+            throw AttestError.serverError(http.statusCode)
+        default:
+            throw AttestError.serverError(http.statusCode)
+        }
+    }
+
+    // MARK: - Per-ping assertion
+
+    /// Builds the App Attest assertion for a single `/checkin` send.
+    ///
+    /// `clientDataHash` for the assertion is `SHA256(payloadBytes || challengeBytes)`.
+    /// **Order matters**: payload first, then the challenge (per the server
+    /// spec). Returns the base64-encoded assertion CBOR, the keyID (already a
+    /// base64url string), and the challenge string — all three become headers
+    /// on the outgoing request.
+    func assertion(forPayload payload: Data, baseURL: URL) async throws -> (assertion: String, keyID: String, challenge: String) {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        var hasher = SHA256()
+        hasher.update(data: payload)
+        hasher.update(data: Data(challenge.utf8))
+        let clientDataHash = Data(hasher.finalize())
+
+        let assertionCBOR: Data
+        do {
+            assertionCBOR = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
+        } catch {
+            throw AttestError.assertionFailed(error)
+        }
+        return (assertionCBOR.base64EncodedString(), keyID, challenge)
+    }
+
+    // MARK: - Helpers
+
+    /// Reads the cached App Attest key identifier from Keychain, generating a
+    /// new one (and persisting it) on first call. The keyID is the only thing
+    /// we store — Apple holds the actual private key in the Secure Enclave.
+    private func currentOrCreateKeyID() async throws -> String {
+        if let cached = keychain.getValue(String.self, forKey: Self.keyIDStorageKey),
+           !cached.isEmpty
+        {
+            return cached
+        }
+        let newKey: String
+        do {
+            newKey = try await service.generateKey()
+        } catch {
+            throw AttestError.keyGenerationFailed(error)
+        }
+        keychain.setValue(newKey, forKey: Self.keyIDStorageKey)
+        debug(.telemetry, "generated new App Attest keyID")
+        return newKey
+    }
+
+    private func fetchChallenge(baseURL: URL) async throws -> String {
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/auth/ios/challenge"))
+        request.httpMethod = "POST"
+        request.timeoutInterval = 15
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+        guard (200 ..< 300).contains(http.statusCode) else {
+            if (500 ..< 600).contains(http.statusCode) {
+                throw AttestError.serverError(http.statusCode)
+            }
+            throw AttestError.clientError(http.statusCode)
+        }
+
+        struct ChallengeResponse: Decodable { let challenge: String }
+        do {
+            let cr = try JSONDecoder().decode(ChallengeResponse.self, from: data)
+            return cr.challenge
+        } catch {
+            throw AttestError.malformedResponse
+        }
+    }
+
+    /// Produces the `<TEAMID>.<bundle-id>` string the server expects in
+    /// `app_id` — matches the regex `^[A-Z0-9]+\.org\.nightscout\.[^.]+\.trio$`
+    /// when the build is configured correctly.
+    ///
+    /// Reads `application-identifier` from `embedded.mobileprovision`. On iOS
+    /// the SDK doesn't expose `SecTaskCopyValueForEntitlement` to Swift, and
+    /// parsing the mobile-provision file is the standard workaround. Returns
+    /// nil for App Store builds (no embedded.mobileprovision) — which Trio
+    /// doesn't ship, so this path is fine for sideload + TestFlight.
+    static func currentAppID() -> String? {
+        guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
+              let raw = try? Data(contentsOf: url)
+        else { return nil }
+
+        // The mobileprovision file is a CMS-signed envelope around a plist.
+        // Pull the plist substring between the XML prolog and `</plist>`.
+        guard let ascii = String(data: raw, encoding: .ascii),
+              let start = ascii.range(of: "<?xml"),
+              let end = ascii.range(of: "</plist>")
+        else { return nil }
+
+        let plistString = String(ascii[start.lowerBound ..< end.upperBound])
+        guard let plistData = plistString.data(using: .utf8),
+              let plist = try? PropertyListSerialization
+              .propertyList(from: plistData, options: [], format: nil) as? [String: Any],
+              let entitlements = plist["Entitlements"] as? [String: Any],
+              let appID = entitlements["application-identifier"] as? String
+        else { return nil }
+
+        return appID
+    }
+
+    // MARK: - Errors
+
+    enum AttestError: Error, CustomStringConvertible {
+        case unsupportedDevice
+        case forbidden
+        case unknownAppID
+        case keyGenerationFailed(Error)
+        case attestationFailed(Error)
+        case assertionFailed(Error)
+        case transportError
+        case malformedResponse
+        case clientError(Int)
+        case serverError(Int)
+
+        var description: String {
+            switch self {
+            case .unsupportedDevice: return "App Attest unsupported on this device"
+            case .forbidden: return "app_id forbidden by server"
+            case .unknownAppID: return "unable to read application-identifier entitlement"
+            case let .keyGenerationFailed(e): return "generateKey failed: \(e.localizedDescription)"
+            case let .attestationFailed(e): return "attestKey failed: \(e.localizedDescription)"
+            case let .assertionFailed(e): return "generateAssertion failed: \(e.localizedDescription)"
+            case .transportError: return "non-HTTP response"
+            case .malformedResponse: return "malformed challenge response"
+            case let .clientError(code): return "client error \(code)"
+            case let .serverError(code): return "server error \(code)"
+            }
+        }
+    }
+}

+ 330 - 0
Trio/Sources/Services/Telemetry/TelemetryClient.swift

@@ -0,0 +1,330 @@
+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
+
+    private static let baseURL: URL? = URL(string: "https://telemetry.triodocs.org")
+
+    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
+
+        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, 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
+            }
+            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
+    }
+}