krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.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}