Просмотр исходного кода

Merge branch 'dev' into feat/dev-eversense

Deniz Cengiz 1 день назад
Родитель
Сommit
5a3b84bbc0
27 измененных файлов с 1645 добавлено и 54 удалено
  1. 1 1
      Config.xcconfig
  2. 63 2
      PRIVACY_POLICY.md
  3. 56 0
      Trio.xcodeproj/project.pbxproj
  4. 218 1
      Trio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  5. 4 2
      Trio/Sources/APS/FetchGlucoseManager.swift
  6. 12 0
      Trio/Sources/Application/AppDelegate.swift
  7. 30 0
      Trio/Sources/Application/TrioApp.swift
  8. 26 0
      Trio/Sources/Helpers/PropertyPersistentFlags.swift
  9. 9 1
      Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift
  10. 12 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  11. 4 0
      Trio/Sources/Logger/Logger.swift
  12. 30 11
      Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift
  13. 19 8
      Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift
  14. 7 1
      Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift
  15. 27 12
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  16. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  17. 22 11
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/DiagnosticsStepView.swift
  18. 47 3
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  19. 5 0
      Trio/Sources/Modules/Telemetry/TelemetryDataFlow.swift
  20. 3 0
      Trio/Sources/Modules/Telemetry/TelemetryProvider.swift
  21. 9 0
      Trio/Sources/Modules/Telemetry/TelemetryStateModel.swift
  22. 145 0
      Trio/Sources/Modules/Telemetry/View/TelemetryMigrationSheetView.swift
  23. 84 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift
  24. 54 0
      Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift
  25. 338 0
      Trio/Sources/Services/Telemetry/TelemetryAttestor.swift
  26. 352 0
      Trio/Sources/Services/Telemetry/TelemetryClient.swift
  27. 67 0
      TrioTests/GlucoseSmoothingTests.swift

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.7.0
-APP_DEV_VERSION = 0.7.0.16
+APP_DEV_VERSION = 0.7.0.19
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 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

@@ -621,6 +621,14 @@
 		DD73FA0F2D74F58E00D19D1E /* BackgroundTask+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.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 */; };
@@ -1480,6 +1488,14 @@
 		DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackgroundTask+Helper.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>"; };
@@ -1956,6 +1972,7 @@
 				DDD163032C4C67B400CD525A /* Adjustments */,
 				DD1745382C55BF8B00211FAC /* AlgorithmAdvancedSettings */,
 				DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */,
+				DD7E1E30000000000000000B /* Telemetry */,
 				DD1745422C55C5C400211FAC /* AutosensSettings */,
 				A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */,
 				3811DE0425C9D32E00A708ED /* Base */,
@@ -2122,6 +2139,7 @@
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
+				DD7E1E30000000000000000A /* Telemetry */,
 				3811DEA525C9D88300A708ED /* UnlockManager */,
 				38E87406274F9AA500975559 /* UserNotifications */,
 				38E8754D275556E100975559 /* WatchManager */,
