krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.6.0: 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 clearAllNotifications() async {
144        let center = UNUserNotificationCenter.current()
145        center.removeAllPendingNotificationRequests()
146        center.removeAllDeliveredNotifications()
147    }
148
149    private func schedule(
150        identifier: String,
151        title: String,
152        body: String,
153        interruptionLevel: UNNotificationInterruptionLevel,
154        domain: String? = nil
155    ) async {
156        let content = UNMutableNotificationContent()
157        content.title = title
158        content.body = body
159        content.sound = .default
160        content.interruptionLevel = interruptionLevel
161        if let domain {
162            // Group alerts per domain and let taps/actions route back into it.
163            content.threadIdentifier = domain
164            content.userInfo = [Self.domainUserInfoKey: domain]
165            content.categoryIdentifier = Self.domainCategoryIdentifier
166        }
167
168        let request = UNNotificationRequest(
169            identifier: identifier,
170            content: content,
171            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
172        )
173
174        try? await UNUserNotificationCenter.current().add(request)
175    }
176}
177
178private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
179    static let shared = NotificationCenterDelegate()
180
181    func userNotificationCenter(
182        _ center: UNUserNotificationCenter,
183        willPresent notification: UNNotification
184    ) async -> UNNotificationPresentationOptions {
185        [.banner, .list, .sound]
186    }
187
188    func userNotificationCenter(
189        _ center: UNUserNotificationCenter,
190        didReceive response: UNNotificationResponse
191    ) async {
192        let userInfo = response.notification.request.content.userInfo
193        guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String,
194              !domain.isEmpty
195        else { return }
196
197        let action: DomainDigDeepLink.Action
198        switch response.actionIdentifier {
199        case LocalNotificationService.reinspectActionIdentifier:
200            action = .inspect(domain)
201        default:
202            // Default tap: open the tracked domain's detail.
203            action = .detail(domain)
204        }
205
206        await MainActor.run {
207            DomainDigIntentRouter.shared.pendingAction = action
208        }
209    }
210}