krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.8.1: DomainDig/LocalNotificationService.swift · raw

  1import Foundation
  2import UserNotifications
  3
  4@MainActor
  5final class LocalNotificationService {
  6    static let shared = LocalNotificationService()
  7
  8    private init() {}
  9
 10    static let domainUserInfoKey = "domain"
 11    static let domainCategoryIdentifier = "domain-event"
 12    static let reinspectActionIdentifier = "reinspect"
 13
 14    func configureForegroundPresentation() {
 15        let center = UNUserNotificationCenter.current()
 16        center.delegate = NotificationCenterDelegate.shared
 17
 18        let reinspect = UNNotificationAction(
 19            identifier: Self.reinspectActionIdentifier,
 20            title: "Re-inspect",
 21            options: []
 22        )
 23        center.setNotificationCategories([
 24            UNNotificationCategory(
 25                identifier: Self.domainCategoryIdentifier,
 26                actions: [reinspect],
 27                intentIdentifiers: [],
 28                options: []
 29            )
 30        ])
 31    }
 32
 33    func requestAuthorizationIfNeeded() async -> Bool {
 34        let center = UNUserNotificationCenter.current()
 35        let settings = await center.notificationSettings()
 36
 37        switch settings.authorizationStatus {
 38        case .authorized, .provisional, .ephemeral:
 39            return true
 40        case .notDetermined:
 41            return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
 42        case .denied:
 43            return false
 44        @unknown default:
 45            return false
 46        }
 47    }
 48
 49    func isAuthorizedForAlerts() async -> Bool {
 50        let settings = await UNUserNotificationCenter.current().notificationSettings()
 51        switch settings.authorizationStatus {
 52        case .authorized, .provisional, .ephemeral:
 53            return true
 54        case .denied, .notDetermined:
 55            return false
 56        @unknown default:
 57            return false
 58        }
 59    }
 60
 61    func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
 62        await schedule(
 63            identifier: "domain-change-\(domain)",
 64            title: domain,
 65            body: message,
 66            interruptionLevel: severity == .high ? .timeSensitive : .active,
 67            domain: domain
 68        )
 69    }
 70
 71    func notifyCertificateWarning(domain: String, daysRemaining: Int) async {
 72        await schedule(
 73            identifier: "cert-warning-\(domain)",
 74            title: domain,
 75            body: "Certificate expires in \(daysRemaining) days",
 76            interruptionLevel: .timeSensitive,
 77            domain: domain
 78        )
 79    }
 80
 81    func notifyMonitoringAlert(
 82        domain: String,
 83        message: String,
 84        severity: MonitoringAlertSeverity
 85    ) async {
 86        let interruptionLevel: UNNotificationInterruptionLevel
 87        switch severity {
 88        case .critical:
 89            interruptionLevel = .timeSensitive
 90        case .warning, .info:
 91            interruptionLevel = .active
 92        }
 93
 94        await schedule(
 95            identifier: "monitoring-\(domain)-\(UUID().uuidString)",
 96            title: domain,
 97            body: message,
 98            interruptionLevel: interruptionLevel,
 99            domain: domain
100        )
101    }
102
103    func notifyMonitoringSummary(
104        domain: String,
105        alerts: [MonitoringPendingAlert]
106    ) async {
107        let summary = alerts
108            .sorted { $0.detectedAt < $1.detectedAt }
109            .prefix(2)
110            .map(\.message)
111            .joined(separator: "")
112        let body: String
113        if alerts.count <= 1 {
114            body = alerts.first?.message ?? "Monitoring change detected"
115        } else if summary.isEmpty {
116            body = "\(alerts.count) monitoring changes detected"
117        } else {
118            body = "\(alerts.count) monitoring changes: \(summary)"
119        }
120
121        let severity = alerts.map(\.severity).max() ?? .info
122        let interruptionLevel: UNNotificationInterruptionLevel = severity == .critical ? .timeSensitive : .active
123
124        await schedule(
125            identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)",
126            title: domain,
127            body: body,
128            interruptionLevel: interruptionLevel,
129            domain: domain
130        )
131    }
132
133    func notifySweepComplete(summary: BatchSweepSummary) async {
134        let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
135        await schedule(
136            identifier: "sweep-complete",
137            title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete",
138            body: body,
139            interruptionLevel: .active
140        )
141    }
142
143    func notifyScheduledReportReady(domainCount: Int) async {
144        await schedule(
145            identifier: "scheduled-report-\(UUID().uuidString)",
146            title: "Scheduled Report Ready",
147            body: "Report generated for \(domainCount) domain\(domainCount == 1 ? "" : "s").",
148            interruptionLevel: .active
149        )
150    }
151
152    func clearAllNotifications() async {
153        let center = UNUserNotificationCenter.current()
154        center.removeAllPendingNotificationRequests()
155        center.removeAllDeliveredNotifications()
156    }
157
158    private func schedule(
159        identifier: String,
160        title: String,
161        body: String,
162        interruptionLevel: UNNotificationInterruptionLevel,
163        domain: String? = nil
164    ) async {
165        let content = UNMutableNotificationContent()
166        content.title = title
167        content.body = body
168        content.sound = .default
169        content.interruptionLevel = interruptionLevel
170        if let domain {
171            // Group alerts per domain and let taps/actions route back into it.
172            content.threadIdentifier = domain
173            content.userInfo = [Self.domainUserInfoKey: domain]
174            content.categoryIdentifier = Self.domainCategoryIdentifier
175        }
176
177        let request = UNNotificationRequest(
178            identifier: identifier,
179            content: content,
180            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
181        )
182
183        try? await UNUserNotificationCenter.current().add(request)
184    }
185}
186
187private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
188    static let shared = NotificationCenterDelegate()
189
190    func userNotificationCenter(
191        _ center: UNUserNotificationCenter,
192        willPresent notification: UNNotification
193    ) async -> UNNotificationPresentationOptions {
194        [.banner, .list, .sound]
195    }
196
197    func userNotificationCenter(
198        _ center: UNUserNotificationCenter,
199        didReceive response: UNNotificationResponse
200    ) async {
201        let userInfo = response.notification.request.content.userInfo
202        guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String,
203              !domain.isEmpty
204        else { return }
205
206        let action: DomainDigDeepLink.Action
207        switch response.actionIdentifier {
208        case LocalNotificationService.reinspectActionIdentifier:
209            action = .inspect(domain)
210        default:
211            // Default tap: open the tracked domain's detail.
212            action = .detail(domain)
213        }
214
215        await MainActor.run {
216            DomainDigIntentRouter.shared.pendingAction = action
217        }
218    }
219}