@@ -2129,6 +2147,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 = (
@@ -4373,6 +4421,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 */,

+ 218 - 1
Trio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,6 +1,169 @@
 {
+  "originHash" : "b271d5de8862534ec5ccf9ccfd7ce226afa7ed4c799b7066ccbe2281782402a1",
   "pins" : [
     {
+      "identity" : "abseil-cpp-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/abseil-cpp-binary.git",
+      "state" : {
+        "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
+        "version" : "1.2024072200.0"
+      }
+    },
+    {
+      "identity" : "app-check",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/app-check.git",
+      "state" : {
+        "revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
+        "version" : "11.2.0"
+      }
+    },
+    {
+      "identity" : "bluecryptor",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueCryptor.git",
+      "state" : {
+        "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d",
+        "version" : "2.0.2"
+      }
+    },
+    {
+      "identity" : "blueecc",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueECC.git",
+      "state" : {
+        "revision" : "1485268a54f8135435a825a855e733f026fa6cc8",
+        "version" : "1.2.201"
+      }
+    },
+    {
+      "identity" : "bluersa",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/BlueRSA.git",
+      "state" : {
+        "revision" : "f40325520344a966523b214394aa350132a6af68",
+        "version" : "1.0.203"
+      }
+    },
+    {
+      "identity" : "connectiq-companion-app-sdk-ios",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/garmin/connectiq-companion-app-sdk-ios",
+      "state" : {
+        "revision" : "f0d29ff691d700a132d86205ed9bb091e336c2f7",
+        "version" : "1.8.0"
+      }
+    },
+    {
+      "identity" : "cryptoswift",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/krzyzanowskim/CryptoSwift",
+      "state" : {
+        "revision" : "f2a627b84c1ff96f21ac2fcb623ab36142dd5512",
+        "version" : "1.10.0"
+      }
+    },
+    {
+      "identity" : "firebase-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/firebase-ios-sdk.git",
+      "state" : {
+        "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
+        "version" : "11.15.0"
+      }
+    },
+    {
+      "identity" : "google-ads-on-device-conversion-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
+      "state" : {
+        "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
+        "version" : "2.3.0"
+      }
+    },
+    {
+      "identity" : "googleappmeasurement",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleAppMeasurement.git",
+      "state" : {
+        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
+        "version" : "11.15.0"
+      }
+    },
+    {
+      "identity" : "googledatatransport",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleDataTransport.git",
+      "state" : {
+        "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
+        "version" : "10.1.0"
+      }
+    },
+    {
+      "identity" : "googleutilities",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/GoogleUtilities.git",
+      "state" : {
+        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
+        "version" : "8.1.0"
+      }
+    },
+    {
+      "identity" : "grpc-binary",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/grpc-binary.git",
+      "state" : {
+        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
+        "version" : "1.69.1"
+      }
+    },
+    {
+      "identity" : "gtm-session-fetcher",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/gtm-session-fetcher.git",
+      "state" : {
+        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
+        "version" : "4.5.0"
+      }
+    },
+    {
+      "identity" : "interop-ios-for-google-sdks",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/interop-ios-for-google-sdks.git",
+      "state" : {
+        "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
+        "version" : "101.0.0"
+      }
+    },
+    {
+      "identity" : "kituracontracts",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/KituraContracts.git",
+      "state" : {
+        "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23",
+        "version" : "2.0.1"
+      }
+    },
+    {
+      "identity" : "leveldb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/leveldb.git",
+      "state" : {
+        "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
+        "version" : "1.22.5"
+      }
+    },
+    {
+      "identity" : "loggerapi",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/Kitura/LoggerAPI.git",
+      "state" : {
+        "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac",
+        "version" : "2.0.0"
+      }
+    },
+    {
       "identity" : "mkringprogressview",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/maxkonovalov/MKRingProgressView.git",
@@ -10,6 +173,33 @@
       }
     },
     {
+      "identity" : "nanopb",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/firebase/nanopb.git",
+      "state" : {
+        "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
+        "version" : "2.30910.0"
+      }
+    },
+    {
+      "identity" : "promises",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/google/promises.git",
+      "state" : {
+        "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
+        "version" : "2.4.0"
+      }
+    },
+    {
+      "identity" : "slidebutton",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/no-comment/SlideButton",
+      "state" : {
+        "branch" : "main",
+        "revision" : "5eacebba4d7deeb693592bc9a62ab2d2181e133b"
+      }
+    },
+    {
       "identity" : "swift-algorithms",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-algorithms",
@@ -19,6 +209,24 @@
       }
     },
     {
+      "identity" : "swift-jwt",
+      "kind" : "remoteSourceControl",
+      "location" : "http://github.com/Kitura/Swift-JWT.git",
+      "state" : {
+        "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f",
+        "version" : "4.0.1"
+      }
+    },
+    {
+      "identity" : "swift-log",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-log.git",
+      "state" : {
+        "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
+        "version" : "1.12.0"
+      }
+    },
+    {
       "identity" : "swift-numerics",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-numerics",
@@ -28,6 +236,15 @@
       }
     },
     {
+      "identity" : "swift-protobuf",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-protobuf.git",
+      "state" : {
+        "revision" : "81558271e243f8f47dfe8e9fdd55f3c2b5413f68",
+        "version" : "1.37.0"
+      }
+    },
+    {
       "identity" : "swiftcharts",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/ivanschuetz/SwiftCharts.git",
@@ -64,5 +281,5 @@
       }
     }
   ],
