Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */; };
1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */; };
1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D63DEA426E950D400F46FA5 /* SupportManager.swift */; };
C1A55E012E9F000000A10002 /* IssueReportSubmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */; };
1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */; };
1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */; };
1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; };
Expand Down Expand Up @@ -357,6 +358,7 @@
A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987CD4824A58A0100439ADC /* ZipArchive.swift */; };
A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; };
A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; };
C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10003 /* SendIssueReportView.swift */; };
A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; };
A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; };
A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */; };
Expand Down Expand Up @@ -835,6 +837,7 @@
1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataClass.swift"; sourceTree = "<group>"; };
1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataProperties.swift"; sourceTree = "<group>"; };
1D63DEA426E950D400F46FA5 /* SupportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManager.swift; sourceTree = "<group>"; };
C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportSubmitter.swift; sourceTree = "<group>"; };
1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = "<group>"; };
1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerTests.swift; sourceTree = "<group>"; };
1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1169,6 +1172,7 @@
A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = "<group>"; };
A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = "<group>"; };
A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = "<group>"; };
C1A55E012E9F000000A10003 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = "<group>"; };
A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = "<group>"; };
A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = "<group>"; };
A9B996EF27235191002DC09C /* LoopWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWarning.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2027,6 +2031,7 @@
14C970662C59918100E8A01B /* Charts */,
431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */,
A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */,
C1A55E012E9F000000A10003 /* SendIssueReportView.swift */,
C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */,
14BBB3AE2C61274400ECB800 /* Favorite Foods */,
43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */,
Expand Down Expand Up @@ -2103,6 +2108,7 @@
43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */,
E95D37FF24EADE68005E2F50 /* Store Protocols */,
1D63DEA426E950D400F46FA5 /* SupportManager.swift */,
C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */,
C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */,
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */,
Expand Down Expand Up @@ -3404,6 +3410,7 @@
897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */,
4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */,
A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */,
C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */,
43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */,
C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */,
4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */,
Expand Down Expand Up @@ -3565,6 +3572,7 @@
A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */,
4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */,
1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */,
C1A55E012E9F000000A10002 /* IssueReportSubmitter.swift in Sources */,
84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */,
4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */,
14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */,
Expand Down
139 changes: 139 additions & 0 deletions Loop/Managers/IssueReportSubmitter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// IssueReportSubmitter.swift
// Loop
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import Foundation

struct IssueReport {
struct Contact: Encodable {
var name: String?
var email: String?
var zulip: String?
var github: String?
}

struct Attachment {
var filename: String
var contentType: String
var data: Data
}

var title: String?
var description: String
var contact: Contact?
var context: [String: IssueReportContextValue]
var attachments: [Attachment]
}

enum IssueReportContextValue: Encodable, Equatable {
case string(String)
case strings([String])

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .strings(let value): try container.encode(value)
}
}
}

enum IssueReportSubmitterError: LocalizedError {
case server(status: Int, reason: String?)
case invalidResponse
case network(Error)

var isRetryable: Bool {
switch self {
case .server(let status, _): return status >= 500
case .invalidResponse, .network: return true
}
}

var errorDescription: String? {
switch self {
case .server(413, _):
return NSLocalizedString("The report is too large. Try fewer or smaller screenshots.", comment: "Issue report error when the upload is too large")
case .server(415, _):
return NSLocalizedString("One of the attachments isn't a supported image type.", comment: "Issue report error when an attachment type is refused")
case .server(429, _):
return NSLocalizedString("Too many reports have been sent from this network recently. Please try again later.", comment: "Issue report error when rate limited")
case .server(let status, let reason):
return String(format: NSLocalizedString("The server couldn't accept the report (%1$d %2$@).", comment: "Issue report error with HTTP status (1) and reason (2)"), status, reason ?? "")
case .invalidResponse:
return NSLocalizedString("The server sent an unexpected response.", comment: "Issue report error for an unreadable response")
case .network(let error):
return error.localizedDescription
}
}
}

