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
24 changes: 24 additions & 0 deletions LoopSupport.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
B66D1F552E6A752300471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F542E6A752300471149 /* Localizable.xcstrings */; };
C12ECD05289560B90089EB07 /* LoopSupportKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; platformFilter = ios; };
C12ECD06289560B90089EB07 /* LoopSupportKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
C1A55E022E9F000000B10002 /* IssueReportContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10001 /* IssueReportContext.swift */; };
C1A55E022E9F000000B10004 /* IssueReportSubmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */; };
C1A55E022E9F000000B10006 /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10005 /* ReportProblemView.swift */; };
C1A55E022E9F000000B10008 /* SendIssueReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10007 /* SendIssueReportView.swift */; };
C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */; };
C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */; };
C1BE9741295E3800008BA25D /* LoopKitAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */; };
Expand Down Expand Up @@ -84,6 +88,10 @@
A9CE800A25BCACAE00C04F9B /* LoopSupportPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSupportPlugin.swift; sourceTree = "<group>"; };
A9CE801225BCAE4000C04F9B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B66D1F542E6A752300471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
C1A55E022E9F000000B10001 /* IssueReportContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportContext.swift; sourceTree = "<group>"; };
C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportSubmitter.swift; sourceTree = "<group>"; };
C1A55E022E9F000000B10005 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = "<group>"; };
C1A55E022E9F000000B10007 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = "<group>"; };
C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceView.swift; sourceTree = "<group>"; };
C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceMenuItem.swift; sourceTree = "<group>"; };
C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopKitAnalytics.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -136,6 +144,7 @@
isa = PBXGroup;
children = (
C14A7E7A295F2EA700CD87B4 /* Views */,
C1A55E022E9F000000B10000 /* IssueReport */,
A936A95625BC9A190085119C /* LoopSupportKitUI.h */,
A936A95725BC9A190085119C /* Info.plist */,
B66D1F542E6A752300471149 /* Localizable.xcstrings */,
Expand Down Expand Up @@ -185,6 +194,17 @@
name = Frameworks;
sourceTree = "<group>";
};
C1A55E022E9F000000B10000 /* IssueReport */ = {
isa = PBXGroup;
children = (
C1A55E022E9F000000B10001 /* IssueReportContext.swift */,
C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */,
C1A55E022E9F000000B10005 /* ReportProblemView.swift */,
C1A55E022E9F000000B10007 /* SendIssueReportView.swift */,
);
path = IssueReport;
sourceTree = "<group>";
};
C14A7E7A295F2EA700CD87B4 /* Views */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -349,6 +369,10 @@
A9CE7DA125BC9C0200C04F9B /* OSLog.swift in Sources */,
C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */,
C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */,
C1A55E022E9F000000B10002 /* IssueReportContext.swift in Sources */,
C1A55E022E9F000000B10004 /* IssueReportSubmitter.swift in Sources */,
C1A55E022E9F000000B10006 /* ReportProblemView.swift in Sources */,
C1A55E022E9F000000B10008 /* SendIssueReportView.swift in Sources */,
A9CE7D8425BC9B3700C04F9B /* LocalizedString.swift in Sources */,
A9CE800325BCAC8700C04F9B /* LoopSupportUI.swift in Sources */,
C1BE9741295E3800008BA25D /* LoopKitAnalytics.swift in Sources */,
Expand Down
45 changes: 45 additions & 0 deletions LoopSupportKitUI/IssueReport/IssueReportContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// IssueReportContext.swift
// LoopSupportKitUI
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import UIKit
import LoopKit
import LoopKitUI

enum IssueReportContext {
/// Build, device and pump/CGM details sent with every issue report.
@MainActor
static func current(supportInfo: SupportInfoProvider?) -> [String: IssueReportContextValue] {
let info = Bundle.main.infoDictionary ?? [:]
let buildDetails = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist")
.flatMap { NSDictionary(contentsOf: $0) as? [String: Any] } ?? [:]

var context: [String: IssueReportContextValue] = [
"appVersion": .string(info["CFBundleShortVersionString"] as? String ?? ""),
"buildNumber": .string(info["CFBundleVersion"] as? String ?? ""),
"customizations": .strings(buildDetails["com-loopkit-Loop-customizations"] as? [String] ?? []),
"deviceModel": .string(modelIdentifier),
"osVersion": .string(UIDevice.current.systemVersion),
]
let branch = buildDetails["com-loopkit-LoopWorkspace-git-branch"] as? String ?? buildDetails["com-loopkit-Loop-git-branch"] as? String
context["branch"] = branch.map { .string($0) }
context["workspaceRevision"] = (buildDetails["com-loopkit-LoopWorkspace-git-revision"] as? String).map { .string($0) }
context["loopRevision"] = (buildDetails["com-loopkit-Loop-git-revision"] as? String).map { .string($0) }
context["pumpModel"] = supportInfo?.pumpStatus.map { .string($0.device.model ?? "Unknown") }
context["cgmModel"] = supportInfo?.cgmStatus.map { .string($0.device?.model ?? "Unknown") }
return context
}

private static var modelIdentifier: String {
#if targetEnvironment(simulator)
return "\(UIDevice.current.model)Simulator"
#else
var info = utsname()
uname(&info)
return withUnsafePointer(to: &info.machine) { $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(validatingUTF8: $0) } } ?? "unknown"
#endif
}
}
139 changes: 139 additions & 0 deletions LoopSupportKitUI/IssueReport/IssueReportSubmitter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// IssueReportSubmitter.swift
// LoopSupportKitUI
//
// 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 LocalizedString("The report is too large. Try fewer or smaller screenshots.", comment: "Issue report error when the upload is too large")
case .server(415, _):
return LocalizedString("One of the attachments isn't a supported image type.", comment: "Issue report error when an attachment type is refused")
case .server(429, _):
return LocalizedString("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: LocalizedString("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 LocalizedString("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
}
}
153 changes: 153 additions & 0 deletions LoopSupportKitUI/IssueReport/ReportProblemView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// ReportProblemView.swift
// LoopSupportKitUI
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import SwiftUI
import LoopKitUI

