| @@ -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 | } |