213 lines
7.6 KiB
Swift
213 lines
7.6 KiB
Swift
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<Void, Never>?
|
|
#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"
|
|
}
|
|
}
|
|
}
|