struct ReportProblemView: View {
let delegate: SupportUIDelegate?

@State private var isPreparingReport = false
@State private var reportFile: ReportFile?
@State private var writtenReportURL: URL?

private struct ReportFile: Identifiable {
let url: URL
var id: URL { url }
}

var body: some View {
List {
Section {
NavigationLink(destination: SendIssueReportView(
context: IssueReportContext.current(supportInfo: delegate),
generateDiagnosticReport: delegate.map { delegate in { await delegate.generateIssueReport() } }
)) {
option(
LocalizedString("Send to the Loop Developers", comment: "Report a problem option: submit an issue report to the developers"),
detail: LocalizedString("Private. Describe the problem and attach screenshots and a diagnostic report. Only the LoopKit development team can see it.", comment: "Report a problem option detail: submit an issue report to the developers")
)
}

if delegate != nil {
Button(action: shareDiagnosticReport) {
HStack {
option(
LocalizedString("Share a Diagnostic Report", comment: "Report a problem option: share the diagnostic report yourself"),
detail: LocalizedString("Save the report or send it to someone helping you, for example on Zulip or Facebook. It contains health data such as glucose, insulin, carbs and your therapy settings.", comment: "Report a problem option detail: share the diagnostic report yourself")
)
if isPreparingReport {
Spacer()
ProgressView()
}
}
}
.disabled(isPreparingReport)
}

Button(action: { delegate?.openURL(url: URL(string: "https://github.com/LoopKit/Loop/issues")!) }) {
option(
LocalizedString("Open a GitHub Issue", comment: "Report a problem option: open the public GitHub issues page"),
detail: LocalizedString("Public. For bugs you can describe without any personal or health data.", comment: "Report a problem option detail: open the public GitHub issues page")
)
}

if let delegate {
NavigationLink(destination: delegate.criticalEventLogExportView()) {
option(
LocalizedString("Export Critical Event Logs", comment: "Report a problem option: export the critical event logs"),
detail: LocalizedString("Detailed logs as files. Only needed when a developer asks for them.", comment: "Report a problem option detail: export the critical event logs")
)
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(LocalizedString("Report a Problem", comment: "Title of the report a problem screen"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $reportFile, onDismiss: removeReportFile) { file in
ActivityView(activityItems: [file.url])
}
}

private func option(_ title: String, detail: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.foregroundColor(.primary)
Text(detail)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}

private func shareDiagnosticReport() {
guard let delegate else {
return
}
isPreparingReport = true
Task { @MainActor in
let date = Date()
let report = "Generated: \(date)\n\n" + (await delegate.generateIssueReport())
let name = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name.replacingOccurrences(of: ":", with: "-"))
do {
try report.write(to: url, atomically: true, encoding: .utf8)
writtenReportURL = url
reportFile = ReportFile(url: url)
} catch {
reportFile = nil
}
isPreparingReport = false
}
}

private func removeReportFile() {
if let url = writtenReportURL {
try? FileManager.default.removeItem(at: url)
}
writtenReportURL = nil
}
}

struct ReportProblemMenuItem: View {
let support: LoopSupportUI

var body: some View {
NavigationLink(destination: ReportProblemView(delegate: support.delegate)) {
Text(LocalizedString("Report a Problem", comment: "The title text for the report a problem menu item"))
}
}
}

/// Report a Problem shown on its own, outside settings.
struct ReportProblemModal: View {
let delegate: SupportUIDelegate?

@Environment(\.dismissAction) private var dismiss

var body: some View {
NavigationView {
ReportProblemView(delegate: delegate)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(LocalizedString("Done", comment: "Button to close the report a problem screen"), action: dismiss)
}
}
}
}
}

private struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]

func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}

func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
Loading