Explorar el Código

Add anonymous telemetry (#626)

Jonas Björkert hace 2 meses
padre
commit
8b5ce73dd0

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -420,6 +420,7 @@
 		FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; };
 		FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; };
 		FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; };
+		9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; };
 		FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; };
 		FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; };
 		FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; };
@@ -875,6 +876,7 @@
 		FCC6886A24898FD800A0279D /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = "<group>"; };
 		FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = "<group>"; };
 		FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
+		BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = "<group>"; };
 		FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = "<group>"; };
 		FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = "<group>"; };
 		FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = "<group>"; };
@@ -1676,6 +1678,7 @@
 				DD7B0D432D730A320063DCB6 /* CycleHelper.swift */,
 				DDF6999C2C5AAA4C0058A8D9 /* Views */,
 				FCC6886E2489A53800A0279D /* AppConstants.swift */,
+				BDFBE69CEF18416D84959974 /* Telemetry.swift */,
 				FCC6886A24898FD800A0279D /* ObservationToken.swift */,
 				FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
 				FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
@@ -2143,6 +2146,7 @@
 				DD0650ED2DCE9371004D3B41 /* HighBgAlarmEditor.swift in Sources */,
 				DD7F4C172DD63FA700D449E9 /* RecBolusCondition.swift in Sources */,
 				FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */,
+				9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */,
 				DDE75D2D2DE71401007C1FC1 /* TogglableSecureInput.swift in Sources */,
 				DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */,
 				DD0B9D562DE1EC8A0090C337 /* AlarmType+Snooze.swift in Sources */,

+ 15 - 0
LoopFollow/Application/AppDelegate.swift

@@ -48,6 +48,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         BackgroundRefreshManager.shared.register()
 
+        // Telemetry: record this cold launch (used by the rolling
+        // coldLaunches7d signal). If the running build's SHA differs from
+        // the one we last sent for, fire an immediate ping — the scheduler
+        // alone can't notice an app update. Otherwise let the 24h scheduler
+        // handle cadence: its first run is lastSentAt + 24h, so a relaunch
+        // a few hours after the previous send simply waits out the
+        // remainder. See Helpers/Telemetry.swift.
+        TelemetryClient.shared.recordColdLaunch()
+        Task.detached {
+            if TelemetryClient.shared.buildShaChangedSinceLastSend() {
+                await TelemetryClient.shared.maybeSend()
+            }
+            TelemetryClient.shared.scheduleRecurring()
+        }
+
         // Detect Before-First-Unlock launch. If protected data is unavailable here,
         // StorageValues were cached from encrypted UserDefaults and need a reload
         // on the first foreground after the user unlocks.

+ 36 - 0
LoopFollow/Application/SceneDelegate.swift

@@ -2,12 +2,17 @@
 // SceneDelegate.swift
 
 import AVFoundation
