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..43fd684c82 --- /dev/null +++ b/Loop/Models/OutcomesPackage.swift @@ -0,0 +1,296 @@ +// +// 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 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..edc81879f2 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -39,6 +39,13 @@ struct CurrentSetupView: View { if let comparison = viewModel.comparison { resultsSection(comparison) } + 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")) + } + } + } } 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 + } + } +}