Add native iOS notifications (#64)
This commit is contained in:
212
ios/Sources/NotificationCoordinator.swift
Normal file
212
ios/Sources/NotificationCoordinator.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user