import BackgroundTasks import UIKit import UserNotifications @MainActor final class NotificationCoordinator: NSObject, @preconcurrency UNUserNotificationCenterDelegate { static let refreshIdentifier = "de.rfc1437.gotcha.notifications.refresh" private unowned let context: AppContext private let center = UNUserNotificationCenter.current() private var timer: Timer? private var pollingTask: Task? #if DEBUG private let validatesBackgroundNotifications = ProcessInfo.processInfo.arguments.contains( "--validate-background-notifications" ) #endif init(context: AppContext) { self.context = context } func start() { center.delegate = self BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.refreshIdentifier, using: nil ) { [weak self] task in Task { @MainActor in guard let self, let task = task as? BGAppRefreshTask else { task.setTaskCompleted(success: false) return } self.handle(task) } } timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { [weak self] _ in Task { @MainActor in self?.refresh(deliverAlerts: false) } } } func applicationDidBecomeActive() { #if DEBUG guard !validatesBackgroundNotifications else { return } #endif refresh(deliverAlerts: false) } func applicationDidEnterBackground() { scheduleBackgroundRefresh() #if DEBUG guard validatesBackgroundNotifications else { return } let identifier = UIApplication.shared.beginBackgroundTask() pollingTask?.cancel() pollingTask = Task { defer { UIApplication.shared.endBackgroundTask(identifier) } try? await poll(deliverAlerts: true) } #endif } func setEnabled(_ enabled: Bool) async throws -> Bool { if !enabled { try context.core.setNotificationsEnabled(enabled: false) BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier) center.removeAllPendingNotificationRequests() return false } var settings = await center.notificationSettings() if settings.authorizationStatus == .notDetermined { _ = try await center.requestAuthorization(options: [.alert, .sound]) settings = await center.notificationSettings() } guard Self.isAuthorized(settings.authorizationStatus) else { return false } try context.core.setNotificationsEnabled(enabled: true) scheduleBackgroundRefresh() try await poll(deliverAlerts: false) return true } func authorizationDescription() async -> String { switch await center.notificationSettings().authorizationStatus { case .notDetermined: return "Not requested" case .denied: return "Disabled in iOS Settings" case .authorized: return "Allowed" case .provisional: return "Delivered quietly" case .ephemeral: return "Allowed temporarily" @unknown default: return "Managed by iOS" } } func openSystemSettings() { guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return } UIApplication.shared.open(url) } private func refresh(deliverAlerts: Bool) { pollingTask?.cancel() pollingTask = Task { do { let settings = await center.notificationSettings() guard context.core.settings().notificationsEnabled, Self.isAuthorized(settings.authorizationStatus) else { return } try await poll(deliverAlerts: deliverAlerts) } catch { // Foreground screens surface API errors when the user explicitly refreshes them. } } } private func poll(deliverAlerts: Bool) async throws { let rows = try await context.core.pollNotifications() guard deliverAlerts else { return } for row in rows where row.target != .none { let content = UNMutableNotificationContent() content.title = title(for: row.target) content.body = "Open Gotcha to view the update." content.sound = .default content.threadIdentifier = "gotcha.\(row.serverId)" content.userInfo = [ "serverId": row.serverId, "threadId": row.id, "target": targetName(row.target), "owner": row.owner, "repository": row.repository, "number": row.number, "sha": row.sha, ] let request = UNNotificationRequest( identifier: "gotcha.\(row.serverId).\(row.id)", content: content, trigger: nil ) try await center.add(request) } } private func scheduleBackgroundRefresh() { guard context.core.settings().notificationsEnabled else { return } let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier) request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) try? BGTaskScheduler.shared.submit(request) } private func handle(_ backgroundTask: BGAppRefreshTask) { scheduleBackgroundRefresh() pollingTask?.cancel() let task = Task { do { let settings = await center.notificationSettings() guard Self.isAuthorized(settings.authorizationStatus) else { backgroundTask.setTaskCompleted(success: true) return } try await poll(deliverAlerts: true) backgroundTask.setTaskCompleted(success: true) } catch { backgroundTask.setTaskCompleted(success: false) } } pollingTask = task backgroundTask.expirationHandler = { task.cancel() } } func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { [] } func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { let userInfo = response.notification.request.content.userInfo context.route(notificationUserInfo: userInfo) guard let serverId = userInfo["serverId"] as? String, let id = userInfo["threadId"] as? NSNumber else { return } try? await context.core.markNotificationRead(serverId: serverId, id: id.int64Value) } private static func isAuthorized(_ status: UNAuthorizationStatus) -> Bool { status == .authorized || status == .provisional || status == .ephemeral } private func title(for target: ActivityTargetKind) -> String { switch target { case .repository: return "New repository notification" case .issue: return "New issue notification" case .pullRequest: return "New pull request notification" case .commit: return "New commit notification" case .none: return "New server notification" } } private func targetName(_ target: ActivityTargetKind) -> String { switch target { case .repository: return "repository" case .issue: return "issue" case .pullRequest: return "pull" case .commit: return "commit" case .none: return "none" } } }