import BackgroundTasks import UIKit import UserNotifications /// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421). /// /// Set when the app comes forward, after the player marks an item read, and by a background /// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is /// opened often), so the number can be hours behind; push would make it exact and needs a sender /// in ipx. enum Badge { static let taskId = "net.sdf1.ipx.refresh" private struct Row: Decodable { let id: String let group: String? let unread: Int? let hide_from_all: Bool? } /// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is /// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page /// does not turn the badge to zero. static func unread(inAll body: Data) -> Int? { guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil } let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id)) return rows.reduce(0) { n, f in if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n } return n + max(0, f.unread ?? 0) } } /// Asks once, for the badge alone: no alerts, no sounds. static func requestPermission() { // `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page // has nobody to tap Allow, and the dialog covers it. if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return } UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in // Each launch, not once: Apple can change the token, and says to ask every time. guard granted else { return } Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } } } /// The page's own count, sent as its list changes (#421). @MainActor static func set(_ n: Int) { lastSet = Date() Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) } } /// When the icon was last set from a count, by either way. @MainActor private static var lastSet = Date.distantPast /// While an episode plays with the screen locked the app is awake, unlike the page: so new /// items reach the icon every quarter of an hour, which background refresh does not promise. @MainActor static func refreshIfStale() { guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return } lastSet = Date() Task { await refresh() } } /// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer /// with a feed list: an old number is better than a wrong one. @discardableResult static func refresh() async -> Bool { guard let url = Server.resolveForPlayer("/api/feeds") else { return false } var req = URLRequest(url: url) req.timeoutInterval = 20 req.setValue("application/json", forHTTPHeaderField: "Accept") Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } let cfg = URLSessionConfiguration.default cfg.httpCookieStorage = .shared guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200, let n = unread(inAll: data) else { return false } await MainActor.run { set(n) } return true } // MARK: - while the app is closed /// Before the app finishes launching, which is when iOS wants the handler registered. static func registerBackgroundRefresh() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in // The next one first, so a run that fails or is cut short does not end the chain. scheduleBackgroundRefresh() let work = Task { task.setTaskCompleted(success: await refresh()) } task.expirationHandler = { work.cancel() } } } static func scheduleBackgroundRefresh() { let req = BGAppRefreshTaskRequest(identifier: taskId) req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) try? BGTaskScheduler.shared.submit(req) } }