-  "version" : 2
+  "version" : 3
 }

+ 4 - 2
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -381,9 +381,11 @@ extension BaseFetchGlucoseManager {
             // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
             // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
             // this fetch window must be expanded accordingly.
+            // Fetch descending (newest first) so the limit always keeps the most recent 350 readings.
+            // Reversed before return so callers receive oldest-first (chronological) order.
             predicate: compoundPredicate,
             key: "date",
-            ascending: true, // the first element is the oldest
+            ascending: false,
             fetchLimit: 350
         )
 
@@ -391,7 +393,7 @@ extension BaseFetchGlucoseManager {
             throw CoreDataError.fetchError(function: #function, file: #file)
         }
 
-        return glucoseArray.map(\.objectID)
+        return Array(glucoseArray.map(\.objectID).reversed())
     }
 
     /// CoreData-friendly AAPS exponential smoothing + storage.

+ 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

+ 12 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -72169,6 +72169,9 @@
         }
       }
     },
+    "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck." : {
+
+    },
     "Click to Snooze Alerts" : {
       "localizations" : {
         "bg" : {
@@ -209864,6 +209867,15 @@
         }
       }
     },
+    "Reset and retry send" : {
+
+    },
+    "Reset App Attest state" : {
+
+    },
+    "Reset App Attest state?" : {
+
+    },
     "Reset to Defaults" : {
       "localizations" : {
         "bg" : {

+ 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)

+ 7 - 1
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -19,7 +19,13 @@ extension DynamicSettings {
         private var shouldDisplayHintBinding: Binding<Bool> {
             Binding(
                 get: { hintPayload != nil },
-                set: { newValue in if !newValue { hintPayload = nil } }
+                set: { newValue in
+                    if !newValue {
+                        hintPayload = nil
+                    } else if hintPayload == nil {
+                        hintPayload = HintPayload(label: "", content: AnyView(EmptyView()))
+                    }
+                }
             )
         }
 

+ 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()
+    }
+}

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

@@ -0,0 +1,84 @@
+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 = ""
+    @State private var showResetConfirm: Bool = false
+    @State private var resetStatus: 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)
+
+                Button(role: .destructive) {
+                    showResetConfirm = true
+                } label: {
+                    Label("Reset App Attest state", systemImage: "arrow.counterclockwise.circle")
+                }
+                .buttonStyle(.bordered)
+
+                if let resetStatus {
+                    Text(resetStatus)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .padding()
+        }
+        .navigationTitle("What's sent")
+        .navigationBarTitleDisplayMode(.inline)
+        .onAppear { jsonText = Self.renderPayload() }
+        .confirmationDialog(
+            "Reset App Attest state?",
+            isPresented: $showResetConfirm,
+            titleVisibility: .visible
+        ) {
+            Button("Reset and retry send", role: .destructive) {
+                TelemetryAttestor.shared.resetAttestState()
+                resetStatus = "Reset done — attempting a fresh send. Check logs for status."
+                Task { await TelemetryClient.shared.maybeSend() }
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text(
+                "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck."
+            )
+        }
+    }
+
+    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
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 54 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPrivacyView.swift


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

