summaryrefslogtreecommitdiff
path: root/DomainDig/ScheduledReportService.swift
blob: c1db3de532fc2696f29b01ed7d52046865543fde (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import Foundation

#if canImport(BackgroundTasks)
import BackgroundTasks
#endif

#if canImport(UIKit)
import UIKit
#endif

enum ScheduledReportCadence: String, Codable, CaseIterable, Identifiable {
    case daily
    case weekly

    var id: String { rawValue }
    var title: String { rawValue.capitalized }

    var interval: TimeInterval {
        switch self {
        case .daily: return 86400
        case .weekly: return 7 * 86400
        }
    }
}

struct ScheduledReportSettings: Codable, Equatable {
    var isEnabled: Bool = false
    var cadence: ScheduledReportCadence = .weekly
    var format: DomainExportFormat = .markdown
    var lastGeneratedAt: Date?
}

struct ScheduledReportLog: Codable, Identifiable, Equatable {
    let id: UUID
    let generatedAt: Date
    let domainCount: Int
    let format: DomainExportFormat
    let fileName: String

    init(
        id: UUID = UUID(),
        generatedAt: Date,
        domainCount: Int,
        format: DomainExportFormat,
        fileName: String
    ) {
        self.id = id
        self.generatedAt = generatedAt
        self.domainCount = domainCount
        self.format = format
        self.fileName = fileName
    }
}

/// Local, UserDefaults-backed persistence for scheduled-report settings and
/// generation history. Deliberately not part of `DomainDataPortabilityService`
/// backup/restore: this is local automation configuration, not user-authored
/// content like tracked domains or audit sessions.
enum ScheduledReportStorage {
    private static let settingsKey = "scheduledReport.settings"
    private static let logsKey = "scheduledReport.logs"
    static let maxLogs = 20

    static func loadSettings() -> ScheduledReportSettings {
        guard let data = UserDefaults.standard.data(forKey: settingsKey),
              let settings = try? JSONDecoder().decode(ScheduledReportSettings.self, from: data)
        else { return ScheduledReportSettings() }
        return settings
    }

    static func saveSettings(_ settings: ScheduledReportSettings) {
        guard let data = try? JSONEncoder().encode(settings) else { return }
        UserDefaults.standard.set(data, forKey: settingsKey)
    }

    static func loadLogs() -> [ScheduledReportLog] {
        guard let data = UserDefaults.standard.data(forKey: logsKey),
              let logs = try? JSONDecoder().decode([ScheduledReportLog].self, from: data)
        else { return [] }
        return logs
    }

    static func saveLogs(_ logs: [ScheduledReportLog]) {
        guard let data = try? JSONEncoder().encode(Array(logs.prefix(maxLogs))) else { return }
        UserDefaults.standard.set(data, forKey: logsKey)
    }

    static var reportsDirectory: URL {
        let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("ScheduledReports", isDirectory: true)
        try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
        return base
    }
}

struct ScheduledReportOutcome {
    let success: Bool
    let message: String
    let log: ScheduledReportLog?
}

/// Generates a bundled report for all tracked domains and writes it to disk.
/// Mirrors `DomainMonitoringService`'s headless, storage-backed design so it
/// can run from a background task without a live view model.
@MainActor
final class ScheduledReportService {
    static let shared = ScheduledReportService()

    private init() {}

    @discardableResult
    func generateReport(trigger: MonitoringRunTrigger, requireEnabledSetting: Bool) async -> ScheduledReportOutcome {
        var settings = ScheduledReportStorage.loadSettings()

        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
            if settings.isEnabled {
                settings.isEnabled = false
                ScheduledReportStorage.saveSettings(settings)
                ScheduledReportScheduler.shared.syncSchedule()
            }
            return ScheduledReportOutcome(success: false, message: "Scheduled reports require Pro.", log: nil)
        }

        if requireEnabledSetting, !settings.isEnabled {
            return ScheduledReportOutcome(success: false, message: "Scheduled reports are disabled.", log: nil)
        }

        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
        guard !trackedDomains.isEmpty else {
            return ScheduledReportOutcome(success: false, message: "No tracked domains to report on.", log: nil)
        }

        let history = DomainDataPortabilityService.loadHistoryEntries()
        let builder = DomainReportBuilder()
        let reports = trackedDomains.compactMap { trackedDomain -> DomainReport? in
            let entry = history.first { entry in
                if let trackedDomainID = entry.trackedDomainID {
                    return trackedDomainID == trackedDomain.id
                }
                return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
            }
            guard let entry else { return nil }
            return builder.build(from: entry, historyEntries: history)
        }

        guard !reports.isEmpty else {
            return ScheduledReportOutcome(success: false, message: "No history available for tracked domains yet.", log: nil)
        }

        guard let data = try? DomainReportExporter.data(for: reports, format: settings.format, title: "Scheduled Watchlist Report") else {
            return ScheduledReportOutcome(success: false, message: "Report generation failed.", log: nil)
        }

        let generatedAt = Date()
        let fileName = "\(Self.fileTimestampFormatter.string(from: generatedAt))_scheduled-report.\(settings.format.fileExtension)"
        let fileURL = ScheduledReportStorage.reportsDirectory.appendingPathComponent(fileName)

        do {
            try data.write(to: fileURL, options: .atomic)
        } catch {
            return ScheduledReportOutcome(success: false, message: "Could not save report to disk.", log: nil)
        }

        let log = ScheduledReportLog(generatedAt: generatedAt, domainCount: reports.count, format: settings.format, fileName: fileName)
        var logs = ScheduledReportStorage.loadLogs()
        logs.insert(log, at: 0)
        ScheduledReportStorage.saveLogs(logs)

        settings.lastGeneratedAt = generatedAt
        ScheduledReportStorage.saveSettings(settings)

        await LocalNotificationService.shared.notifyScheduledReportReady(domainCount: reports.count)

        return ScheduledReportOutcome(success: true, message: "Report generated for \(reports.count) domains.", log: log)
    }

    private static let fileTimestampFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyyMMdd_HHmmss"
        return formatter
    }()
}

