From 7ddb049a5e7a633a3163ff965d595c9972a0c8fc Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 28 Oct 2026 11:49:44 -0500 Subject: [PATCH 1/2] Share outcomes with Loop developers from Current Setup Once the current setup has run for 2 weeks, the Current Setup screen links to a Share Outcomes screen. It lists what's sent and what isn't, prepares the package, shows its size and sends it to ingest.loopkit.org/v1/outcomes only when the person taps Send. Each share's receipt is kept so it can be deleted from the server later from the same screen. The package is a zip of per-day JSON files covering the last 6 weeks: settings, glucose, carbs, doses, dosing decisions and overrides, plus the settings and override in effect when the range starts, and a manifest with the app build, the last setup change and a random per-install ID. The device log and alerts are left out. Records are scrubbed at the JSON level: push token, device names and serial numbers, pump event bytes and descriptions, food descriptions and the dosing decisions' historicalGlucose are removed, and the updateRemoteRecommendation decisions are skipped. On a real 6-week export this takes the package from about 58 MB to about 15 MB. The delete token is made on the device and sent with the share, so a retry after a lost response gets back the receipt of the share already stored. --- Loop.xcodeproj/project.pbxproj | 20 ++ Loop/Managers/LoopAppManager.swift | 1 + Loop/Models/OutcomesPackage.swift | 297 ++++++++++++++++++ Loop/Models/OutcomesShareClient.swift | 176 +++++++++++ Loop/View Models/CurrentSetupViewModel.swift | 3 + Loop/View Models/SettingsViewModel.swift | 2 + Loop/View Models/ShareOutcomesViewModel.swift | 148 +++++++++ Loop/Views/CurrentSetupView.swift | 8 + Loop/Views/SettingsView.swift | 3 +- Loop/Views/ShareOutcomesView.swift | 165 ++++++++++ LoopTests/Models/OutcomesPackageTests.swift | 145 +++++++++ 11 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 Loop/Models/OutcomesPackage.swift create mode 100644 Loop/Models/OutcomesShareClient.swift create mode 100644 Loop/View Models/ShareOutcomesViewModel.swift create mode 100644 Loop/Views/ShareOutcomesView.swift create mode 100644 LoopTests/Models/OutcomesPackageTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index aaaeaf19c3..51a9e80069 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -535,12 +535,17 @@ C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */; }; 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CD655083D4816F87B13463 /* SetupChange.swift */; }; + 7F8C56209DE2227C701527B9 /* OutcomesPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F7AB4EB9F6CCDF1593CB2E /* OutcomesPackage.swift */; }; + 79DC7BEA0316756B5C8BE749 /* OutcomesShareClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275032AC3D26F4EC5B6DEA15 /* OutcomesShareClient.swift */; }; + E4E2B3856D5DA256AA09C31E /* ShareOutcomesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A13D8D000431C52A5899D1E /* ShareOutcomesViewModel.swift */; }; + 69C4F11D6759B7FED4F68BC4 /* ShareOutcomesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA918E086710427153DA902 /* ShareOutcomesView.swift */; }; 2622474F63F391C3E102B5E5 /* SetupComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */; }; 16097A41A1EF24BCF2F670DB /* Statistics/SetupComparisonCharts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */; }; 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */; }; D7DECBB118052B85680A241B /* SetupComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */; }; 6D16FF26E7F84F97B5DB6A88 /* CurrentSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */; }; D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */; }; + 5B0E7D2A91C34F68A2D1E401 /* OutcomesPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F8E3BA2D45079B3E2F512 /* OutcomesPackageTests.swift */; }; C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; C1735B1E2A0809830082BB8A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = C1735B1D2A0809830082BB8A /* ZIPFoundation */; }; @@ -1382,12 +1387,17 @@ C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = ""; }; 12CD655083D4816F87B13463 /* SetupChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChange.swift; sourceTree = ""; }; + 22F7AB4EB9F6CCDF1593CB2E /* OutcomesPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesPackage.swift; sourceTree = ""; }; + 275032AC3D26F4EC5B6DEA15 /* OutcomesShareClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesShareClient.swift; sourceTree = ""; }; + 3A13D8D000431C52A5899D1E /* ShareOutcomesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareOutcomesViewModel.swift; sourceTree = ""; }; + 0BA918E086710427153DA902 /* ShareOutcomesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareOutcomesView.swift; sourceTree = ""; }; BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupComparisonTests.swift; sourceTree = ""; }; 174DD37A96993576E0E573C6 /* Statistics/SetupComparisonCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Statistics/SetupComparisonCharts.swift; sourceTree = ""; }; 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSetupViewModel.swift; sourceTree = ""; }; D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupComparison.swift; sourceTree = ""; }; CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentSetupView.swift; sourceTree = ""; }; 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupChangeTests.swift; sourceTree = ""; }; + 6C1F8E3BA2D45079B3E2F512 /* OutcomesPackageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesPackageTests.swift; sourceTree = ""; }; C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = ""; }; @@ -1834,6 +1844,8 @@ C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, 12CD655083D4816F87B13463 /* SetupChange.swift */, + 22F7AB4EB9F6CCDF1593CB2E /* OutcomesPackage.swift */, + 275032AC3D26F4EC5B6DEA15 /* OutcomesShareClient.swift */, D5E7CDB17CCE70885EE93850 /* SetupComparison.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, @@ -2163,6 +2175,7 @@ C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, CFD4A26A23202AF2EA506FCF /* CurrentSetupView.swift */, + 0BA918E086710427153DA902 /* ShareOutcomesView.swift */, FEEDF0000000000000000004 /* Statistics/TimeInRangeBar.swift */, 747DA22B61531CCAAC79E86D /* Statistics/DailyInsulinChartView.swift */, FEEDF0000000000000000003 /* Statistics/AGPChartView.swift */, @@ -2674,6 +2687,7 @@ 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, FEEDF0000000000000000001 /* StatisticsViewModel.swift */, 17C7C2227FAC0A9F7F570A01 /* CurrentSetupViewModel.swift */, + 3A13D8D000431C52A5899D1E /* ShareOutcomesViewModel.swift */, 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, @@ -2777,6 +2791,7 @@ C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, F69CE2EE3714858772FE7A26 /* InsulinDeliveryHistoryTests.swift */, 03EC166B8AD05800AFCD474E /* SetupChangeTests.swift */, + 6C1F8E3BA2D45079B3E2F512 /* OutcomesPackageTests.swift */, BE36167100E59D7413C50F63 /* SetupComparisonTests.swift */, FEEDF0000000000000000005 /* GlucoseStatisticsTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, @@ -3780,6 +3795,10 @@ 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */, 8879F70ABCDF1E10619547A4 /* SetupChange.swift in Sources */, + 7F8C56209DE2227C701527B9 /* OutcomesPackage.swift in Sources */, + 79DC7BEA0316756B5C8BE749 /* OutcomesShareClient.swift in Sources */, + E4E2B3856D5DA256AA09C31E /* ShareOutcomesViewModel.swift in Sources */, + 69C4F11D6759B7FED4F68BC4 /* ShareOutcomesView.swift in Sources */, D7DECBB118052B85680A241B /* SetupComparison.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, @@ -4054,6 +4073,7 @@ C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */, 30E4A68B8C914C5E4B245555 /* InsulinDeliveryHistoryTests.swift in Sources */, D70F014FFB67BA58678452E9 /* SetupChangeTests.swift in Sources */, + 5B0E7D2A91C34F68A2D1E401 /* OutcomesPackageTests.swift in Sources */, 2622474F63F391C3E102B5E5 /* SetupComparisonTests.swift in Sources */, FEEDB0000000000000000005 /* GlucoseStatisticsTests.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3e1110d169..02023d5bb5 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -618,6 +618,7 @@ class LoopAppManager: NSObject { } return SetupHistory(settings: settings, experiments: experiments) } + viewModel.outcomesPackageBuilder = criticalEventLogExportManager.map { OutcomesPackageBuilder(logs: $0.logs) } return viewModel }() diff --git a/Loop/Models/OutcomesPackage.swift b/Loop/Models/OutcomesPackage.swift new file mode 100644 index 0000000000..67f146ee90 --- /dev/null +++ b/Loop/Models/OutcomesPackage.swift @@ -0,0 +1,297 @@ +// +// OutcomesPackage.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +struct OutcomesManifest: Codable, Equatable { + struct App: Codable, Equatable { + var version: String + var build: String + var workspaceBranch: String? + var workspaceRevision: String? + var loopRevision: String? + } + + struct Setup: Codable, Equatable { + var changeDate: Date + var changeDateIsLowerBound: Bool + var changes: [String] + } + + struct Range: Codable, Equatable { + var start: Date + var end: Date + } + + struct File: Codable, Equatable { + var name: String + var bytes: Int64 + } + + var format = 1 + var installID: String + var app: App + var setup: Setup + var range: Range + var days: Int + var files: [File] = [] +} + +struct OutcomesPackage { + let url: URL + let manifest: OutcomesManifest + let bytes: Int64 +} + +enum OutcomesPackageError: Error { + case archiveFailure + case unexpectedFormat(String) +} + +struct OutcomesPackageBuilder { + static let duration = TimeInterval.days(42) + static let minimumSetupDuration = TimeInterval.days(14) + static let exportNames: Set = ["Settings.json", "Glucose.json", "Carbs.json", "DosingDecisions.json", "Doses.json", "Overrides.json"] + + private static let sliceDuration = TimeInterval.hours(6) + private static let inEffectAtStartNames: Set = ["Settings.json", "Overrides.json"] + private static let inEffectLookback = TimeInterval.days(90) + + let logs: [CriticalEventLog] + var fileManager: FileManager = .default + + init(logs: [CriticalEventLog], fileManager: FileManager = .default) { + self.logs = logs.filter { Self.exportNames.contains($0.exportName) } + self.fileManager = fileManager + } + + /// Writes the share for the setup since `change`, covering up to `duration` before `now`. + func build(change: SetupChange, installID: String, now: Date, progress: Progress) throws -> OutcomesPackage { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let start = now.addingTimeInterval(-Self.duration) + var dayStarts: [Date] = [] + var day = calendar.startOfDay(for: start) + while day < now { + dayStarts.append(day) + day = calendar.date(byAdding: .day, value: 1, to: day)! + } + progress.totalUnitCount = Int64(dayStarts.count * logs.count) + + let url = fileManager.temporaryDirectory.appendingPathComponent("Outcomes-\(UUID().uuidString).zip") + guard let archive = ZipArchive(url: url) else { + throw OutcomesPackageError.archiveFailure + } + var succeeded = false + defer { + archive.close() + if !succeeded { + try? fileManager.removeItem(at: url) + } + } + + let dayFormatter = ISO8601DateFormatter() + dayFormatter.formatOptions = [.withFullDate] + var files: [OutcomesManifest.File] = [] + // The settings and override in effect when the range starts + for log in logs where Self.inEffectAtStartNames.contains(log.exportName) { + let name = "start/\(log.exportName)" + let bytes = try export(log, from: start.addingTimeInterval(-Self.inEffectLookback), to: start, lastOnly: true, into: archive.createArchiveFile(withPath: name), progress: progress) + files.append(OutcomesManifest.File(name: name, bytes: bytes)) + } + for dayStart in dayStarts { + let dayEnd = min(calendar.date(byAdding: .day, value: 1, to: dayStart)!, now) + for log in logs { + guard !progress.isCancelled else { + throw CriticalEventLogError.cancelled + } + let name = "\(dayFormatter.string(from: dayStart))/\(log.exportName)" + let bytes = try export(log, from: max(dayStart, start), to: dayEnd, into: archive.createArchiveFile(withPath: name), progress: progress) + files.append(OutcomesManifest.File(name: name, bytes: bytes)) + progress.completedUnitCount += 1 + } + } + + let manifest = OutcomesManifest( + installID: installID, + app: OutcomesManifest.App( + version: Bundle.main.shortVersionString, + build: Bundle.main.version, + workspaceBranch: BuildDetails.default.workspaceGitBranch, + workspaceRevision: BuildDetails.default.workspaceGitRevision, + loopRevision: BuildDetails.default.gitRevision), + setup: OutcomesManifest.Setup(changeDate: change.date, changeDateIsLowerBound: change.isLowerBound, changes: change.items.map(\.identifier)), + range: OutcomesManifest.Range(start: start, end: now), + days: dayStarts.count, + files: files) + + let manifestStream = archive.createArchiveFile(withPath: "manifest.json") + try manifestStream.write(try Self.manifestEncoder.encode(manifest)) + try manifestStream.finish(sync: true) + if let error = archive.close() { + throw error + } + succeeded = true + let bytes = (try? fileManager.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + return OutcomesPackage(url: url, manifest: manifest, bytes: bytes) + } + + static func latest(_ objects: [Any]) -> Any? { + func date(_ object: Any) -> String { + let dictionary = object as? [String: Any] + return dictionary?["date"] as? String ?? dictionary?["startDate"] as? String ?? "" + } + return objects.max { date($0) < date($1) } + } + + static let manifestEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + // Exported in slices so a day of dosing decisions isn't parsed all at once + private func export(_ log: CriticalEventLog, from start: Date, to end: Date, lastOnly: Bool = false, into stream: DataOutputStream, progress: Progress) throws -> Int64 { + var written: Int64 = 0 + func write(_ data: Data) throws { + try stream.write(data) + written += Int64(data.count) + } + try write(Data("[".utf8)) + var first = true + if lastOnly { + let buffer = MemoryOutputStream() + if let error = log.export(startDate: start, endDate: end, to: buffer, progress: Progress(totalUnitCount: 0)) { + throw error + } + guard let objects = try JSONSerialization.jsonObject(with: buffer.data) as? [Any] else { + throw OutcomesPackageError.unexpectedFormat(log.exportName) + } + if let last = Self.latest(objects).flatMap({ OutcomesScrubber.scrub($0, in: log.exportName) }) { + try write(Data("\n".utf8)) + try write(try JSONSerialization.data(withJSONObject: last, options: [.sortedKeys])) + first = false + } + } + var sliceStart = lastOnly ? end : start + while sliceStart < end { + let sliceEnd = min(sliceStart.addingTimeInterval(Self.sliceDuration), end) + try autoreleasepool { + let buffer = MemoryOutputStream() + if let error = log.export(startDate: sliceStart, endDate: sliceEnd, to: buffer, progress: Progress(totalUnitCount: 0)) { + throw error + } + guard let objects = try JSONSerialization.jsonObject(with: buffer.data) as? [Any] else { + throw OutcomesPackageError.unexpectedFormat(log.exportName) + } + for object in objects { + guard !progress.isCancelled else { + throw CriticalEventLogError.cancelled + } + guard let scrubbed = OutcomesScrubber.scrub(object, in: log.exportName) else { + continue + } + try write(Data((first ? "\n" : ",\n").utf8)) + try write(try JSONSerialization.data(withJSONObject: scrubbed, options: [.sortedKeys])) + first = false + } + } + sliceStart = sliceEnd + } + try write(Data((first ? "]" : "\n]").utf8)) + try stream.finish(sync: true) + return written + } +} + +/// Removes what could identify the person or their devices, and records the share doesn't need. +enum OutcomesScrubber { + static let removedKeys: Set = [ + "deviceToken", // push token for remote commands + "raw", // pump event bytes + "title", // pump event descriptions + "foodType", // free text + "historicalGlucose", // repeats Glucose.json + "localIdentifier", // device serial numbers + "udiDeviceIdentifier", + ] + + /// Device names, such as "Pete's iPhone", are dropped from these. + static let deviceKeys: Set = ["device", "controllerDevice", "cgmDevice", "pumpDevice"] + + /// These decisions repeat the loop's own after each cycle, for remote displays. + static let skippedDosingDecisionReasons: Set = ["updateRemoteRecommendation"] + + static func scrub(_ object: Any, in exportName: String) -> Any? { + // JSONStreamEncoder's placeholder for a record it couldn't encode quotes the record's description + if let dictionary = object as? [String: Any], dictionary.keys.elementsEqual(["error"]) { + return nil + } + if exportName == "DosingDecisions.json", + let reason = ((object as? [String: Any])?["data"] as? [String: Any])?["reason"] as? String, + skippedDosingDecisionReasons.contains(reason) { + return nil + } + return scrub(object, isDevice: false) + } + + private static func scrub(_ object: Any, isDevice: Bool) -> Any { + switch object { + case let dictionary as [String: Any]: + var result: [String: Any] = [:] + for (key, value) in dictionary where !removedKeys.contains(key) && !(isDevice && key == "name") { + result[key] = scrub(value, isDevice: deviceKeys.contains(key)) + } + return result + case let array as [Any]: + return array.map { scrub($0, isDevice: false) } + default: + return object + } + } +} + +final class MemoryOutputStream: DataOutputStream { + private(set) var data = Data() + + func write(_ data: Data) throws { + self.data.append(data) + } + + func finish(sync: Bool) throws {} + + var streamError: Error? { nil } +} + +extension SetupChange.Item { + /// Stable, unlocalized name for the shared manifest. + var identifier: String { + switch self { + case .therapySetting(let setting): + switch setting { + case .glucoseTargetRange: return "glucoseTargetRange" + case .preMealCorrectionRangeOverride: return "preMealCorrectionRange" + case .suspendThreshold: return "glucoseSafetyLimit" + case .basalRate: return "basalRate" + case .deliveryLimits: return "deliveryLimits" + case .carbRatio: return "carbRatio" + case .insulinSensitivity: return "insulinSensitivity" + case .none: return "none" + } + case .insulinType: + return "insulinType" + case .insulinModel: + return "insulinModel" + case .dosingStrategy: + return "dosingStrategy" + case .experiment(let experiment, let enabled): + return "experiment.\(experiment.rawValue).\(enabled ? "on" : "off")" + } + } +} diff --git a/Loop/Models/OutcomesShareClient.swift b/Loop/Models/OutcomesShareClient.swift new file mode 100644 index 0000000000..bdaee9b487 --- /dev/null +++ b/Loop/Models/OutcomesShareClient.swift @@ -0,0 +1,176 @@ +// +// OutcomesShareClient.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import Security + +struct OutcomesShareReceipt: Codable, Equatable, Identifiable { + var id: String + var deleteToken: String + var sent: Date + var changeDate: Date + var days: Int + var bytes: Int64 +} + +enum OutcomesShareError: LocalizedError { + case server(status: Int, reason: String?) + case invalidResponse + case network(Error) + + var errorDescription: String? { + switch self { + case .server(404, _): + return NSLocalizedString("The server has no record of this share. It may already have been deleted.", comment: "Outcomes share error when deleting a share the server doesn't have") + case .server(409, _): + return NSLocalizedString("This setup was already shared in the last day.", comment: "Outcomes share error for a duplicate share") + case .server(413, _): + return NSLocalizedString("The share is too large for the server.", comment: "Outcomes share error when the upload is too large") + case .server(429, _): + return NSLocalizedString("Too many shares have been sent from this network recently. Please try again later.", comment: "Outcomes share error when rate limited") + case .server(let status, let reason): + return String(format: NSLocalizedString("The server couldn't accept the share (%1$d %2$@).", comment: "Outcomes share error with HTTP status (1) and reason (2)"), status, reason ?? "") + case .invalidResponse: + return NSLocalizedString("The server sent an unexpected response.", comment: "Outcomes share error for an unreadable response") + case .network(let error): + return error.localizedDescription + } + } +} + +struct OutcomesShareClient { + static let endpoint = URL(string: "https://ingest.loopkit.org/v1/outcomes")! + static let apiKey = "7dd7414785560c0dd1ef802ac10c00b4" + + var session: URLSession = .shared + var fileManager: FileManager = .default + + /// A retry with the same token returns the first attempt's receipt, if that one was stored. + static func makeDeleteToken() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + func submit(_ package: OutcomesPackage, note: String?, deleteToken: String) async throws -> OutcomesShareReceipt { + let boundary = "Boundary-\(UUID().uuidString)" + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + let bodyURL = fileManager.temporaryDirectory.appendingPathComponent("OutcomesUpload-\(UUID().uuidString)") + defer { try? fileManager.removeItem(at: bodyURL) } + try writeBody(for: package, note: note, deleteToken: deleteToken, boundary: boundary, to: bodyURL) + + let (json, _) = try await send(request) { try await session.upload(for: $0, fromFile: bodyURL) } + guard let id = json["id"] as? String else { + throw OutcomesShareError.invalidResponse + } + return OutcomesShareReceipt(id: id, deleteToken: deleteToken, sent: Date(), changeDate: package.manifest.setup.changeDate, days: package.manifest.days, bytes: package.bytes) + } + + func delete(_ receipt: OutcomesShareReceipt) async throws { + var request = URLRequest(url: Self.endpoint.appendingPathComponent(receipt.id)) + request.httpMethod = "DELETE" + request.setValue("Bearer \(receipt.deleteToken)", forHTTPHeaderField: "Authorization") + _ = try await send(request) { try await session.data(for: $0) } + } + + private func send(_ request: URLRequest, _ perform: (URLRequest) async throws -> (Data, URLResponse)) async throws -> ([String: Any], HTTPURLResponse) { + let data: Data + let response: URLResponse + do { + (data, response) = try await perform(request) + } catch { + throw OutcomesShareError.network(error) + } + guard let response = response as? HTTPURLResponse else { + throw OutcomesShareError.invalidResponse + } + let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + guard response.statusCode == 200 else { + throw OutcomesShareError.server(status: response.statusCode, reason: json["error"] as? String) + } + return (json, response) + } + + // Written to a file so the package is never held in memory whole + // The server checks the key, token and manifest before it reads the package, so the order matters + private func writeBody(for package: OutcomesPackage, note: String?, deleteToken: String, boundary: String, to url: URL) throws { + guard fileManager.createFile(atPath: url.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let output = try FileHandle(forWritingTo: url) + defer { try? output.close() } + + func header(name: String, filename: String? = nil, contentType: String? = nil) -> Data { + var header = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"" + if let filename { + header += "; filename=\"\(filename)\"" + } + header += "\r\n" + if let contentType { + header += "Content-Type: \(contentType)\r\n" + } + return Data((header + "\r\n").utf8) + } + + try output.write(contentsOf: header(name: "client") + Data(Self.apiKey.utf8) + Data("\r\n".utf8)) + try output.write(contentsOf: header(name: "deleteToken") + Data(deleteToken.utf8) + Data("\r\n".utf8)) + if let note, !note.isEmpty { + try output.write(contentsOf: header(name: "note", contentType: "text/plain; charset=utf-8") + Data(note.utf8) + Data("\r\n".utf8)) + } + try output.write(contentsOf: header(name: "manifest", contentType: "application/json") + OutcomesPackageBuilder.manifestEncoder.encode(package.manifest) + Data("\r\n".utf8)) + try output.write(contentsOf: header(name: "package", filename: "outcomes.zip", contentType: "application/zip")) + let input = try FileHandle(forReadingFrom: package.url) + defer { try? input.close() } + while let chunk = try input.read(upToCount: 1 << 20), !chunk.isEmpty { + try output.write(contentsOf: chunk) + } + try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8)) + } +} + +/// The shares sent from this install, kept so they can be deleted later. +struct OutcomesShareReceipts { + private let defaults: UserDefaults + private static let receiptsKey = "com.loopkit.Loop.outcomesShareReceipts" + private static let installIDKey = "com.loopkit.Loop.outcomesShareInstallID" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var all: [OutcomesShareReceipt] { + defaults.data(forKey: Self.receiptsKey).flatMap { try? PropertyListDecoder().decode([OutcomesShareReceipt].self, from: $0) } ?? [] + } + + func add(_ receipt: OutcomesShareReceipt) { + save(all + [receipt]) + } + + func remove(_ receipt: OutcomesShareReceipt) { + save(all.filter { $0.id != receipt.id }) + } + + /// Random, and used only to link shares from the same install. + var installID: String { + if let id = defaults.string(forKey: Self.installIDKey) { + return id + } + let id = UUID().uuidString + defaults.set(id, forKey: Self.installIDKey) + return id + } + + private func save(_ receipts: [OutcomesShareReceipt]) { + defaults.set(try? PropertyListEncoder().encode(receipts), forKey: Self.receiptsKey) + } +} diff --git a/Loop/View Models/CurrentSetupViewModel.swift b/Loop/View Models/CurrentSetupViewModel.swift index ed10dfaa53..de792da7ee 100644 --- a/Loop/View Models/CurrentSetupViewModel.swift +++ b/Loop/View Models/CurrentSetupViewModel.swift @@ -18,6 +18,7 @@ final class CurrentSetupViewModel: ObservableObject { @Published private(set) var timeInRange: [Date: Double] = [:] @Published private(set) var isLoading = true + let outcomesPackageBuilder: OutcomesPackageBuilder? private let loadHistory: () async throws -> SetupHistory private let glucoseStore: GlucoseStoreProtocol? private let insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)? @@ -31,9 +32,11 @@ final class CurrentSetupViewModel: ObservableObject { loadHistory: @escaping () async throws -> SetupHistory, glucoseStore: GlucoseStoreProtocol?, insulinDeliveryHistory: ((Int) async throws -> InsulinDeliveryHistory)?, + outcomesPackageBuilder: OutcomesPackageBuilder? = nil, cacheDuration: TimeInterval = Bundle.main.localCacheDuration, now: @escaping () -> Date = { Date() }) { self.selected = selected + self.outcomesPackageBuilder = outcomesPackageBuilder self.loadHistory = loadHistory self.glucoseStore = glucoseStore self.insulinDeliveryHistory = insulinDeliveryHistory diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 87c5cb424d..9c78a8c46c 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -123,6 +123,8 @@ class SettingsViewModel { @ObservationIgnored var setupHistory: (() async throws -> SetupHistory)? + @ObservationIgnored var outcomesPackageBuilder: OutcomesPackageBuilder? + @MainActor var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) diff --git a/Loop/View Models/ShareOutcomesViewModel.swift b/Loop/View Models/ShareOutcomesViewModel.swift new file mode 100644 index 0000000000..96b7367c2c --- /dev/null +++ b/Loop/View Models/ShareOutcomesViewModel.swift @@ -0,0 +1,148 @@ +// +// ShareOutcomesViewModel.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +@MainActor +final class ShareOutcomesViewModel: ObservableObject { + enum State { + case preparing(Double) + case ready(OutcomesPackage) + case sending(OutcomesPackage) + case sent(OutcomesShareReceipt) + case deleted + case failed(String, package: OutcomesPackage?) + } + + @Published private(set) var state: State = .preparing(0) + @Published private(set) var receipts: [OutcomesShareReceipt] + @Published private(set) var deletionError: String? + @Published var note = "" + + static let maxNoteLength = 1000 + + let change: SetupChange + private let builder: OutcomesPackageBuilder + private let client: OutcomesShareClient + private let store: OutcomesShareReceipts + private let now: () -> Date + private var progress: Progress? + private var packageURL: URL? + private let deleteToken = OutcomesShareClient.makeDeleteToken() + + init(change: SetupChange, + builder: OutcomesPackageBuilder, + client: OutcomesShareClient = OutcomesShareClient(), + store: OutcomesShareReceipts = OutcomesShareReceipts(), + now: @escaping () -> Date = { Date() }) { + self.change = change + self.builder = builder + self.client = client + self.store = store + self.now = now + self.receipts = store.all + } + + deinit { + progress?.cancel() + if let packageURL { + try? FileManager.default.removeItem(at: packageURL) + } + } + + var range: DateInterval { + let end = now() + return DateInterval(start: end.addingTimeInterval(-OutcomesPackageBuilder.duration), end: end) + } + + var canEditNote: Bool { + switch state { + case .preparing, .ready, .failed(_, .some): return true + case .sending, .sent, .deleted, .failed(_, .none): return false + } + } + + /// An earlier share of this same setup. + var previousShare: OutcomesShareReceipt? { + receipts.last { $0.changeDate == change.date } + } + + func prepare() async { + guard case .preparing = state, progress == nil else { + return + } + let progress = Progress(totalUnitCount: 0) + self.progress = progress + let observation = progress.observe(\.fractionCompleted) { [weak self] progress, _ in + let fraction = progress.fractionCompleted + Task { @MainActor in + if case .preparing = self?.state { + self?.state = .preparing(fraction) + } + } + } + defer { observation.invalidate() } + + let builder = builder, change = change, installID = store.installID, now = now() + let result = await Task.detached(priority: .userInitiated) { + Result { try builder.build(change: change, installID: installID, now: now, progress: progress) } + }.value + switch result { + case .success(let package): + packageURL = package.url + state = .ready(package) + case .failure(let error): + if !progress.isCancelled { + state = .failed(error.localizedDescription, package: nil) + } + } + } + + func cancel() { + progress?.cancel() + } + + func send() async { + let package: OutcomesPackage + switch state { + case .ready(let ready), .failed(_, .some(let ready)): + package = ready + default: + return + } + state = .sending(package) + let note = String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxNoteLength)) + do { + let receipt = try await client.submit(package, note: note, deleteToken: deleteToken) + store.add(receipt) + receipts = store.all + state = .sent(receipt) + try? FileManager.default.removeItem(at: package.url) + packageURL = nil + } catch { + state = .failed(error.localizedDescription, package: package) + } + } + + func delete(_ receipt: OutcomesShareReceipt) async { + deletionError = nil + do { + try await client.delete(receipt) + } catch OutcomesShareError.server(404, _) { + // Already gone from the server + } catch { + deletionError = error.localizedDescription + return + } + store.remove(receipt) + receipts = store.all + if case .sent(let sent) = state, sent.id == receipt.id { + state = .deleted + } + } +} diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 15a0d5aa51..5c3a7a7833 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -39,6 +39,14 @@ struct CurrentSetupView: View { if let comparison = viewModel.comparison { resultsSection(comparison) } + if viewModel.isCurrentSelected, let builder = viewModel.outcomesPackageBuilder, + Date().timeIntervalSince(selected.date) >= OutcomesPackageBuilder.minimumSetupDuration { + Section(footer: Text(NSLocalizedString("Help improve Loop by sending these results, and the history behind them, to the Loop developers.", comment: "Footer of the share outcomes link on the current setup screen"))) { + NavigationLink(destination: ShareOutcomesView(viewModel: ShareOutcomesViewModel(change: selected, builder: builder))) { + Text(NSLocalizedString("Share Outcomes with Loop Developers", comment: "Link to the share outcomes screen")) + } + } + } } else if !viewModel.isLoading { Section { Text(NSLocalizedString("No settings have been recorded yet.", comment: "Shown on the current setup screen when there is no settings history")) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index e02112dbdb..70f6d26b4e 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -362,7 +362,8 @@ extension SettingsView { selected: setupChange, loadHistory: { [viewModel] in try await viewModel.setupHistory?() ?? SetupHistory(settings: [], experiments: []) }, glucoseStore: glucoseStore, - insulinDeliveryHistory: viewModel.insulinDeliveryHistory + insulinDeliveryHistory: viewModel.insulinDeliveryHistory, + outcomesPackageBuilder: viewModel.outcomesPackageBuilder ))) { LargeButton( action: {}, diff --git a/Loop/Views/ShareOutcomesView.swift b/Loop/Views/ShareOutcomesView.swift new file mode 100644 index 0000000000..2cab881f0a --- /dev/null +++ b/Loop/Views/ShareOutcomesView.swift @@ -0,0 +1,165 @@ +// +// ShareOutcomesView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI + +struct ShareOutcomesView: View { + @StateObject private var viewModel: ShareOutcomesViewModel + @State private var pendingDeletion: OutcomesShareReceipt? + + static let policyURL = URL(string: "https://analytics.loopkit.org/policy")! + + init(viewModel: @autoclosure @escaping () -> ShareOutcomesViewModel) { + _viewModel = StateObject(wrappedValue: viewModel()) + } + + var body: some View { + List { + Section(footer: Text(NSLocalizedString("Loop developers use shared outcomes to learn how settings changes and algorithm experiments affect time in range and lows. Each share is sent only when you tap Send.", comment: "Footer of the introduction on the share outcomes screen"))) { + Text(String(format: NSLocalizedString("Send the Loop developers your history from %1$@, covering your current setup and the weeks before it.", comment: "Introduction on the share outcomes screen (1: date range)"), + dateIntervalFormatter.string(from: viewModel.range) ?? "")) + } + Section(header: SectionHeader(label: NSLocalizedString("What's Sent", comment: "Header of the list of what an outcomes share includes"))) { + item(NSLocalizedString("Glucose readings", comment: "Outcomes share content item")) + item(NSLocalizedString("Carb entries, without food descriptions", comment: "Outcomes share content item")) + item(NSLocalizedString("Insulin delivery", comment: "Outcomes share content item")) + item(NSLocalizedString("Loop's dosing decisions and forecasts", comment: "Outcomes share content item")) + item(NSLocalizedString("Therapy settings history, including algorithm experiments", comment: "Outcomes share content item")) + item(NSLocalizedString("Preset history, including preset names", comment: "Outcomes share content item")) + item(NSLocalizedString("Pump and CGM models, app version and build", comment: "Outcomes share content item")) + item(NSLocalizedString("A random ID for this app, so shares from it can be linked", comment: "Outcomes share content item")) + item(NSLocalizedString("Your note, if you add one", comment: "Outcomes share content item")) + } + Section(header: SectionHeader(label: NSLocalizedString("What's Not Sent", comment: "Header of the list of what an outcomes share leaves out"))) { + item(NSLocalizedString("Your name or contact details", comment: "Outcomes share exclusion item")) + item(NSLocalizedString("Phone name, and pump and CGM serial numbers", comment: "Outcomes share exclusion item")) + item(NSLocalizedString("Device communication logs and alerts", comment: "Outcomes share exclusion item")) + } + Section { + Link(NSLocalizedString("Data Policy", comment: "Link to the shared outcomes data policy"), destination: Self.policyURL) + } + if viewModel.canEditNote { + noteSection + } + sendSection + if !viewModel.receipts.isEmpty { + receiptsSection + } + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("Share Outcomes", comment: "Title of the share outcomes screen")) + .task { await viewModel.prepare() } + .onDisappear { viewModel.cancel() } + .confirmationDialog(NSLocalizedString("Delete this share from the LoopKit server?", comment: "Confirmation title for deleting an outcomes share"), + isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible, + presenting: pendingDeletion) { receipt in + Button(NSLocalizedString("Delete Share", comment: "Button to delete an outcomes share"), role: .destructive) { + Task { await viewModel.delete(receipt) } + } + } + } + + private var noteSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Note", comment: "Header of the optional note sent with an outcomes share")), + footer: Text(String(format: NSLocalizedString("Optional. Anything that would help the developers understand these results, such as illness, travel or a change in routine. Don't include your name or contact details. %1$d of %2$d characters.", comment: "Footer for the note sent with an outcomes share (1: used)(2: limit)"), viewModel.note.count, ShareOutcomesViewModel.maxNoteLength))) { + TextField(NSLocalizedString("Add a note", comment: "Placeholder for the note sent with an outcomes share"), text: $viewModel.note, axis: .vertical) + .lineLimit(3...8) + .onChange(of: viewModel.note) { _, note in + if note.count > ShareOutcomesViewModel.maxNoteLength { + viewModel.note = String(note.prefix(ShareOutcomesViewModel.maxNoteLength)) + } + } + } + } + + @ViewBuilder + private var sendSection: some View { + Section(footer: sendFooter) { + switch viewModel.state { + case .preparing(let fraction): + ProgressView(value: fraction) { + Text(NSLocalizedString("Preparing…", comment: "Shown while the outcomes share is being prepared")) + } + case .ready(let package): + summaryRows(package) + Button(NSLocalizedString("Send", comment: "Button to send an outcomes share")) { + Task { await viewModel.send() } + } + case .sending(let package): + summaryRows(package) + HStack { + Text(NSLocalizedString("Sending…", comment: "Shown while the outcomes share is uploading")) + Spacer() + ProgressView() + } + case .sent(let receipt): + Label(NSLocalizedString("Sent. Thank you!", comment: "Shown after an outcomes share is sent"), systemImage: "checkmark.circle.fill") + .foregroundColor(.accentColor) + LabeledContent(NSLocalizedString("Receipt", comment: "Label for an outcomes share's receipt ID"), value: receipt.id) + .textSelection(.enabled) + case .deleted: + Text(NSLocalizedString("The share was deleted.", comment: "Shown after the outcomes share just sent is deleted")) + case .failed(let message, let package): + Text(message) + .foregroundColor(.red) + if package != nil { + Button(NSLocalizedString("Try Again", comment: "Button to retry sending an outcomes share")) { + Task { await viewModel.send() } + } + } + } + } + } + + private var sendFooter: some View { + var text = NSLocalizedString("Shares are stored on a LoopKit server that only Loop developers can access. You can delete a share from this screen at any time.", comment: "Footer explaining how outcomes shares are stored") + if case .ready = viewModel.state, let previous = viewModel.previousShare { + text = String(format: NSLocalizedString("You shared this setup on %@.", comment: "Note that the current setup was already shared (1: date)"), previous.sent.formatted(date: .abbreviated, time: .shortened)) + " " + text + } + return Text(text) + } + + @ViewBuilder + private func summaryRows(_ package: OutcomesPackage) -> some View { + LabeledContent(NSLocalizedString("Size", comment: "Label for the size of an outcomes share"), value: ByteCountFormatter.string(fromByteCount: package.bytes, countStyle: .file)) + } + + private var receiptsSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Your Shares", comment: "Header of the list of outcomes shares sent from this app")), + footer: viewModel.deletionError.map { Text($0).foregroundColor(.red) }) { + ForEach(viewModel.receipts.reversed()) { receipt in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(receipt.sent.formatted(date: .abbreviated, time: .shortened)) + Text(receipt.id) + .font(.footnote) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + Spacer() + Button(NSLocalizedString("Delete", comment: "Button to delete an outcomes share"), role: .destructive) { + pendingDeletion = receipt + } + .buttonStyle(.borderless) + } + } + } + } + + private var dateIntervalFormatter: DateIntervalFormatter { + let formatter = DateIntervalFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + } + + private func item(_ text: String) -> some View { + Text(text) + } +} diff --git a/LoopTests/Models/OutcomesPackageTests.swift b/LoopTests/Models/OutcomesPackageTests.swift new file mode 100644 index 0000000000..433735c000 --- /dev/null +++ b/LoopTests/Models/OutcomesPackageTests.swift @@ -0,0 +1,145 @@ +// +// OutcomesPackageTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import ZIPFoundation +@testable import Loop + +class OutcomesScrubberTests: XCTestCase { + func testRemovesIdentifyingFields() throws { + let settings: [String: Any] = [ + "date": "2026-10-01T00:00:00.000Z", + "data": [ + "deviceToken": "abc", + "controllerDevice": ["name": "Pete's iPhone", "model": "iPhone", "systemVersion": "26.1"], + "pumpDevice": ["name": "Omnipod", "manufacturer": "Insulet", "localIdentifier": "1F0A", "udiDeviceIdentifier": "0123"], + "overridePresets": [["name": "Running", "symbol": "🏃"]], + ] as [String: Any], + ] + let scrubbed = try XCTUnwrap(OutcomesScrubber.scrub(settings, in: "Settings.json") as? [String: Any]) + let data = try XCTUnwrap(scrubbed["data"] as? [String: Any]) + XCTAssertNil(data["deviceToken"]) + XCTAssertEqual(data["controllerDevice"] as? [String: String], ["model": "iPhone", "systemVersion": "26.1"]) + XCTAssertEqual(data["pumpDevice"] as? [String: String], ["manufacturer": "Insulet"]) + XCTAssertEqual(data["overridePresets"] as? [[String: String]], [["name": "Running", "symbol": "🏃"]]) + XCTAssertEqual(scrubbed["date"] as? String, "2026-10-01T00:00:00.000Z") + } + + func testRemovesPumpEventDetailsAndFoodTypes() throws { + let dose: [String: Any] = ["raw": "AQID", "title": "BolusPumpEvent(...)", "type": "bolus"] + XCTAssertEqual(OutcomesScrubber.scrub(dose, in: "Doses.json") as? [String: String], ["type": "bolus"]) + let carb: [String: Any] = ["foodType": "Pizza at Joe's", "grams": 40] + XCTAssertEqual((OutcomesScrubber.scrub(carb, in: "Carbs.json") as? [String: Any])?.keys.sorted(), ["grams"]) + } + + func testDosingDecisions() throws { + let remote: [String: Any] = ["data": ["reason": "updateRemoteRecommendation"]] + XCTAssertNil(OutcomesScrubber.scrub(remote, in: "DosingDecisions.json")) + + let loop: [String: Any] = ["data": [ + "reason": "loop", + "historicalGlucose": [["quantity": 100]], + "pumpManagerStatus": ["device": ["name": "Omnipod", "localIdentifier": "1F0A", "model": "Dash"]], + ] as [String: Any]] + let data = try XCTUnwrap((OutcomesScrubber.scrub(loop, in: "DosingDecisions.json") as? [String: Any])?["data"] as? [String: Any]) + XCTAssertNil(data["historicalGlucose"]) + XCTAssertEqual((data["pumpManagerStatus"] as? [String: Any])?["device"] as? [String: String], ["model": "Dash"]) + } + + func testSkipsEncoderErrorPlaceholders() { + XCTAssertNil(OutcomesScrubber.scrub(["error": "Failed to encode value: Pete's iPhone"], in: "Settings.json")) + } +} + +class OutcomesPackageBuilderTests: XCTestCase { + private let now = ISO8601DateFormatter().date(from: "2026-10-28T12:00:00Z")! + + private func record(_ date: Date, _ extra: [String: Any] = [:]) -> [String: Any] { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return extra.merging(["date": formatter.string(from: date)]) { a, _ in a } + } + + func testBuild() throws { + let start = now.addingTimeInterval(-OutcomesPackageBuilder.duration) + let settings = FakeLog(exportName: "Settings.json", records: [ + (start - .days(30), record(start - .days(30), ["value": "old"])), + (start - .days(2), record(start - .days(2), ["value": "inEffect", "deviceToken": "abc"])), + (now - .days(3), record(now - .days(3), ["value": "recent"])), + ]) + let glucose = FakeLog(exportName: "Glucose.json", records: [ + (start - .hours(1), record(start - .hours(1))), + (now - .hours(1), record(now - .hours(1), ["quantity": 120])), + ]) + let deviceLog = FakeLog(exportName: "DeviceLog.json", records: [(now - .hours(1), record(now - .hours(1)))]) + let builder = OutcomesPackageBuilder(logs: [settings, glucose, deviceLog]) + let change = SetupChange(date: now - .days(15), isLowerBound: false, items: [.therapySetting(.carbRatio), .experiment(.guardAndRunway, enabled: true)]) + + let package = try builder.build(change: change, installID: "install", now: now, progress: Progress()) + defer { try? FileManager.default.removeItem(at: package.url) } + + let archive = try XCTUnwrap(Archive(url: package.url, accessMode: .read)) + func json(_ path: String) throws -> [[String: Any]] { + var data = Data() + _ = try archive.extract(try XCTUnwrap(archive[path], path)) { data.append($0) } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + } + + // 42 days that don't start at midnight touch 43 UTC days + XCTAssertEqual(package.manifest.days, 43) + XCTAssertNil(archive.first { $0.path.contains("DeviceLog") }) + XCTAssertEqual(Array(archive).count, 1 + 43 * 2 + 1) + + let inEffect = try json("start/Settings.json") + XCTAssertEqual(inEffect.map { $0["value"] as? String }, ["inEffect"]) + XCTAssertNil(inEffect.first?["deviceToken"]) + XCTAssertEqual(try json("2026-10-25/Settings.json").map { $0["value"] as? String }, ["recent"]) + XCTAssertEqual(try json("2026-10-28/Glucose.json").map { $0["quantity"] as? Int }, [120]) + XCTAssertEqual(try json("2026-09-16/Glucose.json").count, 0) + + var manifestData = Data() + _ = try archive.extract(try XCTUnwrap(archive["manifest.json"])) { manifestData.append($0) } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let manifest = try decoder.decode(OutcomesManifest.self, from: manifestData) + XCTAssertEqual(manifest, package.manifest) + XCTAssertEqual(manifest.installID, "install") + XCTAssertEqual(manifest.setup.changes, ["carbRatio", "experiment.guardAndRunway.on"]) + XCTAssertEqual(manifest.range.start, start) + XCTAssertEqual(manifest.files.count, 1 + 43 * 2) + } + + func testBuildCancelled() { + let builder = OutcomesPackageBuilder(logs: [FakeLog(exportName: "Glucose.json", records: [])]) + let progress = Progress() + progress.cancel() + let change = SetupChange(date: now - .days(15), isLowerBound: true, items: []) + XCTAssertThrowsError(try builder.build(change: change, installID: "install", now: now, progress: progress)) { error in + XCTAssertEqual(error as? CriticalEventLogError, .cancelled) + } + } +} + +private struct FakeLog: CriticalEventLog { + let exportName: String + let records: [(Date, [String: Any])] + + func exportProgressTotalUnitCount(startDate: Date, endDate: Date?) -> Result { + .success(Int64(records.count)) + } + + func export(startDate: Date, endDate: Date, to stream: DataOutputStream, progress: Progress) -> Error? { + let objects = records.filter { $0.0 >= startDate && $0.0 < endDate }.map(\.1) + do { + try stream.write(try JSONSerialization.data(withJSONObject: objects)) + return nil + } catch { + return error + } + } +} From b9b95f957432ff3f1fa63eff0d3bf586db2874a7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 28 Oct 2026 16:48:37 -0500 Subject: [PATCH 2/2] Offer sharing at any time, recommending 2 weeks without changes Any setup changes within the shared range are in the settings history, so they can be kept or left out in an analysis. The link no longer waits for the setup to run 2 weeks; its footer recommends that instead. --- Loop/Models/OutcomesPackage.swift | 1 - Loop/Views/CurrentSetupView.swift | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Loop/Models/OutcomesPackage.swift b/Loop/Models/OutcomesPackage.swift index 67f146ee90..43fd684c82 100644 --- a/Loop/Models/OutcomesPackage.swift +++ b/Loop/Models/OutcomesPackage.swift @@ -55,7 +55,6 @@ enum OutcomesPackageError: Error { struct OutcomesPackageBuilder { static let duration = TimeInterval.days(42) - static let minimumSetupDuration = TimeInterval.days(14) static let exportNames: Set = ["Settings.json", "Glucose.json", "Carbs.json", "DosingDecisions.json", "Doses.json", "Overrides.json"] private static let sliceDuration = TimeInterval.hours(6) diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 5c3a7a7833..edc81879f2 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -39,9 +39,8 @@ struct CurrentSetupView: View { if let comparison = viewModel.comparison { resultsSection(comparison) } - if viewModel.isCurrentSelected, let builder = viewModel.outcomesPackageBuilder, - Date().timeIntervalSince(selected.date) >= OutcomesPackageBuilder.minimumSetupDuration { - Section(footer: Text(NSLocalizedString("Help improve Loop by sending these results, and the history behind them, to the Loop developers.", comment: "Footer of the share outcomes link on the current setup screen"))) { + if viewModel.isCurrentSelected, let builder = viewModel.outcomesPackageBuilder { + Section(footer: Text(NSLocalizedString("Help improve Loop by sending these results, and the history behind them, to the Loop developers. Sharing after about 2 weeks without setup changes is ideal, but you can share at any time. Any changes are included in the history.", comment: "Footer of the share outcomes link on the current setup screen"))) { NavigationLink(destination: ShareOutcomesView(viewModel: ShareOutcomesViewModel(change: selected, builder: builder))) { Text(NSLocalizedString("Share Outcomes with Loop Developers", comment: "Link to the share outcomes screen")) }