diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 4c2444734..f7335eadc 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/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 = ""; }; FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = ""; }; FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = ""; }; @@ -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 */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..7bfb3b604 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/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. diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 882db04e6..526eb7a78 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/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) { diff --git a/LoopFollow/Helpers/BuildDetails.swift b/LoopFollow/Helpers/BuildDetails.swift index 053466dba..a985d6dbd 100644 --- a/LoopFollow/Helpers/BuildDetails.swift +++ b/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)" } diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift new file mode 100644 index 000000000..83005456e --- /dev/null +++ b/LoopFollow/Helpers/Telemetry.swift @@ -0,0 +1,399 @@ +// LoopFollow +// Telemetry.swift + +import CryptoKit +import Foundation +import SwiftUI +import UIKit + +// MARK: - TelemetryClient + +final class TelemetryClient { + static let shared = TelemetryClient() + + private static let endpoint = URL(string: "https://lf.bjorkert.se/api/telemetry/checkin")! + private static let salt = "lf-telemetry" + private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60 + private static let dailyInterval: TimeInterval = 24 * 60 * 60 + + private init() {} + + /// Records a cold launch in a sliding 7-day window of timestamps. Called + /// from AppDelegate.didFinishLaunchingWithOptions on every process start + /// (foreground or background). The count of entries in the window is sent + /// as `coldLaunches7d` in each ping, giving a "how often is iOS recycling + /// or killing this process" signal that's directly comparable across + /// pings regardless of the cadence between them. + func recordColdLaunch(now: Date = Date()) { + let cutoff = now.addingTimeInterval(-Self.weeklyInterval) + var recent = Storage.shared.telemetryColdLaunchTimes.value + recent.removeAll { $0 < cutoff } + recent.append(now) + Storage.shared.telemetryColdLaunchTimes.value = recent + } + + /// Static write token, committed in source. The LoopFollow repo is public, + /// so this string is public too. The backend treats it as a "front door + /// sign" rather than a secret: TLS, NGINX rate limit (60 req/min/IP), + /// strict schema validation, and an insert+find-only MongoDB role bound + /// any abuse to harmless duplicate-row inserts. + private static let writeToken = "RsEDJ8RoOs7HHZ_XGOdI1sY3Yuv6iPnRRk7tg-NlCAg" + + /// 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 regular 24h scheduler can't tell that + /// the build changed and would otherwise wait out the previous interval. + func buildShaChangedSinceLastSend() -> Bool { + let currentSha = BuildDetails.default.commitSha ?? "" + return Storage.shared.telemetryLastSentSha.value != currentSha + } + + /// Wires telemetry into TaskScheduler. Called once on app start (from + /// AppDelegate.didFinishLaunchingWithOptions) and again after each tick. + /// First run is computed from `telemetryLastSentAt`: a relaunch 6h after + /// the previous send waits 18h; a relaunch after 25h fires on the next + /// timer tick (TaskScheduler treats a past nextRun as "fire soon"). Each + /// fired tick reschedules itself for +24h, giving the steady-state + /// cadence while the app keeps running. + /// + /// 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, and + /// scheduling for "now" with `lastSentAt` still nil would tight-loop + /// (fire → maybeSend bails → reschedule → fire …). + func scheduleRecurring() { + let storage = Storage.shared + guard storage.telemetryConsentDecisionMade.value, + storage.telemetryEnabled.value + else { + return + } + + let nextRun: Date + if let last = storage.telemetryLastSentAt.value { + nextRun = last.addingTimeInterval(Self.dailyInterval) + } else { + // Consent given but we've never landed a successful send + // (network down at first launch, server hiccup, etc). Retry in + // a minute — bounded so a persistently failing send doesn't + // turn into a busy loop. + nextRun = Date().addingTimeInterval(60) + } + + TaskScheduler.shared.scheduleTask(id: .telemetry, nextRun: nextRun) { + Task.detached { + await TelemetryClient.shared.maybeSend() + TelemetryClient.shared.scheduleRecurring() + } + } + } + + /// Single entry point used by all callers (scheduler tick, consent-yes, + /// startup SHA-change). Gated only by consent + opt-in toggle; *when* to + /// send is the caller's decision (the scheduler handles the 24h cadence + /// by setting `nextRun`; startup handles the SHA-change shortcut). + func maybeSend() async { + let storage = Storage.shared + guard storage.telemetryConsentDecisionMade.value else { return } + guard storage.telemetryEnabled.value else { return } + await send() + } + + /// The exact payload that would be POSTed right now. Pure function: useful + /// both for sending and for the "What's sent" preview UI. + func buildPayload() -> [String: Any] { + let storage = Storage.shared + let info = Bundle.main.infoDictionary ?? [:] + let bd = BuildDetails.default + + var payload: [String: Any] = [:] + + if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v } + if let v = info["CFBundleVersion"] as? String { payload["buildNumber"] = v } + + if let branch = bd.branch { payload["buildBranch"] = branch } + if let sha = bd.commitSha { payload["buildSha"] = sha } + if let date = bd.buildDateString { payload["buildDate"] = date } + + // Only signal we can actually verify: receipt-based TestFlight check. + // macCatalyst is covered by `platform`; simulator is covered by the + // `Simulator …` prefix on `device`. Anything else is a local Xcode + // build (browser-build), which is just "isTestFlight == false". + payload["isTestFlight"] = bd.isTestFlightBuild() + + payload["instance"] = AppConstants.appInstanceId + + if let idfv = UIDevice.current.identifierForVendor?.uuidString { + payload["idfv"] = idfv + } + + payload["device"] = Self.hardwareIdentifier() + payload["platform"] = Self.detectPlatform() + payload["osVersion"] = UIDevice.current.systemVersion + + let dexcomUser = storage.shareUserName.value.trimmingCharacters(in: .whitespacesAndNewlines) + payload["usesDexcom"] = !dexcomUser.isEmpty + + let nsURLRaw = storage.url.value.trimmingCharacters(in: .whitespacesAndNewlines) + payload["usesNightscout"] = !nsURLRaw.isEmpty + + // Which closed-loop app is being followed (Loop / Trio / …). Field + // omitted when device hasn't been detected yet; absence is the signal. + let device = storage.device.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !device.isEmpty { + payload["followingApp"] = device + } + + payload["backgroundRefreshMethod"] = storage.backgroundRefreshType.value.rawValue + + // Selected user-preference fields. Picked for product-decision value; + // none reveal personal or health information. + payload["units"] = storage.units.value // "mg/dL" / "mmol/L" + payload["remoteType"] = storage.remoteType.value.rawValue // which remote-command path + payload["appearanceMode"] = storage.appearanceMode.value.rawValue // light / dark / system + payload["contactEnabled"] = storage.contactEnabled.value // Contacts integration on? + payload["calendarEnabled"] = !storage.calendarIdentifier.value.isEmpty // calendar selected? + + payload["coldLaunches7d"] = storage.telemetryColdLaunchTimes.value.count + + return payload + } + + /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget; + /// errors are logged at debug level only and never surfaced to the UI. + func send() async { + let storage = Storage.shared + let payload = buildPayload() + guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { + LogManager.shared.log(category: .telemetry, message: "skip send: payload not JSON-serializable", isDebug: true) + return + } + + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization") + request.httpBody = body + request.timeoutInterval = 15 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + LogManager.shared.log(category: .telemetry, message: "send: non-HTTP response", isDebug: true) + return + } + if (200 ..< 300).contains(http.statusCode) { + let now = Date() + let sha = (payload["buildSha"] as? String) ?? "" + storage.telemetryLastSentAt.value = now + storage.telemetryLastSentSha.value = sha + LogManager.shared.log(category: .telemetry, message: "send ok status=\(http.statusCode)", isDebug: true) + } else { + LogManager.shared.log(category: .telemetry, message: "send non-2xx status=\(http.statusCode)", isDebug: true) + } + } catch { + LogManager.shared.log(category: .telemetry, message: "send error: \(error.localizedDescription)", isDebug: true) + } + } + + // MARK: - Helpers + + /// Salted SHA-256, truncated to 16 hex chars (64 bits). + static func hashed(_ raw: String) -> String { + let canonical = raw.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + let input = Data((salt + canonical).utf8) + let digest = SHA256.hash(data: input) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + + /// `iPhone15,2`-style identifier from `utsname.machine`. Returns + /// `Simulator ` 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 + } +} + +// MARK: - TelemetryPreviewView + +/// Renders the exact payload that would be sent right now, with a copy +/// button. Linked to from the Diagnostics section in Settings and from the +/// consent sheet's "See exactly what's sent" button. +struct TelemetryPreviewView: View { + @State private var jsonText: String = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Below is the exact JSON object that LoopFollow would send to lf.bjorkert.se right now.") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 4) + + Text(jsonText) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(6) + + Button { + UIPasteboard.general.string = jsonText + } label: { + Label("Copy JSON", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + } + .padding() + } + .navigationTitle("What's sent") + .navigationBarTitleDisplayMode(.inline) + .onAppear { jsonText = Self.renderPayload() } + } + + private static func renderPayload() -> String { + let payload = TelemetryClient.shared.buildPayload() + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { return "Unable to render payload." } + return text + } +} + +// MARK: - TelemetryPrivacyView + +/// In-app summary so users don't have to leave the app to understand +/// what is collected. +struct TelemetryPrivacyView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Group { + Text("Endpoint") + .font(.headline) + Text("Once a day (or after a new build), the app sends a small JSON object to https://lf.bjorkert.se. The endpoint is self-hosted by the maintainer; no third-party analytics service is involved.") + } + + Group { + Text("What is sent") + .font(.headline) + Text("App version, build SHA and date, whether this is a TestFlight build, the install instance number, an Apple-supplied per-vendor identifier (IDFV) that resets when all this developer's apps are removed from the device, the hardware identifier (e.g. iPhone15,2), and iOS version. Whether Nightscout and Dexcom are configured (yes/no — no URLs or usernames). Which app you're following (Loop, Trio, etc), if known. A small set of preference flags (units, appearance mode, calendar/contact integration enabled, remote-command type, background refresh method). The full JSON is visible under Diagnostics → What's sent.") + } + + Group { + Text("What stays on your device") + .font(.headline) + Text("All glucose, insulin, and carb data. Your Nightscout URL and API token. Your Dexcom credentials. Remote-command secrets and APNS keys. Time zone. Location data. Logs — these are never sent automatically; the Settings → Logs sharing flow is unchanged and only triggered by you.") + } + + Group { + Text("Frequency") + .font(.headline) + Text("Once every 24 hours, plus once after installing a new build. The check runs in the background while the app is active or refreshing in the background.") + } + + Group { + Text("Opt out") + .font(.headline) + Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent.") + } + + Group { + Text("Source") + .font(.headline) + Text("LoopFollow/Helpers/Telemetry.swift on GitHub.") + } + } + .padding() + } + .navigationTitle("Privacy") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - TelemetryConsentView + +/// One-time prompt shown the first time the app foregrounds after install +/// or after an update from a pre-telemetry version. +struct TelemetryConsentView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.") + + Text("You can change this any time in Settings → Diagnostics.") + .font(.subheadline) + .foregroundColor(.secondary) + + NavigationLink { + TelemetryPreviewView() + } label: { + Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass") + } + .padding(.top, 4) + } + .padding() + } + .navigationTitle("Help us help you!") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + Button { + Storage.shared.telemetryEnabled.value = true + Storage.shared.telemetryConsentDecisionMade.value = true + // Fire the inaugural ping immediately, then start the + // 24h scheduled cadence ticking from that send. + Task.detached { + await TelemetryClient.shared.maybeSend() + TelemetryClient.shared.scheduleRecurring() + } + dismiss() + } label: { + Text("Yes, send anonymous stats") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button { + Storage.shared.telemetryEnabled.value = false + Storage.shared.telemetryConsentDecisionMade.value = true + dismiss() + } label: { + Text("No thanks") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.bottom, 12) + .background(.bar) + } + } + } +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..d4625d1d8 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -29,6 +29,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case telemetry = "Telemetry" } init() { diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 93b7c8f4f..008407a8c 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/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) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 5dea5ebb1..be9504ef1 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -182,6 +182,18 @@ class Storage { var lastVersionUpdateNotificationShown = StorageValue(key: "lastVersionUpdateNotificationShown", defaultValue: nil) var lastExpirationNotificationShown = StorageValue(key: "lastExpirationNotificationShown", defaultValue: nil) + // MARK: - Telemetry ----------------------------------------------------------- + + // See LoopFollow/Helpers/Telemetry.swift. + + var telemetryEnabled = StorageValue(key: "telemetryEnabled", defaultValue: true) + var telemetryConsentDecisionMade = StorageValue(key: "telemetryConsentDecisionMade", defaultValue: false) + var telemetryLastSentAt = StorageValue(key: "telemetryLastSentAt", defaultValue: nil) + var telemetryLastSentSha = StorageValue(key: "telemetryLastSentSha", defaultValue: "") + + // Sliding 7-day window of cold-launch timestamps. + var telemetryColdLaunchTimes = StorageValue<[Date]>(key: "telemetryColdLaunchTimes", defaultValue: []) + var hideInfoTable = StorageValue(key: "hideInfoTable", defaultValue: false) var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index a18b92aa3..51588fb7a 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -12,6 +12,7 @@ enum TaskID: CaseIterable { case minAgoUpdate case calendarWrite case alarmCheck + case telemetry } struct ScheduledTask {