@@ -0,0 +1,338 @@
+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))
+
+        // Diagnostics for `attestKey` failures. We log shape, not values:
+        // keyID prefix only (the keyID is per-install and shouldn't end up in
+        // shareable logs in full). If any of these look off, the failure is
+        // ours; if they look right and Apple still rejects, the failure is
+        // server-side at Apple.
+        let keyIDPrefix = String(keyID.prefix(8))
+        debug(
+            .telemetry,
+            "attestKey input: isSupported=\(service.isSupported) keyID.count=\(keyID.count) keyID.prefix=\(keyIDPrefix) hash.count=\(clientDataHash.count) challenge.count=\(challenge.count) bundle=\(Bundle.main.bundleIdentifier ?? "nil")"
+        )
+
+        let attestationCBOR: Data
+        do {
+            attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+        } catch {
+            // `attestKey` is one-shot per key per device, but only on success.
+            // Branch on the DCError code so logs distinguish the recoverable
+            // cases from real failures:
+            //   .invalidKey         — keyID is permanently burnt; drop it.
+            //   .invalidInput       — Apple rejected an argument as malformed.
+            //                         In practice we see this when the keyID
+            //                         is stale (e.g. survived an uninstall via
+            //                         Keychain) and no longer matches Apple's
+            //                         expected identity for this install. Drop
+            //                         the keyID — same recovery as invalidKey.
+            //   .serverUnavailable  — Apple's App Attest backend is down or
+            //                         throttling. Key is still valid; the
+            //                         next cycle retries with the same keyID.
+            if let dcError = error as? DCError {
+                switch dcError.code {
+                case .invalidInput,
+                     .invalidKey:
+                    keychain.removeObject(forKey: Self.keyIDStorageKey)
+                    let reason = dcError.code == .invalidKey ? "invalidKey" : "invalidInput"
+                    debug(.telemetry, "attestKey \(reason): discarded keyID; will regenerate next cycle")
+                case .serverUnavailable:
+                    debug(.telemetry, "attestKey serverUnavailable: Apple App Attest backend transient — will retry next cycle")
+                default:
+                    break
+                }
+            }
+            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)
+        }
+    }
+
+    /// Clears the local App Attest state so the next `registerIfNeeded`
+    /// generates a fresh key and re-runs the handshake from scratch. Both the
+    /// keyID and the "registered" flag are dropped: `attestKey` may be called
+    /// at most once per key per device, so reusing the old keyID would throw
+    /// `DCError.invalidKey`. Use when `/checkin` returns 401 (server lost our
+    /// registration).
+    func invalidateRegistration() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+    }
+
+    /// Full local-state reset for stuck installs. In addition to what
+    /// `invalidateRegistration` clears, this also drops the sticky
+    /// `telemetryAttestForbidden` flag — so a tester who got 403'd and wants
+    /// to retry can do so without reinstalling. Exposed through a button in
+    /// the telemetry inspector. Does not touch consent or installId.
+    func resetAttestState() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+        PropertyPersistentFlags.shared.telemetryAttestForbidden = false
+        debug(.telemetry, "reset App Attest state: keyID, registered flag, and forbidden flag cleared")
+    }
+
+    // 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>`.
+        // ISO Latin-1 maps every byte 0x00–0xFF 1:1, so the conversion never
+        // fails on the binary CMS bytes surrounding the plist — `.ascii` would
+        // return nil here.
+        guard let scanned = String(data: raw, encoding: .isoLatin1),
+              let start = scanned.range(of: "<?xml"),
+              let end = scanned.range(of: "</plist>")
+        else { return nil }
+
+        let plistString = String(scanned[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)"
+            }
+        }
+    }
+}

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

