Files
proton-stream/ios/ProtonStream/App/NewEpisodes.swift
T
nvrl d4e9628199 feat(ios): notify when a show gains episodes
A BGProcessingTask runs the same crawl_for_news no sooner than every six
hours, when iOS chooses. A processing task rather than an app refresh,
since a crawl of a large share does not fit in thirty seconds.
2026-10-13 21:26:15 +02:00

83 lines
3.3 KiB
Swift

import BackgroundTasks
import Foundation
import UserNotifications
/// Look at the shares while the app is away, and say when a show gained
/// episodes — what `NewEpisodesWorker` does on Android.
///
/// A processing task rather than an app refresh: a crawl of a large share
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
/// when it sees fit, mostly overnight on a charger, so it is a best effort
/// where Android's is a schedule.
enum NewEpisodes {
static let identifier = "io.narl.protonstream.new-episodes"
private static let interval: TimeInterval = 6 * 60 * 60
/// At launch, before the app finishes launching: iOS refuses a handler
/// registered later.
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
guard let task = task as? BGProcessingTask else { return }
run(task)
}
}
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
static func schedule() {
guard SettingsStore().newEpisodeNotifications else {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
return
}
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresNetworkConnectivity = true
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
try? BGTaskScheduler.shared.submit(request)
}
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
static func requestPermission() {
guard SettingsStore().newEpisodeNotifications else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
private static func run(_ task: BGProcessingTask) {
schedule()
let work = Task {
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
await post(news)
task.setTaskCompleted(success: !Task.isCancelled)
}
task.expirationHandler = {
work.cancel()
Task { try? await NativeRuntime.engine().stopCrawl() }
}
}
private static func post(_ news: [NewEpisodesRecord]) async {
let center = UNUserNotificationCenter.current()
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
for item in news {
let content = UNMutableNotificationContent()
content.title = item.title
content.body = describe(item)
content.threadIdentifier = "new-episodes"
// Keyed by the show, so a second look's news replaces the first.
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
try? await center.add(request)
}
}
/// What one show's notification says: "S01E04 and 2 more are new".
static func describe(_ news: NewEpisodesRecord) -> String {
let more = Int(news.count) - 1
guard let first = news.first else {
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
}
switch more {
case 0: return "\(first) is new"
case 1: return "\(first) and 1 more are new"
default: return "\(first) and \(more) more are new"
}
}
}