/// Background scheduling for report generation, mirroring
/// `DomainMonitoringScheduler`'s BGTaskScheduler-based approach with its own
/// task identifier and cadence.
@MainActor
final class ScheduledReportScheduler {
    static let shared = ScheduledReportScheduler()
    static let taskIdentifier = "net.cleberg.DomainDig.report.schedule"

    private var isRegistered = false

    private init() {}

    func registerBackgroundTask() {
        #if canImport(BackgroundTasks)
        guard !isRegistered else { return }
        isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
            guard let refreshTask = task as? BGAppRefreshTask else {
                task.setTaskCompleted(success: false)
                return
            }
            self.handleAppRefresh(task: refreshTask)
        }
        #endif
    }

    @discardableResult
    func syncSchedule() -> String? {
        #if canImport(BackgroundTasks)
        let settings = ScheduledReportStorage.loadSettings()
        guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
            return nil
        }

        #if canImport(UIKit)
        guard UIApplication.shared.backgroundRefreshStatus == .available else {
            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
            return "Background refresh is unavailable."
        }
        #endif

        let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
        let baseDate = settings.lastGeneratedAt ?? Date()
        request.earliestBeginDate = max(
            baseDate.addingTimeInterval(settings.cadence.interval),
            Date(timeIntervalSinceNow: 15 * 60)
        )

        do {
            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
            try BGTaskScheduler.shared.submit(request)
            return nil
        } catch {
            return "Could not schedule reports."
        }
        #else
        return "Scheduled reports are unavailable on this platform."
        #endif
    }

    #if canImport(BackgroundTasks)
    private func handleAppRefresh(task: BGAppRefreshTask) {
        _ = syncSchedule()

        let worker = Task {
            let outcome = await ScheduledReportService.shared.generateReport(trigger: .background, requireEnabledSetting: true)
            task.setTaskCompleted(success: outcome.success)
        }

        task.expirationHandler = {
            worker.cancel()
        }
    }
    #endif
}