@@ -0,0 +1,352 @@
+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 productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
+
+    /// Effective base URL: respects the debug override in
+    /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
+    /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
+    private static var baseURL: URL? {
+        if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+            !override.isEmpty,
+            let url = URL(string: override)
+        {
+            return url
+        }
+        return productionBaseURL
+    }
+
+    private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
+    private static let dailyInterval: TimeInterval = 24 * 60 * 60
+    private static let maxPayloadBytes = 4096
+
+    // MARK: Injected services
+
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var tidepoolManager: TidepoolManager!
+    @Injected() private var healthKitManager: HealthKitManager!
+    @Injected() private var keychain: Keychain!
+
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+    private var timer: DispatchTimer?
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    // MARK: - Cold launches
+
+    /// Records a cold launch in a sliding 7-day window of timestamps. The count
+    /// of entries in the window ships as `coldLaunches7d` in every ping — a
+    /// "how often does iOS recycle this process" signal that is directly
+    /// comparable across pings regardless of the cadence between them.
+    func recordColdLaunch(now: Date = Date()) {
+        let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
+        var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
+        recent.removeAll { $0 < cutoff }
+        recent.append(now)
+        PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
+    }
+
+    // MARK: - Install identifier
+
+    /// Stable per-install UUID, generated lazily on first call. IDFV resets if
+    /// the user deletes every Trio-team app at once; this survives
+    /// independently and is wiped only by deleting Trio itself.
+    private func installId() -> String {
+        if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
+            return existing
+        }
+        let new = UUID().uuidString
+        PropertyPersistentFlags.shared.telemetryInstallId = new
+        return new
+    }
+
+    // MARK: - Cadence
+
+    /// True when the running build's commit SHA differs from the SHA recorded
+    /// at the last successful send. Used at startup to fire one immediate ping
+    /// after an app update — the 24h scheduler can't notice a build change and
+    /// would otherwise wait out the previous interval.
+    func buildShaChangedSinceLastSend() -> Bool {
+        let currentSha = BuildDetails.shared.trioCommitSHA
+        return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
+    }
+
+    /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
+    /// scheduling if the user hasn't decided on consent yet or has opted out
+    /// — there's nothing for the timer to do.
+    func scheduleRecurring() {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+
+        lock.lock()
+        defer { lock.unlock() }
+
+        if timer == nil {
+            let t = DispatchTimer(timeInterval: Self.dailyInterval)
+            t.eventHandler = { [weak self] in
+                Task.detached { await self?.maybeSend() }
+            }
+            t.resume()
+            timer = t
+        }
+    }
+
+    /// Single entry point for all sends (scheduler tick, consent-yes, startup
+    /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
+    /// decision — startup handles the SHA-change shortcut, the timer handles
+    /// 24h cadence.
+    func maybeSend() async {
+        guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
+              PropertyPersistentFlags.shared.telemetryEnabled == true
+        else {
+            return
+        }
+        await send()
+    }
+
+    // MARK: - Payload
+
+    /// The exact payload that would be POSTed right now. Pure function: shared
+    /// by `send()` and `TelemetryPreviewView`.
+    func buildPayload() -> [String: Any] {
+        injectIfNeeded()
+
+        let bd = BuildDetails.shared
+        let info = Bundle.main.infoDictionary ?? [:]
+
+        var payload: [String: Any] = [:]
+
+        if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
+        // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
+        // the most precise build identifier we have. Always emit, even when
+        // the Info.plist key is missing, so dashboards can rely on the field.
+        payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
+        payload["commitSha"] = bd.trioCommitSHA
+        payload["branch"] = bd.trioBranch
+
+        // Date-only prefix of the build-date string. Keeps the field a
+        // low-resolution build identifier, not a precise timestamp.
+        if let raw = bd.buildDateString, raw.count >= 10 {
+            payload["buildDate"] = String(raw.prefix(10))
+        }
+
+        payload["isTestFlight"] = bd.isTestFlightBuild()
+
+        if let idfv = UIDevice.current.identifierForVendor?.uuidString {
+            payload["idfv"] = idfv
+        }
+        payload["installId"] = installId()
+
+        payload["device"] = Self.hardwareIdentifier()
+        payload["platform"] = Self.detectPlatform()
+        payload["osVersion"] = UIDevice.current.systemVersion
+
+        // Pump model — omitted entirely when no pump is paired.
+        if let pump = apsManager?.pumpManager {
+            payload["pumpModel"] = pump.localizedTitle
+        }
+
+        // CGM: enum tells us the configured *type*; the live manager (if any)
+        // tells us the specific model name. Both are useful — `cgmType`
+        // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
+        let settings = settingsManager?.settings
+        payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
+        if let cgm = fetchGlucoseManager?.cgmManager {
+            payload["cgmModel"] = cgm.localizedTitle
+        }
+
+        // Nightscout: keys present in keychain ⇒ configured. We never include
+        // the URL or token themselves.
+        let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
+
+        payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
+
+        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
+            }
+            switch http.statusCode {
+            case 200 ..< 300:
+                PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
+                PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
+                debug(.telemetry, "send ok status=\(http.statusCode)")
+            case 401:
+                // Server doesn't recognize our registration (e.g. its registry
+                // was wiped). Drop the local keyID + registered flag so the
+                // next cycle generates a fresh key and re-attests — `attestKey`
+                // can't be re-run on the existing keyID (one-shot per Apple).
+                attestor.invalidateRegistration()
+                debug(.telemetry, "send 401: stale registration, will re-register next cycle")
+            default:
+                debug(.telemetry, "send non-2xx status=\(http.statusCode)")
+            }
+        } catch {
+            debug(.telemetry, "send error: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - Helpers
+
+    /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
+    /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
+    /// can ignore those rows.
+    static func hardwareIdentifier() -> String {
+        #if targetEnvironment(simulator)
+            let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
+            return "Simulator \(env)"
+        #else
+            var sys = utsname()
+            uname(&sys)
+            let mirror = Mirror(reflecting: sys.machine)
+            let machine = mirror.children.reduce(into: "") { acc, child in
+                guard let v = child.value as? Int8, v != 0 else { return }
+                acc.append(Character(UnicodeScalar(UInt8(v))))
+            }
+            return machine.isEmpty ? "Unknown" : machine
+        #endif
+    }
+
+    static func detectPlatform() -> String {
+        #if targetEnvironment(macCatalyst)
+            return "macCatalyst"
+        #else
+            switch UIDevice.current.userInterfaceIdiom {
+            case .pad: return "iPadOS"
+            default: return "iOS"
+            }
+        #endif
+    }
+}

+ 67 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -217,6 +217,73 @@ import Testing
         }
     }
 