+import SwiftUI
 import UIKit
 
 class SceneDelegate: UIResponder, UIWindowSceneDelegate {
     var window: UIWindow?
     let synthesizer = AVSpeechSynthesizer()
 
+    /// One-shot guard so the consent prompt is only attempted once per
+    /// process lifetime even if the scene activates repeatedly.
+    private var consentPromptShownThisProcess = false
+
     func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
         // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
         // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
@@ -32,6 +37,37 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
     func sceneDidBecomeActive(_: UIScene) {
         // Called when the scene has moved from an inactive state to an active state.
         // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
+        runTelemetryFirstForegroundHook()
+    }
+
+    /// Presents the one-time consent sheet on first foreground. Sending is
+    /// handled by AppDelegate at launch and by TaskScheduler thereafter —
+    /// firing maybeSend here would duplicate the launch-time send.
+    private func runTelemetryFirstForegroundHook() {
+        if !Storage.shared.telemetryConsentDecisionMade.value,
+           !consentPromptShownThisProcess
+        {
+            consentPromptShownThisProcess = true
+            presentTelemetryConsentSheet()
+        }
+    }
+
+    private func presentTelemetryConsentSheet() {
+        guard let root = window?.rootViewController else { return }
+        // Find the topmost presented controller so we don't try to present
+        // over a sheet that's already up.
+        var top = root
+        while let presented = top.presentedViewController {
+            top = presented
+        }
+
+        let host = UIHostingController(rootView: TelemetryConsentView())
+        host.isModalInPresentation = true // user must explicitly choose
+        // Defer to the next runloop so view hierarchy is settled when the
+        // scene first becomes active on a fresh install.
+        DispatchQueue.main.async {
+            top.present(host, animated: true)
+        }
     }
 
     func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {

+ 5 - 1
LoopFollow/Helpers/BuildDetails.swift

@@ -31,9 +31,13 @@ class BuildDetails {
         return dict["com-LoopFollow-branch"] as? String
     }
 
+    var commitSha: String? {
+        return dict["com-LoopFollow-commit-sha"] as? String
+    }
+
     var branchAndSha: String {
         let branch = branch ?? "Unknown"
-        let sha = dict["com-LoopFollow-commit-sha"] as? String ?? "Unknown"
+        let sha = commitSha ?? "Unknown"
         return "\(branch) \(sha)"
     }
 

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 400 - 0
LoopFollow/Helpers/Telemetry.swift


+ 1 - 0
LoopFollow/Log/LogManager.swift

@@ -29,6 +29,7 @@ class LogManager {
         case calendar = "Calendar"
         case deviceStatus = "Device Status"
         case remote = "Remote"
+        case telemetry = "Telemetry"
     }
 
     init() {

+ 14 - 0
LoopFollow/Settings/GeneralSettingsView.swift

@@ -29,6 +29,9 @@ struct GeneralSettingsView: View {
     @ObservedObject var speakHighBG = Storage.shared.speakHighBG
     @ObservedObject var speakHighBGLimit = Storage.shared.speakHighBGLimit
 
+    // Telemetry — see LoopFollow/Helpers/Telemetry.swift
+    @ObservedObject var telemetryEnabled = Storage.shared.telemetryEnabled
+
     var body: some View {
         NavigationView {
             Form {
@@ -132,6 +135,17 @@ struct GeneralSettingsView: View {
                         }
                     }
                 }
+
+                Section("Diagnostics") {
+                    Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value)
+                        .onChange(of: telemetryEnabled.value) { newValue in
+                            if newValue {
+                                TelemetryClient.shared.scheduleRecurring()
+                            }
+                        }
+                    NavigationLink("What's sent") { TelemetryPreviewView() }
+                    NavigationLink("Privacy") { TelemetryPrivacyView() }
+                }
             }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)

+ 12 - 0
LoopFollow/Storage/Storage.swift

@@ -182,6 +182,18 @@ class Storage {
     var lastVersionUpdateNotificationShown = StorageValue<Date?>(key: "lastVersionUpdateNotificationShown", defaultValue: nil)
     var lastExpirationNotificationShown = StorageValue<Date?>(key: "lastExpirationNotificationShown", defaultValue: nil)
 
+    // MARK: - Telemetry -----------------------------------------------------------
+
+    // See LoopFollow/Helpers/Telemetry.swift.
+
+    var telemetryEnabled = StorageValue<Bool>(key: "telemetryEnabled", defaultValue: true)
+    var telemetryConsentDecisionMade = StorageValue<Bool>(key: "telemetryConsentDecisionMade", defaultValue: false)
+    var telemetryLastSentAt = StorageValue<Date?>(key: "telemetryLastSentAt", defaultValue: nil)
+    var telemetryLastSentSha = StorageValue<String>(key: "telemetryLastSentSha", defaultValue: "")
+
+    // Sliding 7-day window of cold-launch timestamps.
+    var telemetryColdLaunchTimes = StorageValue<[Date]>(key: "telemetryColdLaunchTimes", defaultValue: [])
+
     var hideInfoTable = StorageValue<Bool>(key: "hideInfoTable", defaultValue: false)
     var token = StorageValue<String>(key: "token", defaultValue: "")
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")

+ 1 - 0
LoopFollow/Task/TaskScheduler.swift

@@ -12,6 +12,7 @@ enum TaskID: CaseIterable {
     case minAgoUpdate
     case calendarWrite
     case alarmCheck
+    case telemetry
 }
 
 struct ScheduledTask {