struct IssueReportSubmitter {
static let endpoint = URL(string: "https://ingest.loopkit.org/v1/issues")!
static let apiKey = "7dd7414785560c0dd1ef802ac10c00b4"

var session: URLSession = .shared

func submit(_ report: IssueReport) async throws -> String {
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 body = try Self.body(for: report, boundary: boundary)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.upload(for: request, from: body)
} catch {
throw IssueReportSubmitterError.network(error)
}
guard let response = response as? HTTPURLResponse else {
throw IssueReportSubmitterError.invalidResponse
}
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
guard response.statusCode == 200 else {
throw IssueReportSubmitterError.server(status: response.statusCode, reason: json?["error"] as? String)
}
guard let id = json?["id"] as? String else {
throw IssueReportSubmitterError.invalidResponse
}
return id
}

static func body(for report: IssueReport, boundary: String) throws -> Data {
struct Payload: Encodable {
var title: String?
var description: String
var contact: IssueReport.Contact?
var context: [String: IssueReportContextValue]
}
let payload = try JSONEncoder().encode(Payload(title: report.title, description: report.description, contact: report.contact, context: report.context))

var body = Data()
func appendPart(name: String, filename: String? = nil, contentType: String? = nil, data: Data) {
var header = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\""
if let filename {
header += "; filename=\"\(filename.replacingOccurrences(of: "\"", with: ""))\""
}
header += "\r\n"
if let contentType {
header += "Content-Type: \(contentType)\r\n"
}
body.append(Data((header + "\r\n").utf8))
body.append(data)
body.append(Data("\r\n".utf8))
}

appendPart(name: "client", data: Data(apiKey.utf8))
appendPart(name: "report", contentType: "application/json", data: payload)
for attachment in report.attachments {
appendPart(name: "attachment", filename: attachment.filename, contentType: attachment.contentType, data: attachment.data)
}
body.append(Data("--\(boundary)--\r\n".utf8))
return body
}
}
4 changes: 4 additions & 0 deletions Loop/View Controllers/StatusTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,10 @@ extension StatusTableViewController: SettingsViewModelDelegate {
self.presentIssueReport()
}
}

func generateDiagnosticReport() async -> String {
await diagnosticReportGenerator.generateDiagnosticReport()
}
}

// MARK: - Services delegation
Expand Down
26 changes: 26 additions & 0 deletions Loop/View Models/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public protocol SettingsViewModelDelegate: AnyObject {
func dosingEnabledChanged(_: Bool)
func dosingStrategyChanged(_: AutomaticDosingStrategy)
func didTapIssueReport()
func generateDiagnosticReport() async -> String
var closedLoopDescriptiveText: String? { get }
var automaticDosingEnabled: Bool { get set }
var automationHistory: [AutomationHistoryEntry] { get }
Expand All @@ -74,6 +75,31 @@ class SettingsViewModel {
func didTapIssueReport() {
delegate?.didTapIssueReport()
}

var generateDiagnosticReport: (() async -> String)? {
guard let delegate else {
return nil
}
return { [weak delegate] in await delegate?.generateDiagnosticReport() ?? "" }
}

@MainActor
var issueReportContext: [String: IssueReportContextValue] {
let buildDetails = BuildDetails.default
var context: [String: IssueReportContextValue] = [
"appVersion": .string(Bundle.main.shortVersionString),
"buildNumber": .string(Bundle.main.version),
"customizations": .strings(buildDetails.customizations),
"deviceModel": .string(UIDevice.current.modelIdentifier),
"osVersion": .string(UIDevice.current.systemVersion),
]
context["branch"] = (buildDetails.workspaceGitBranch ?? buildDetails.gitBranch).map { .string($0) }
context["workspaceRevision"] = buildDetails.workspaceGitRevision.map { .string($0) }
context["loopRevision"] = buildDetails.gitRevision.map { .string($0) }
context["pumpModel"] = deviceManager?.pumpManager.map { .string($0.status.device.model ?? "Unknown") }
context["cgmModel"] = deviceManager?.cgmManager.map { .string($0.cgmManagerStatus.device?.model ?? "Unknown") }
return context
}

var availableSupports: [SupportUI]
let pumpManagerSettingsViewModel: PumpManagerViewModel
Expand Down
Loading