+    // MARK: - fetchGlucose Window Tests
+
+    @Test(
+        "fetchGlucose retains the most recent 350 readings (not the oldest) when 24h holds more than 350"
+    ) func testFetchGlucoseKeepsMostRecentWhenOverLimit() async throws {
+        // GIVEN: 360 readings within the last 24h (3 min spacing => ~18h span).
+        // Each reading carries a unique glucose value so we can verify which subset survives the limit.
+        let count = 360
+        let values: [Int16] = (0 ..< count).map { Int16(100 + $0) }
+        await createGlucoseSequence(values: values, interval: 3 * 60, isManual: false)
+
+        // WHEN
+        let objectIDs = try await fetchGlucoseManager.fetchGlucose(context: testContext)
+
+        // THEN
+        #expect(objectIDs.count == 350, "fetchGlucose should respect the 350 limit, got \(objectIDs.count).")
+
+        await testContext.perform {
+            let fetched = objectIDs.compactMap { self.testContext.object(with: $0) as? GlucoseStored }
+            #expect(fetched.count == 350, "All returned object IDs must resolve to GlucoseStored instances.")
+
+            // Returned order must be oldest-first (chronological) — the smoother walks the array this way.
+            let dates = fetched.compactMap(\.date)
+            #expect(dates == dates.sorted(), "fetchGlucose must return readings in chronological (ascending) order.")
+
+            // The most recent reading (current BG) must be the LAST element after the chronological reverse.
+            #expect(
+                fetched.last?.glucose == Int16(100 + count - 1),
+                "Most recent reading (current BG) must be retained after the 350-limit truncation."
+            )
+
+            // The oldest 10 readings must be dropped — verify the limit cut from the OLD end, not the recent end.
+            let returnedGlucoseValues = Set(fetched.map(\.glucose))
+            #expect(
+                !returnedGlucoseValues.contains(Int16(100)),
+                "Oldest reading must be excluded by the limit (truncation should cut old, not recent)."
+            )
+            #expect(
+                returnedGlucoseValues.contains(Int16(100 + count - 1)),
+                "Newest reading must be included after truncation."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing writes a smoothed value for the current BG when 24h holds more than 350 readings"
+    ) func testExponentialSmoothingCoversCurrentBGAboveLimit() async throws {
+        // GIVEN: 360 contiguous CGM readings within the last 24h (3 min spacing, no gaps).
+        let count = 360
+        let values: [Int16] = (0 ..< count).map { _ in Int16(120) }
+        await createGlucoseSequence(values: values, interval: 3 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN: the most recent reading must have received a smoothed value.
+        // Regression test for the bug where ascending+fetchLimit kept the OLDEST 350 readings,
+        // so the current BG fell outside the smoothing window and was never written.
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == count)
+
+        #expect(
+            ascending.last?.smoothedGlucose != nil,
+            "Most recent reading (current BG) must receive a smoothed value when over the 350-row limit."
+        )
+    }
+
     // MARK: - OpenAPS Glucose Selection Tests
 
     @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {