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
28 changes: 26 additions & 2 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 Expand Up @@ -477,7 +501,7 @@
GCC_WARN_UNUSED_LABEL = YES;
GCC_WARN_UNUSED_PARAMETER = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
LOCALIZED_STRING_MACRO_NAMES = (
NSLocalizedString,
Expand Down Expand Up @@ -582,7 +606,7 @@
GCC_WARN_UNUSED_LABEL = YES;
GCC_WARN_UNUSED_PARAMETER = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
LOCALIZED_STRING_MACRO_NAMES = (
NSLocalizedString,
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
}
}
Loading