| @@ -0,0 +1,258 @@ |
| |
1 | import Foundation |
| |
2 | |
| |
3 | #if canImport(BackgroundTasks) |
| |
4 | import BackgroundTasks |
| |
5 | #endif |
| |
6 | |
| |
7 | #if canImport(UIKit) |
| |
8 | import UIKit |
| |
9 | #endif |
| |
10 | |
| |
11 | enum ScheduledReportCadence: String, Codable, CaseIterable, Identifiable { |
| |
12 | case daily |
| |
13 | case weekly |
| |
14 | |
| |
15 | var id: String { rawValue } |
| |
16 | var title: String { rawValue.capitalized } |
| |
17 | |
| |
18 | var interval: TimeInterval { |
| |
19 | switch self { |
| |
20 | case .daily: return 86400 |
| |
21 | case .weekly: return 7 * 86400 |
| |
22 | } |
| |
23 | } |
| |
24 | } |
| |
25 | |
| |
26 | struct ScheduledReportSettings: Codable, Equatable { |
| |
27 | var isEnabled: Bool = false |
| |
28 | var cadence: ScheduledReportCadence = .weekly |
| |
29 | var format: DomainExportFormat = .markdown |
| |
30 | var lastGeneratedAt: Date? |
| |
31 | } |
| |
32 | |
| |
33 | struct ScheduledReportLog: Codable, Identifiable, Equatable { |
| |
34 | let id: UUID |
| |
35 | let generatedAt: Date |
| |
36 | let domainCount: Int |
| |
37 | let format: DomainExportFormat |
| |
38 | let fileName: String |
| |
39 | |
| |
40 | init( |
| |
41 | id: UUID = UUID(), |
| |
42 | generatedAt: Date, |
| |
43 | domainCount: Int, |
| |
44 | format: DomainExportFormat, |
| |
45 | fileName: String |
| |
46 | ) { |
| |
47 | self.id = id |
| |
48 | self.generatedAt = generatedAt |
| |
49 | self.domainCount = domainCount |
| |
50 | self.format = format |
| |
51 | self.fileName = fileName |
| |
52 | } |
| |
53 | } |
| |
54 | |
| |
55 | /// Local, UserDefaults-backed persistence for scheduled-report settings and |
| |
56 | /// generation history. Deliberately not part of `DomainDataPortabilityService` |
| |
57 | /// backup/restore: this is local automation configuration, not user-authored |
| |
58 | /// content like tracked domains or audit sessions. |
| |
59 | enum ScheduledReportStorage { |
| |
60 | private static let settingsKey = "scheduledReport.settings" |
| |
61 | private static let logsKey = "scheduledReport.logs" |
| |
62 | static let maxLogs = 20 |
| |
63 | |
| |
64 | static func loadSettings() -> ScheduledReportSettings { |
| |
65 | guard let data = UserDefaults.standard.data(forKey: settingsKey), |
| |
66 | let settings = try? JSONDecoder().decode(ScheduledReportSettings.self, from: data) |
| |
67 | else { return ScheduledReportSettings() } |
| |
68 | return settings |
| |
69 | } |
| |
70 | |
| |
71 | static func saveSettings(_ settings: ScheduledReportSettings) { |
| |
72 | guard let data = try? JSONEncoder().encode(settings) else { return } |
| |
73 | UserDefaults.standard.set(data, forKey: settingsKey) |
| |
74 | } |
| |
75 | |
| |
76 | static func loadLogs() -> [ScheduledReportLog] { |
| |
77 | guard let data = UserDefaults.standard.data(forKey: logsKey), |
| |
78 | let logs = try? JSONDecoder().decode([ScheduledReportLog].self, from: data) |
| |
79 | else { return [] } |
| |
80 | return logs |
| |
81 | } |
| |
82 | |
| |
83 | static func saveLogs(_ logs: [ScheduledReportLog]) { |
| |
84 | guard let data = try? JSONEncoder().encode(Array(logs.prefix(maxLogs))) else { return } |
| |
85 | UserDefaults.standard.set(data, forKey: logsKey) |
| |
86 | } |
| |
87 | |
| |
88 | static var reportsDirectory: URL { |
| |
89 | let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] |
| |
90 | .appendingPathComponent("ScheduledReports", isDirectory: true) |
| |
91 | try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) |
| |
92 | return base |
| |
93 | } |
| |
94 | } |
| |
95 | |
| |
96 | struct ScheduledReportOutcome { |
| |
97 | let success: Bool |
| |
98 | let message: String |
| |
99 | let log: ScheduledReportLog? |
| |
100 | } |
| |
101 | |
| |
102 | /// Generates a bundled report for all tracked domains and writes it to disk. |
| |
103 | /// Mirrors `DomainMonitoringService`'s headless, storage-backed design so it |
| |
104 | /// can run from a background task without a live view model. |
| |
105 | @MainActor |
| |
106 | final class ScheduledReportService { |
| |
107 | static let shared = ScheduledReportService() |
| |
108 | |
| |
109 | private init() {} |
| |
110 | |
| |
111 | @discardableResult |
| |
112 | func generateReport(trigger: MonitoringRunTrigger, requireEnabledSetting: Bool) async -> ScheduledReportOutcome { |
| |
113 | var settings = ScheduledReportStorage.loadSettings() |
| |
114 | |
| |
115 | guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { |
| |
116 | if settings.isEnabled { |
| |
117 | settings.isEnabled = false |
| |
118 | ScheduledReportStorage.saveSettings(settings) |
| |
119 | ScheduledReportScheduler.shared.syncSchedule() |
| |
120 | } |
| |
121 | return ScheduledReportOutcome(success: false, message: "Scheduled reports require Pro.", log: nil) |
| |
122 | } |
| |
123 | |
| |
124 | if requireEnabledSetting, !settings.isEnabled { |
| |
125 | return ScheduledReportOutcome(success: false, message: "Scheduled reports are disabled.", log: nil) |
| |
126 | } |
| |
127 | |
| |
128 | let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() |
| |
129 | guard !trackedDomains.isEmpty else { |
| |
130 | return ScheduledReportOutcome(success: false, message: "No tracked domains to report on.", log: nil) |
| |
131 | } |
| |
132 | |
| |
133 | let history = DomainDataPortabilityService.loadHistoryEntries() |
| |
134 | let builder = DomainReportBuilder() |
| |
135 | let reports = trackedDomains.compactMap { trackedDomain -> DomainReport? in |
| |
136 | let entry = history.first { entry in |
| |
137 | if let trackedDomainID = entry.trackedDomainID { |
| |
138 | return trackedDomainID == trackedDomain.id |
| |
139 | } |
| |
140 | return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame |
| |
141 | } |
| |
142 | guard let entry else { return nil } |
| |
143 | return builder.build(from: entry, historyEntries: history) |
| |
144 | } |
| |
145 | |
| |
146 | guard !reports.isEmpty else { |
| |
147 | return ScheduledReportOutcome(success: false, message: "No history available for tracked domains yet.", log: nil) |
| |
148 | } |
| |
149 | |
| |
150 | guard let data = try? DomainReportExporter.data(for: reports, format: settings.format, title: "Scheduled Watchlist Report") else { |
| |
151 | return ScheduledReportOutcome(success: false, message: "Report generation failed.", log: nil) |
| |
152 | } |
| |
153 | |
| |
154 | let generatedAt = Date() |
| |
155 | let fileName = "\(Self.fileTimestampFormatter.string(from: generatedAt))_scheduled-report.\(settings.format.fileExtension)" |
| |
156 | let fileURL = ScheduledReportStorage.reportsDirectory.appendingPathComponent(fileName) |
| |
157 | |
| |
158 | do { |
| |
159 | try data.write(to: fileURL, options: .atomic) |
| |
160 | } catch { |
| |
161 | return ScheduledReportOutcome(success: false, message: "Could not save report to disk.", log: nil) |
| |
162 | } |
| |
163 | |
| |
164 | let log = ScheduledReportLog(generatedAt: generatedAt, domainCount: reports.count, format: settings.format, fileName: fileName) |
| |
165 | var logs = ScheduledReportStorage.loadLogs() |
| |
166 | logs.insert(log, at: 0) |
| |
167 | ScheduledReportStorage.saveLogs(logs) |
| |
168 | |
| |
169 | settings.lastGeneratedAt = generatedAt |
| |
170 | ScheduledReportStorage.saveSettings(settings) |
| |
171 | |
| |
172 | await LocalNotificationService.shared.notifyScheduledReportReady(domainCount: reports.count) |
| |
173 | |
| |
174 | return ScheduledReportOutcome(success: true, message: "Report generated for \(reports.count) domains.", log: log) |
| |
175 | } |
| |
176 | |
| |
177 | private static let fileTimestampFormatter: DateFormatter = { |
| |
178 | let formatter = DateFormatter() |
| |
179 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| |
180 | return formatter |
| |
181 | }() |
| |
182 | } |
| |
183 | |
| |
184 | /// Background scheduling for report generation, mirroring |
| |
185 | /// `DomainMonitoringScheduler`'s BGTaskScheduler-based approach with its own |
| |
186 | /// task identifier and cadence. |
| |
187 | @MainActor |
| |
188 | final class ScheduledReportScheduler { |
| |
189 | static let shared = ScheduledReportScheduler() |
| |
190 | static let taskIdentifier = "net.cleberg.DomainDig.report.schedule" |
| |
191 | |
| |
192 | private var isRegistered = false |
| |
193 | |
| |
194 | private init() {} |
| |
195 | |
| |
196 | func registerBackgroundTask() { |
| |
197 | #if canImport(BackgroundTasks) |
| |
198 | guard !isRegistered else { return } |
| |
199 | isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in |
| |
200 | guard let refreshTask = task as? BGAppRefreshTask else { |
| |
201 | task.setTaskCompleted(success: false) |
| |
202 | return |
| |
203 | } |
| |
204 | self.handleAppRefresh(task: refreshTask) |
| |
205 | } |
| |
206 | #endif |
| |
207 | } |
| |
208 | |
| |
209 | @discardableResult |
| |
210 | func syncSchedule() -> String? { |
| |
211 | #if canImport(BackgroundTasks) |
| |
212 | let settings = ScheduledReportStorage.loadSettings() |
| |
213 | guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else { |
| |
214 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) |
| |
215 | return nil |
| |
216 | } |
| |
217 | |
| |
218 | #if canImport(UIKit) |
| |
219 | guard UIApplication.shared.backgroundRefreshStatus == .available else { |
| |
220 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) |
| |
221 | return "Background refresh is unavailable." |
| |
222 | } |
| |
223 | #endif |
| |
224 | |
| |
225 | let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) |
| |
226 | let baseDate = settings.lastGeneratedAt ?? Date() |
| |
227 | request.earliestBeginDate = max( |
| |
228 | baseDate.addingTimeInterval(settings.cadence.interval), |
| |
229 | Date(timeIntervalSinceNow: 15 * 60) |
| |
230 | ) |
| |
231 | |
| |
232 | do { |
| |
233 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) |
| |
234 | try BGTaskScheduler.shared.submit(request) |
| |
235 | return nil |
| |
236 | } catch { |
| |
237 | return "Could not schedule reports." |
| |
238 | } |
| |
239 | #else |
| |
240 | return "Scheduled reports are unavailable on this platform." |
| |
241 | #endif |
| |
242 | } |
| |
243 | |
| |
244 | #if canImport(BackgroundTasks) |
| |
245 | private func handleAppRefresh(task: BGAppRefreshTask) { |
| |
246 | _ = syncSchedule() |
| |
247 | |
| |
248 | let worker = Task { |
| |
249 | let outcome = await ScheduledReportService.shared.generateReport(trigger: .background, requireEnabledSetting: true) |
| |
250 | task.setTaskCompleted(success: outcome.success) |
| |
251 | } |
| |
252 | |
| |
253 | task.expirationHandler = { |
| |
254 | worker.cancel() |
| |
255 | } |
| |
256 | } |
| |
257 | #endif |
| |
258 | } |