From 2fbe2b50f05518642b7be25c7f0495a891614922 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 15 Oct 2026 14:59:53 -0500 Subject: [PATCH 1/3] Require iOS 17.6 The project-level 15.1 setting overrode Loop.xcconfig, so the app still targeted iOS 15.1. Remove it and raise Loop.xcconfig to 17.6, which matches next-dev and the plugins that already require it. --- Loop.xcconfig | 2 +- Loop.xcodeproj/project.pbxproj | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Loop.xcconfig b/Loop.xcconfig index 7827f574d5..c40d01accc 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -43,7 +43,7 @@ LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE = LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE = // Min iOS Version [DEFAULT] -IPHONEOS_DEPLOYMENT_TARGET = 16.2 +IPHONEOS_DEPLOYMENT_TARGET = 17.6 // Base string for opening app via URL [DEFAULT] URL_SCHEME_NAME = $(MAIN_APP_DISPLAY_NAME) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8f8f9dce09..45c777d110 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4283,7 +4283,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -4395,7 +4394,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, From 356682316522f7f42e03754c9a931f2a546e3c3b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 15 Oct 2026 15:02:20 -0500 Subject: [PATCH 2/3] Submit issue reports to the LoopKit developers Backport of LoopKit/Loop#2585 from next-dev. Settings > Support > Report Issue now opens one screen where the person either submits a report to ingest.loopkit.org/v1/issues or exports Loop's diagnostic report locally, which opens the existing issue report screen. A submitted report has a description, optional title and screenshots. It is anonymous unless the person fills in a name, email, Zulip or GitHub contact, and the analytics device ID is never sent. Build and device details (version, build, branch, revisions, device model, OS, pump and CGM model) are listed on the screen and sent with every report. Loop's diagnostic report is attached as text by default; the toggle says it contains health data. Photos other than PNG are converted to JPEG. dev has no build customizations list, so that context key is left out. The diagnostic report and the pump and CGM models come from the settings delegate, since dev's settings view model has no device manager. --- Loop.xcodeproj/project.pbxproj | 8 + Loop/Managers/IssueReportSubmitter.swift | 139 ++++++++++ .../StatusTableViewController.swift | 14 + Loop/View Models/SettingsViewModel.swift | 27 ++ Loop/Views/SendIssueReportView.swift | 254 ++++++++++++++++++ Loop/Views/SettingsView.swift | 10 +- 6 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 Loop/Managers/IssueReportSubmitter.swift create mode 100644 Loop/Views/SendIssueReportView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 45c777d110..504223dd3a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -54,6 +54,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 */; }; @@ -344,6 +345,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 */; }; @@ -789,6 +791,7 @@ 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataClass.swift"; sourceTree = ""; }; 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataProperties.swift"; sourceTree = ""; }; 1D63DEA426E950D400F46FA5 /* SupportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManager.swift; sourceTree = ""; }; + C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportSubmitter.swift; sourceTree = ""; }; 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerTests.swift; sourceTree = ""; }; 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; @@ -1154,6 +1157,7 @@ A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = ""; }; A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; + C1A55E012E9F000000A10003 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = ""; }; A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; A9B996EF27235191002DC09C /* LoopWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWarning.swift; sourceTree = ""; }; @@ -1976,6 +1980,7 @@ 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */, 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, + C1A55E012E9F000000A10003 /* SendIssueReportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, @@ -2033,6 +2038,7 @@ B470F5832AB22B5100049695 /* StatefulPluggable.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, + C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */, 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, @@ -3389,6 +3395,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 */, 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */, C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, @@ -3509,6 +3516,7 @@ A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, + C1A55E012E9F000000A10002 /* IssueReportSubmitter.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, diff --git a/Loop/Managers/IssueReportSubmitter.swift b/Loop/Managers/IssueReportSubmitter.swift new file mode 100644 index 0000000000..68669dd2f3 --- /dev/null +++ b/Loop/Managers/IssueReportSubmitter.swift @@ -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 + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3e124fa7f5..487da36e7e 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -2233,6 +2233,20 @@ extension StatusTableViewController: SettingsViewModelDelegate { self.show(vc, sender: nil) } } + + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + deviceManager.generateDiagnosticReport { continuation.resume(returning: $0) } + } + } + + var pumpModel: String? { + deviceManager.pumpManager.map { $0.status.device.model ?? "Unknown" } + } + + var cgmModel: String? { + deviceManager.cgmManager.map { $0.cgmManagerStatus.device?.model ?? "Unknown" } + } } // MARK: - Services delegation diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d4b48766b3..29bb11c482 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -53,7 +53,10 @@ public protocol SettingsViewModelDelegate: AnyObject { func dosingEnabledChanged(_: Bool) func dosingStrategyChanged(_: AutomaticDosingStrategy) func didTapIssueReport() + func generateDiagnosticReport() async -> String var closedLoopDescriptiveText: String? { get } + var pumpModel: String? { get } + var cgmModel: String? { get } } public class SettingsViewModel: ObservableObject { @@ -69,6 +72,30 @@ public class SettingsViewModel: ObservableObject { 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), + "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"] = delegate?.pumpModel.map { .string($0) } + context["cgmModel"] = delegate?.cgmModel.map { .string($0) } + return context + } var availableSupports: [SupportUI] let pumpManagerSettingsViewModel: PumpManagerViewModel diff --git a/Loop/Views/SendIssueReportView.swift b/Loop/Views/SendIssueReportView.swift new file mode 100644 index 0000000000..5eb2ca73ee --- /dev/null +++ b/Loop/Views/SendIssueReportView.swift @@ -0,0 +1,254 @@ +// +// SendIssueReportView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import PhotosUI +import SwiftUI + +struct SendIssueReportView: View { + let context: [String: IssueReportContextValue] + let generateDiagnosticReport: (() async -> String)? + let exportLocally: () -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var title = "" + @State private var description = "" + @State private var isAnonymous = true + @State private var contact = IssueReport.Contact() + @State private var includeDiagnosticReport = true + @State private var photoSelection: [PhotosPickerItem] = [] + @State private var screenshots: [Screenshot] = [] + @State private var isSending = false + @State private var sentReportID: String? + @State private var sendError: IssueReportSubmitterError? + @State private var showingError = false + + private static let maxDescriptionLength = 20_000 + private static let maxScreenshots = 8 + private static let maxAttachmentBytes = 10_000_000 + + private struct Screenshot: Identifiable { + let id = UUID() + let image: UIImage + let attachment: IssueReport.Attachment + } + + var body: some View { + Form { + Section(footer: Text(String(format: NSLocalizedString("%1$d of %2$d characters", comment: "Issue report description length (1: used)(2: limit)"), description.count, Self.maxDescriptionLength))) { + TextField(NSLocalizedString("Title (optional)", comment: "Placeholder for the issue report title"), text: $title) + ZStack(alignment: .topLeading) { + if description.isEmpty { + Text(NSLocalizedString("Describe what you saw and what you expected to happen", comment: "Placeholder for the issue report description")) + .foregroundColor(Color(.placeholderText)) + .padding(.top, 8) + .padding(.leading, 4) + } + TextEditor(text: $description) + .frame(minHeight: 160) + } + } + + screenshotsSection + + Section(footer: Text(isAnonymous ? + NSLocalizedString("The report won't include your name, contact details or anything else that identifies you.", comment: "Footer for an anonymous issue report") : + NSLocalizedString("Fill in any of these so developers can follow up with you.", comment: "Footer for issue report contact details")) + ) { + Toggle(NSLocalizedString("Submit Anonymously", comment: "Toggle for submitting an issue report without contact details"), isOn: $isAnonymous) + if !isAnonymous { + TextField(NSLocalizedString("Name", comment: "Issue report contact name placeholder"), text: binding(\.name)) + .textContentType(.name) + TextField(NSLocalizedString("Email", comment: "Issue report contact email placeholder"), text: binding(\.email)) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + TextField(NSLocalizedString("Zulip Name", comment: "Issue report contact Zulip placeholder"), text: binding(\.zulip)) + .textInputAutocapitalization(.never) + TextField(NSLocalizedString("GitHub Username", comment: "Issue report contact GitHub placeholder"), text: binding(\.github)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if generateDiagnosticReport != nil { + Section(footer: Text(NSLocalizedString("Attaches Loop's issue report as a text file. It contains health data such as recent glucose, insulin, carbs and your therapy settings.", comment: "Footer for the include diagnostic report toggle"))) { + Toggle(NSLocalizedString("Include Diagnostic Report", comment: "Toggle for attaching the diagnostic report to an issue report"), isOn: $includeDiagnosticReport) + } + } + + Section(header: Text(NSLocalizedString("Also Sent", comment: "Header for the build and device details sent with an issue report")), + footer: Text(NSLocalizedString("Reports go to the LoopKit development team and are visible only to them. Your IP address is used only to limit how often reports can be sent and is never stored.", comment: "Privacy footer for issue reports"))) + { + ForEach(context.keys.sorted(), id: \.self) { key in + HStack { + Text(key) + Spacer() + Text(Self.displayValue(context[key])) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + .font(.footnote) + } + } + + Section(footer: Text(NSLocalizedString("Opens Loop's diagnostic report so you can save it or share it yourself. Nothing is sent to the developers.", comment: "Footer for exporting the issue report locally"))) { + Button(NSLocalizedString("Export Locally Instead", comment: "Button to open the diagnostic report for local export instead of submitting"), action: exportLocally) + } + } + .navigationTitle(NSLocalizedString("Report Issue", comment: "Title of the issue report screen")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if isSending { + ProgressView() + } else { + Button(NSLocalizedString("Submit", comment: "Button to submit an issue report to the developers")) { + send() + } + .disabled(!canSend) + } + } + } + .disabled(isSending) + .onChange(of: photoSelection) { _, items in + loadScreenshots(items) + } + .alert(NSLocalizedString("Report Submitted", comment: "Title of the alert after an issue report was submitted"), isPresented: Binding(get: { sentReportID != nil }, set: { if !$0 { sentReportID = nil } })) { + Button(NSLocalizedString("Copy ID", comment: "Button to copy the issue report ID")) { + UIPasteboard.general.string = sentReportID + dismiss() + } + Button(NSLocalizedString("Done", comment: "Button to close the issue report sent alert"), role: .cancel) { + dismiss() + } + } message: { + Text(String(format: NSLocalizedString("Thank you. Your report ID is %1$@. Mention it if you discuss the problem on Zulip or GitHub.", comment: "Message after an issue report was sent (1: report ID)"), sentReportID ?? "")) + } + .alert(NSLocalizedString("Unable to Submit Report", comment: "Title of the alert when an issue report fails"), isPresented: $showingError, presenting: sendError) { error in + if error.isRetryable { + Button(NSLocalizedString("Try Again", comment: "Button to retry sending an issue report")) { + send() + } + } + Button(NSLocalizedString("OK", comment: "Button to close the issue report error alert"), role: .cancel) {} + } message: { error in + Text(error.localizedDescription) + } + } + + private var screenshotsSection: some View { + Section(footer: Text(String(format: NSLocalizedString("Up to %1$d screenshots or photos.", comment: "Footer for issue report screenshots (1: limit)"), Self.maxScreenshots))) { + if !screenshots.isEmpty { + ScrollView(.horizontal) { + HStack { + ForEach(screenshots) { screenshot in + Image(uiImage: screenshot.image) + .resizable() + .scaledToFit() + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + } + PhotosPicker(selection: $photoSelection, maxSelectionCount: Self.maxScreenshots, matching: .images) { + Text(screenshots.isEmpty ? + NSLocalizedString("Add Screenshots", comment: "Button to choose screenshots for an issue report") : + NSLocalizedString("Change Screenshots", comment: "Button to change the screenshots on an issue report")) + } + } + } + + private var trimmedDescription: String { + description.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSend: Bool { + !trimmedDescription.isEmpty && description.count <= Self.maxDescriptionLength + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding(get: { contact[keyPath: keyPath] ?? "" }, set: { contact[keyPath: keyPath] = $0 }) + } + + private func loadScreenshots(_ items: [PhotosPickerItem]) { + Task { + var loaded: [Screenshot] = [] + for (index, item) in items.enumerated() { + guard let data = try? await item.loadTransferable(type: Data.self), let screenshot = Self.screenshot(from: data, index: index + 1) else { + continue + } + loaded.append(screenshot) + } + screenshots = loaded + } + } + + // The server accepts only PNG and JPEG, so anything else (HEIC from the camera) is converted + private static func screenshot(from data: Data, index: Int) -> Screenshot? { + guard let image = UIImage(data: data) else { + return nil + } + if data.starts(with: [0x89, 0x50, 0x4E, 0x47]), data.count <= maxAttachmentBytes { + return Screenshot(image: image, attachment: .init(filename: "screenshot-\(index).png", contentType: "image/png", data: data)) + } + for quality in [0.8, 0.5, 0.3] { + if let jpeg = image.jpegData(compressionQuality: quality), jpeg.count <= maxAttachmentBytes { + return Screenshot(image: image, attachment: .init(filename: "screenshot-\(index).jpg", contentType: "image/jpeg", data: jpeg)) + } + } + return nil + } + + private static func displayValue(_ value: IssueReportContextValue?) -> String { + switch value { + case .string(let string): return string + case .strings(let strings): return strings.isEmpty ? "–" : strings.joined(separator: ", ") + case nil: return "" + } + } + + private func send() { + isSending = true + Task { + var attachments = screenshots.map(\.attachment) + if includeDiagnosticReport, let generateDiagnosticReport { + let report = await generateDiagnosticReport() + // Stay under the server's per-file limit; decoding repairs a character cut in half + let text = String(decoding: report.utf8.prefix(Self.maxAttachmentBytes - 4), as: UTF8.self) + attachments.append(.init(filename: "diagnostic-report.txt", contentType: "text/plain; charset=utf-8", data: Data(text.utf8))) + } + let report = IssueReport( + title: title.isEmpty ? nil : String(title.prefix(200)), + description: trimmedDescription, + contact: isAnonymous ? nil : Self.trimmed(contact), + context: context, + attachments: attachments + ) + do { + sentReportID = try await IssueReportSubmitter().submit(report) + } catch let error as IssueReportSubmitterError { + sendError = error + showingError = true + } catch { + sendError = .network(error) + showingError = true + } + isSending = false + } + } + + private static func trimmed(_ contact: IssueReport.Contact) -> IssueReport.Contact? { + func clean(_ value: String?) -> String? { + let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } + let result = IssueReport.Contact(name: clean(contact.name), email: clean(contact.email), zulip: clean(contact.zulip), github: clean(contact.github)) + return [result.name, result.email, result.zulip, result.github].allSatisfy({ $0 == nil }) ? nil : result + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 5575fa8c33..b0f2567f18 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -457,10 +457,12 @@ extension SettingsView { private var supportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "The title of the support section in settings"))) { - Button(action: { - self.viewModel.didTapIssueReport() - }) { - Text("Issue Report", comment: "The title text for the issue report menu item") + NavigationLink(destination: SendIssueReportView( + context: viewModel.issueReportContext, + generateDiagnosticReport: viewModel.generateDiagnosticReport, + exportLocally: viewModel.didTapIssueReport + )) { + Text(NSLocalizedString("Report Issue", comment: "The title text for the issue report menu item")) } ForEach(pluginMenuItems.filter( { $0.section == .support })) { From b30a0ec2b69fd03b4536430a1807a436d327da7e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 17 Oct 2026 22:52:27 -0500 Subject: [PATCH 3/3] Include build customizations in issue reports Backport of LoopKit/Loop#2577 from next-dev. capture-build-details.sh records each *.loopcustomization file that the LoopWorkspace customization script adds, the diagnostic report lists them, and an issue report sends them as "customizations" with the other build details. --- Common/Models/BuildDetails.swift | 4 ++++ Loop/Managers/DeviceDataManager.swift | 1 + Loop/View Models/SettingsViewModel.swift | 1 + Scripts/capture-build-details.sh | 14 ++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/Common/Models/BuildDetails.swift b/Common/Models/BuildDetails.swift index b71e265885..aed7cf9c4b 100644 --- a/Common/Models/BuildDetails.swift +++ b/Common/Models/BuildDetails.swift @@ -66,6 +66,10 @@ class BuildDetails { return dict["com-loopkit-LoopWorkspace-git-branch"] as? String } + var customizations: [String] { + return dict["com-loopkit-Loop-customizations"] as? [String] ?? [] + } + /// Returns a dictionary of submodule details. /// The keys are the submodule names, and the values are tuples (branch, commitSHA). var submodules: [String: (branch: String, commitSHA: String)] { diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 09ee0c11db..8ca3dca6f1 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1741,6 +1741,7 @@ extension DeviceDataManager: DeviceSupportDelegate { "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", "* Workspace branch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", "* Workspace SHA: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* Customizations: \(BuildDetails.default.customizations.isEmpty ? "None" : BuildDetails.default.customizations.joined(separator: ", "))", "* Submodule name: branch, SHA", "\(submodulesInfo)", "", diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 29bb11c482..672dfa449e 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -86,6 +86,7 @@ public class SettingsViewModel: ObservableObject { 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), ] diff --git a/Scripts/capture-build-details.sh b/Scripts/capture-build-details.sh index b78eb1c592..3d855c8d50 100755 --- a/Scripts/capture-build-details.sh +++ b/Scripts/capture-build-details.sh @@ -101,6 +101,20 @@ then popd . > /dev/null fi +# --- Customizations --- +# Each customization adds an empty .loopcustomization file at the workspace root or a submodule root. +customizations_key="com-loopkit-Loop-customizations" +/usr/libexec/PlistBuddy -c "Delete :${customizations_key}" "${info_plist_path}" 2>/dev/null || true +/usr/libexec/PlistBuddy -c "Add :${customizations_key} array" "${info_plist_path}" +if [ -e ../.git ]; then + customizations_root=.. +else + customizations_root=. +fi +find "${customizations_root}" -maxdepth 2 -name '*.loopcustomization' -exec basename {} .loopcustomization \; | sort -u | while read -r identifier; do + /usr/libexec/PlistBuddy -c "Add :${customizations_key}: string ${identifier}" "${info_plist_path}" +done + # --- Root repo details --- # Retrieve current branch (or tag) and commit SHA. git_branch=$(git symbolic-ref --short -q HEAD || echo "")