From 3c749a96a607db11b1031098ff919ea4c57f08ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 10:30:44 +0200 Subject: [PATCH 1/5] Add anonymous telemetry Opt-out (with first-foreground prompt) weekly check-in to https://lf.bjorkert.se/api/telemetry/checkin. Trigger fires from both AppDelegate.didFinishLaunchingWithOptions (covers background launches) and SceneDelegate.sceneDidBecomeActive (covers foregrounds), keeping cadence honest for follower-style usage where the app is rarely opened. Payload covers app/iOS/device version, build origin (TestFlight or not), time zone, and a few selected settings (units, remoteType, appearanceMode, contactEnabled, calendarEnabled, backgroundRefreshMethod). Salted-and-truncated SHA-256 hashes of Dexcom username and Nightscout host are sent only when those backends are configured. A sliding 7-day cold-launch counter rides along as a stability signal. No glucose, insulin, carbs, Nightscout URL/token, Dexcom credentials, or logs leave the device. Settings -> Diagnostics has the toggle, a privacy summary, and a "What's sent" inspector that renders the exact JSON about to be posted. --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Application/AppDelegate.swift | 11 + LoopFollow/Application/SceneDelegate.swift | 45 ++ LoopFollow/Helpers/BuildDetails.swift | 6 +- LoopFollow/Helpers/Telemetry.swift | 403 ++++++++++++++++++ LoopFollow/Log/LogManager.swift | 1 + LoopFollow/Settings/GeneralSettingsView.swift | 9 + LoopFollow/Storage/Storage.swift | 13 + 8 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 LoopFollow/Helpers/Telemetry.swift 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..9466549e1 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,6 +48,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundRefreshManager.shared.register() + // Telemetry: every cold launch (foreground or background) is a chance + // to send a check-in. Internally gated by consent, the toggle, and the + // 7-day / build-SHA trigger. Hooking didFinishLaunchingWithOptions in + // addition to SceneDelegate.sceneDidBecomeActive ensures background + // launches (silent push wake, BG app refresh) keep the cadence honest + // for users who rarely foreground the app — important for a follower + // app whose users mostly read state via widgets / live activities. + // See Helpers/Telemetry.swift. + TelemetryClient.shared.recordColdLaunch() + Task.detached { await TelemetryClient.shared.maybeSend() } + // 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..baf046e57 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -2,12 +2,20 @@ // 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. The send + /// check itself is idempotent — `TelemetryClient.maybeSend()` rate-limits + /// via telemetryLastSentAt and a re-entrancy lock — so it can fire from + /// every activation without harm. + 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 +40,43 @@ 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() + } + + /// Telemetry trigger. See Helpers/Telemetry.swift. + /// On every foreground, runs `maybeSend()` (which is idempotent and + /// internally rate-limited). If consent has not yet been recorded, + /// presents the one-time consent sheet exactly once per process. + private func runTelemetryFirstForegroundHook() { + let storage = Storage.shared + + if !storage.telemetryConsentDecisionMade.value { + if !consentPromptShownThisProcess { + consentPromptShownThisProcess = true + presentTelemetryConsentSheet() + } + return + } + + Task.detached { await TelemetryClient.shared.maybeSend() } + } + + 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..0d95b820f --- /dev/null +++ b/LoopFollow/Helpers/Telemetry.swift @@ -0,0 +1,403 @@ +// 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 + + /// Lazily generates and persists the install's permanent clientId on + /// first construction. `Storage.telemetryClientId` is nil until this + /// runs; assigning to .value goes through StorageValue's didSet, which + /// is what actually writes to UserDefaults. + private init() { + let storage = Storage.shared + if storage.telemetryClientId.value == nil { + storage.telemetryClientId.value = UUID().uuidString + } + } + + /// 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" + + /// Re-entrancy lock so concurrent call sites (e.g. AppDelegate cold-launch + /// hook + SceneDelegate foreground hook firing in the same activation) + /// can't both POST. The first one through atomically flips this true; the + /// second sees it and bails. Reset in a `defer` so any path through `send` + /// — success, failure, throw — clears it. + private let sendingLock = NSLock() + private var isSending = false + + /// Returns true if the configured trigger conditions are met (weekly elapsed + /// or build SHA changed since the last successful send). + func shouldSendNow(now: Date = Date()) -> Bool { + let storage = Storage.shared + let weekElapsed = storage.telemetryLastSentAt.value + .map { now.timeIntervalSince($0) > Self.weeklyInterval } ?? true + let currentSha = BuildDetails.default.commitSha ?? "" + let buildChanged = storage.telemetryLastSentSha.value != currentSha + return weekElapsed || buildChanged + } + + /// Single entry point used by all triggers (cold launch, foreground, etc). + /// Skips silently if telemetry is disabled, consent isn't yet recorded, or + /// trigger conditions aren't met. Safe to call from any thread; concurrent + /// calls collapse into one network request via `sendingLock`. + func maybeSend() async { + let storage = Storage.shared + guard storage.telemetryConsentDecisionMade.value else { return } + guard storage.telemetryEnabled.value else { return } + guard shouldSendNow() else { return } + + sendingLock.lock() + if isSending { + sendingLock.unlock() + return + } + isSending = true + sendingLock.unlock() + + defer { + sendingLock.lock() + isSending = false + sendingLock.unlock() + } + + 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] = [:] + + // Guaranteed non-nil after TelemetryClient.shared has been constructed + // — see private init(). Empty fallback is defensive; the server's + // UUID regex would reject an empty string with 400, surfacing the + // invariant break via the reject-rate Telegram alert. + payload["clientId"] = storage.telemetryClientId.value ?? "" + + 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() + + if let team = bd.teamID, !team.isEmpty { + payload["hashedTeamId"] = Self.hashed(team) + } + + payload["instance"] = AppConstants.appInstanceId + + if let idfv = UIDevice.current.identifierForVendor?.uuidString { + payload["hashedIDFV"] = Self.hashed(idfv) + } + + payload["device"] = Self.hardwareIdentifier() + payload["platform"] = Self.detectPlatform() + payload["osVersion"] = UIDevice.current.systemVersion + payload["timeZone"] = TimeZone.current.identifier + + // hashedDexcomAccount / hashedNightscoutHost are sent ONLY when those + // backends are configured. Their presence-or-absence is itself the + // "do you use Dexcom / Nightscout?" signal — no separate booleans. + + let dexcomUser = storage.shareUserName.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !dexcomUser.isEmpty { + payload["hashedDexcomAccount"] = Self.hashed(dexcomUser) + } + + let nsURLRaw = storage.url.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !nsURLRaw.isEmpty, let host = URL(string: nsURLRaw)?.host, !host.isEmpty { + payload["hashedNightscoutHost"] = Self.hashed(host) + } + + 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 week (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 Apple development team that signed this build (anonymized), the install instance number, a per-device anonymized identifier, the hardware identifier (e.g. iPhone15,2), iOS version, and time zone. An anonymized identifier for your Nightscout site and your Dexcom username is also sent — but only when those are configured. 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. 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 7 days, plus once after each new build. The check runs on every app launch (including silent-push wake-ups and background app refresh) and on every foreground. Whichever launch is first eligible will send.") + } + + 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, time zone, and a few settings. Your health data, credentials, 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 one ping right away so the chosen-yes state isn't + // delayed until the next foreground / cold launch. + Task.detached { await TelemetryClient.shared.maybeSend() } + 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..4b9402541 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,12 @@ struct GeneralSettingsView: View { } } } + + Section("Diagnostics") { + Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) + 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..e316c9993 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -182,6 +182,19 @@ 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 telemetryClientId = StorageValue(key: "telemetryClientId", defaultValue: nil) + 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") From 5b59c2340f6d141c35092c2f1634349f6379ef7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 21:14:47 +0200 Subject: [PATCH 2/5] Telemetry: address privacy review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop hashed Nightscout host and hashed Dexcom username; replace with usesNightscout / usesDexcom booleans. The salt is committed in source, so a hashed NS URL is reverse-lookupable from a forum post, and the hashed host is effectively a patient identifier the patient never consented to. - Drop timeZone from the payload. - Send IDFV raw rather than hashed — it's already an opaque per-vendor UUID, hashing it with a public salt is purely cosmetic. - Add followingApp (Loop / Trio / ...), omitted when device isn't yet known. - Switch cadence from "weekly + every cold launch + on build change" to once per 24h via TaskScheduler. Startup fires one immediate ping when the build SHA changed since the last successful send. The settings toggle re-arms the scheduler when flipped from off to on. --- LoopFollow/Application/AppDelegate.swift | 22 ++-- LoopFollow/Helpers/Telemetry.swift | 121 ++++++++++-------- LoopFollow/Settings/GeneralSettingsView.swift | 5 + LoopFollow/Task/TaskScheduler.swift | 1 + 4 files changed, 87 insertions(+), 62 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 9466549e1..7bfb3b604 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,16 +48,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundRefreshManager.shared.register() - // Telemetry: every cold launch (foreground or background) is a chance - // to send a check-in. Internally gated by consent, the toggle, and the - // 7-day / build-SHA trigger. Hooking didFinishLaunchingWithOptions in - // addition to SceneDelegate.sceneDidBecomeActive ensures background - // launches (silent push wake, BG app refresh) keep the cadence honest - // for users who rarely foreground the app — important for a follower - // app whose users mostly read state via widgets / live activities. - // See Helpers/Telemetry.swift. + // 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 { await TelemetryClient.shared.maybeSend() } + 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 diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 0d95b820f..363187c7f 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -14,6 +14,7 @@ final class 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 /// Lazily generates and persists the install's permanent clientId on /// first construction. `Storage.telemetryClientId` is nil until this @@ -47,49 +48,62 @@ final class TelemetryClient { /// any abuse to harmless duplicate-row inserts. private static let writeToken = "RsEDJ8RoOs7HHZ_XGOdI1sY3Yuv6iPnRRk7tg-NlCAg" - /// Re-entrancy lock so concurrent call sites (e.g. AppDelegate cold-launch - /// hook + SceneDelegate foreground hook firing in the same activation) - /// can't both POST. The first one through atomically flips this true; the - /// second sees it and bails. Reset in a `defer` so any path through `send` - /// — success, failure, throw — clears it. - private let sendingLock = NSLock() - private var isSending = false - - /// Returns true if the configured trigger conditions are met (weekly elapsed - /// or build SHA changed since the last successful send). - func shouldSendNow(now: Date = Date()) -> Bool { - let storage = Storage.shared - let weekElapsed = storage.telemetryLastSentAt.value - .map { now.timeIntervalSince($0) > Self.weeklyInterval } ?? true + /// 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 ?? "" - let buildChanged = storage.telemetryLastSentSha.value != currentSha - return weekElapsed || buildChanged + return Storage.shared.telemetryLastSentSha.value != currentSha } - /// Single entry point used by all triggers (cold launch, foreground, etc). - /// Skips silently if telemetry is disabled, consent isn't yet recorded, or - /// trigger conditions aren't met. Safe to call from any thread; concurrent - /// calls collapse into one network request via `sendingLock`. - func maybeSend() async { + /// 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 else { return } - guard storage.telemetryEnabled.value else { return } - guard shouldSendNow() else { return } - - sendingLock.lock() - if isSending { - sendingLock.unlock() + guard storage.telemetryConsentDecisionMade.value, + storage.telemetryEnabled.value + else { return } - isSending = true - sendingLock.unlock() - defer { - sendingLock.lock() - isSending = false - sendingLock.unlock() + 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() } @@ -128,26 +142,24 @@ final class TelemetryClient { payload["instance"] = AppConstants.appInstanceId if let idfv = UIDevice.current.identifierForVendor?.uuidString { - payload["hashedIDFV"] = Self.hashed(idfv) + payload["idfv"] = idfv } payload["device"] = Self.hardwareIdentifier() payload["platform"] = Self.detectPlatform() payload["osVersion"] = UIDevice.current.systemVersion - payload["timeZone"] = TimeZone.current.identifier - - // hashedDexcomAccount / hashedNightscoutHost are sent ONLY when those - // backends are configured. Their presence-or-absence is itself the - // "do you use Dexcom / Nightscout?" signal — no separate booleans. let dexcomUser = storage.shareUserName.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !dexcomUser.isEmpty { - payload["hashedDexcomAccount"] = Self.hashed(dexcomUser) - } + payload["usesDexcom"] = !dexcomUser.isEmpty let nsURLRaw = storage.url.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !nsURLRaw.isEmpty, let host = URL(string: nsURLRaw)?.host, !host.isEmpty { - payload["hashedNightscoutHost"] = Self.hashed(host) + 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 @@ -301,25 +313,25 @@ struct TelemetryPrivacyView: View { Group { Text("Endpoint") .font(.headline) - Text("Once a week (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.") + 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 Apple development team that signed this build (anonymized), the install instance number, a per-device anonymized identifier, the hardware identifier (e.g. iPhone15,2), iOS version, and time zone. An anonymized identifier for your Nightscout site and your Dexcom username is also sent — but only when those are configured. The full JSON is visible under Diagnostics → What's sent.") + Text("App version, build SHA and date, whether this is a TestFlight build, the Apple development team that signed this build (anonymized), 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. Location data. Logs — these are never sent automatically; the Settings → Logs sharing flow is unchanged and only triggered by you.") + 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 7 days, plus once after each new build. The check runs on every app launch (including silent-push wake-ups and background app refresh) and on every foreground. Whichever launch is first eligible will send.") + 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 { @@ -352,7 +364,7 @@ struct TelemetryConsentView: 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, time zone, and a few settings. Your health data, credentials, and logs remain on your device.") + 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) @@ -374,9 +386,12 @@ struct TelemetryConsentView: View { Button { Storage.shared.telemetryEnabled.value = true Storage.shared.telemetryConsentDecisionMade.value = true - // Fire one ping right away so the chosen-yes state isn't - // delayed until the next foreground / cold launch. - Task.detached { await TelemetryClient.shared.maybeSend() } + // 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") diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 4b9402541..008407a8c 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -138,6 +138,11 @@ 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() } } 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 { From 53c4b24d53101768b558bf7bbcbb4b4bb26a3a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 21:19:39 +0200 Subject: [PATCH 3/5] Telemetry: drop clientId IDFV (sent raw) plus the existing instance field already gives a stable per-install identifier, so the separate generated UUID is redundant. One fewer stable identifier in the wire. --- LoopFollow/Helpers/Telemetry.swift | 17 +---------------- LoopFollow/Storage/Storage.swift | 1 - 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 363187c7f..043d43302 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -16,16 +16,7 @@ final class TelemetryClient { private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60 private static let dailyInterval: TimeInterval = 24 * 60 * 60 - /// Lazily generates and persists the install's permanent clientId on - /// first construction. `Storage.telemetryClientId` is nil until this - /// runs; assigning to .value goes through StorageValue's didSet, which - /// is what actually writes to UserDefaults. - private init() { - let storage = Storage.shared - if storage.telemetryClientId.value == nil { - storage.telemetryClientId.value = UUID().uuidString - } - } + private init() {} /// Records a cold launch in a sliding 7-day window of timestamps. Called /// from AppDelegate.didFinishLaunchingWithOptions on every process start @@ -116,12 +107,6 @@ final class TelemetryClient { var payload: [String: Any] = [:] - // Guaranteed non-nil after TelemetryClient.shared has been constructed - // — see private init(). Empty fallback is defensive; the server's - // UUID regex would reject an empty string with 400, surfacing the - // invariant break via the reject-rate Telegram alert. - payload["clientId"] = storage.telemetryClientId.value ?? "" - if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v } if let v = info["CFBundleVersion"] as? String { payload["buildNumber"] = v } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index e316c9993..be9504ef1 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -188,7 +188,6 @@ class Storage { var telemetryEnabled = StorageValue(key: "telemetryEnabled", defaultValue: true) var telemetryConsentDecisionMade = StorageValue(key: "telemetryConsentDecisionMade", defaultValue: false) - var telemetryClientId = StorageValue(key: "telemetryClientId", defaultValue: nil) var telemetryLastSentAt = StorageValue(key: "telemetryLastSentAt", defaultValue: nil) var telemetryLastSentSha = StorageValue(key: "telemetryLastSentSha", defaultValue: "") From 3758a4b27d909adff0ac04b396c6ffe55ae90a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 21:51:15 +0200 Subject: [PATCH 4/5] Telemetry: stop firing maybeSend from sceneDidBecomeActive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppDelegate handles the launch-time SHA-change shortcut and TaskScheduler handles the 24h cadence; the foreground hook also calling maybeSend produced two pings per cold launch (and two first-seen Telegram alerts). The hook keeps its other job — presenting the consent sheet on first foreground when the user hasn't decided yet. --- LoopFollow/Application/SceneDelegate.swift | 27 ++++++++-------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index baf046e57..526eb7a78 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -10,10 +10,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let synthesizer = AVSpeechSynthesizer() /// One-shot guard so the consent prompt is only attempted once per - /// process lifetime even if the scene activates repeatedly. The send - /// check itself is idempotent — `TelemetryClient.maybeSend()` rate-limits - /// via telemetryLastSentAt and a re-entrancy lock — so it can fire from - /// every activation without harm. + /// process lifetime even if the scene activates repeatedly. private var consentPromptShownThisProcess = false func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { @@ -43,22 +40,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { runTelemetryFirstForegroundHook() } - /// Telemetry trigger. See Helpers/Telemetry.swift. - /// On every foreground, runs `maybeSend()` (which is idempotent and - /// internally rate-limited). If consent has not yet been recorded, - /// presents the one-time consent sheet exactly once per process. + /// 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() { - let storage = Storage.shared - - if !storage.telemetryConsentDecisionMade.value { - if !consentPromptShownThisProcess { - consentPromptShownThisProcess = true - presentTelemetryConsentSheet() - } - return + if !Storage.shared.telemetryConsentDecisionMade.value, + !consentPromptShownThisProcess + { + consentPromptShownThisProcess = true + presentTelemetryConsentSheet() } - - Task.detached { await TelemetryClient.shared.maybeSend() } } private func presentTelemetryConsentSheet() { From 98f63803979e9384940352fe9eb4c43f7fe6db44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 30 Apr 2026 08:02:52 +0200 Subject: [PATCH 5/5] Telemetry: drop hashedTeamId Browser-build vs TestFlight is already covered by isTestFlight; the hashed team ID added a stable per-builder fingerprint without enough analytic value to justify the privacy cost. --- LoopFollow/Helpers/Telemetry.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 043d43302..83005456e 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -120,10 +120,6 @@ final class TelemetryClient { // build (browser-build), which is just "isTestFlight == false". payload["isTestFlight"] = bd.isTestFlightBuild() - if let team = bd.teamID, !team.isEmpty { - payload["hashedTeamId"] = Self.hashed(team) - } - payload["instance"] = AppConstants.appInstanceId if let idfv = UIDevice.current.identifierForVendor?.uuidString { @@ -304,7 +300,7 @@ struct TelemetryPrivacyView: View { Group { Text("What is sent") .font(.headline) - Text("App version, build SHA and date, whether this is a TestFlight build, the Apple development team that signed this build (anonymized), 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.") + 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 {