Files
Gotcha/ios/Sources/NotificationCoordinator.swift
2026-08-15 12:50:05 +02:00

198 lines
7.0 KiB
Swift

import BackgroundTasks
import UIKit
import UserNotifications
@MainActor
final class NotificationCoordinator: NSObject, 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<Void, Never>?
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() {
refresh(deliverAlerts: false)
}
func applicationDidEnterBackground() {
scheduleBackgroundRefresh()
}
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() }
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[]
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
await MainActor.run {
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"
}
}
}