mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
417
Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift
Normal file
417
Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift
Normal file
@@ -0,0 +1,417 @@
|
||||
//
|
||||
// BackgroundFeedRefresher.swift
|
||||
// Yattee
|
||||
//
|
||||
// Core background feed refresh logic for notifications.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Performs background feed refresh and detects new videos for notifications.
|
||||
/// Only fetches videos from channels with notifications enabled.
|
||||
@MainActor
|
||||
final class BackgroundFeedRefresher {
|
||||
// MARK: - Dependencies
|
||||
|
||||
private weak var appEnvironment: AppEnvironment?
|
||||
private let notificationManager: NotificationManager
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(notificationManager: NotificationManager) {
|
||||
self.notificationManager = notificationManager
|
||||
}
|
||||
|
||||
func setAppEnvironment(_ environment: AppEnvironment) {
|
||||
self.appEnvironment = environment
|
||||
}
|
||||
|
||||
// MARK: - Refresh Logic
|
||||
|
||||
/// Performs background refresh and sends notifications for new videos.
|
||||
/// Routes to appropriate refresh method based on subscription account type.
|
||||
func performBackgroundRefresh() async {
|
||||
guard let appEnvironment else {
|
||||
LoggingService.shared.warning("Background refresh skipped: no app environment", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
guard appEnvironment.settingsManager.backgroundNotificationsEnabled else {
|
||||
LoggingService.shared.debug("Background refresh disabled in settings", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on subscription account type
|
||||
switch appEnvironment.settingsManager.subscriptionAccount.type {
|
||||
case .local:
|
||||
await performLocalAccountRefresh()
|
||||
case .invidious:
|
||||
await performInvidiousAccountRefresh()
|
||||
case .piped:
|
||||
await performPipedAccountRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Account Refresh
|
||||
|
||||
/// Performs background refresh for local (Yattee/iCloud) subscription account.
|
||||
/// Requires Yattee Server - uses stateless feed endpoint for efficient single-request fetching.
|
||||
private func performLocalAccountRefresh() async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
// Require Yattee Server for local account background refresh
|
||||
guard let yatteeServer = appEnvironment.instancesManager.instances
|
||||
.first(where: { $0.type == .yatteeServer && $0.isEnabled }) else {
|
||||
LoggingService.shared.debug("Background refresh requires Yattee Server for local subscriptions", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
let lastCheckDate = appEnvironment.settingsManager.lastBackgroundCheck ?? Date.distantPast
|
||||
|
||||
// Get channel IDs with notifications enabled from ChannelNotificationSettings
|
||||
let notifiableChannelIDs = Set(appEnvironment.dataManager.channelIDsWithNotificationsEnabled())
|
||||
|
||||
// Filter subscriptions to only those with notifications enabled
|
||||
let notifiableSubscriptions = appEnvironment.dataManager.subscriptions().filter {
|
||||
notifiableChannelIDs.contains($0.channelID)
|
||||
}
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Background refresh starting (local account) | lastBackgroundCheck: \(lastCheckDate) | channels with notifications: \(notifiableSubscriptions.count)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
guard !notifiableSubscriptions.isEmpty else {
|
||||
LoggingService.shared.debug("No subscriptions with notifications enabled", category: .notifications)
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.debug("Fetching feed from Yattee Server: \(yatteeServer.url)", category: .notifications)
|
||||
|
||||
// Convert subscriptions to channel requests
|
||||
let channelRequests = notifiableSubscriptions.map { subscription in
|
||||
StatelessChannelRequest(
|
||||
channelId: subscription.channelID,
|
||||
site: subscription.site,
|
||||
channelName: subscription.name,
|
||||
channelUrl: subscription.channelURLString,
|
||||
avatarUrl: subscription.avatarURLString
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
||||
let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: yatteeServer)
|
||||
await yatteeServerAPI.setAuthHeader(authHeader)
|
||||
let response = try await yatteeServerAPI.postFeed(
|
||||
channels: channelRequests,
|
||||
limit: 5 * notifiableSubscriptions.count,
|
||||
offset: 0,
|
||||
instance: yatteeServer
|
||||
)
|
||||
|
||||
LoggingService.shared.debug("API returned \(response.videos.count) total videos", category: .notifications)
|
||||
|
||||
// Load last notified video IDs per channel for deduplication
|
||||
let lastNotified = appEnvironment.settingsManager.lastNotifiedVideoPerChannel
|
||||
|
||||
// Filter to new videos - don't wait for ready, use whatever is available
|
||||
var dateFilteredCount = 0
|
||||
var dedupedCount = 0
|
||||
let newVideos: [(video: Video, channelName: String)] = response.videos.compactMap { serverVideo in
|
||||
guard let video = serverVideo.toVideo(),
|
||||
let publishedAt = video.publishedAt,
|
||||
publishedAt > lastCheckDate else {
|
||||
return nil
|
||||
}
|
||||
dateFilteredCount += 1
|
||||
// Skip if this is the last video we notified about for this channel
|
||||
if lastNotified[video.author.id] == video.id.videoID {
|
||||
dedupedCount += 1
|
||||
return nil
|
||||
}
|
||||
return (video: video, channelName: serverVideo.author)
|
||||
}
|
||||
|
||||
LoggingService.shared.debug(
|
||||
"After date filter: \(dateFilteredCount) videos newer than \(lastCheckDate)",
|
||||
category: .notifications
|
||||
)
|
||||
LoggingService.shared.debug(
|
||||
"After deduplication: \(newVideos.count) videos (removed \(dedupedCount) already-notified)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
// Update last notified video per channel (store newest video ID for each channel)
|
||||
var updatedLastNotified = lastNotified
|
||||
for (video, _) in newVideos {
|
||||
updatedLastNotified[video.author.id] = video.id.videoID
|
||||
}
|
||||
appEnvironment.settingsManager.lastNotifiedVideoPerChannel = updatedLastNotified
|
||||
|
||||
// Update timestamp before sending notification
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
|
||||
await sendNotificationsIfNeeded(newVideos: newVideos)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Background refresh failed (local account)", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invidious Account Refresh
|
||||
|
||||
/// Performs background refresh for Invidious subscription account.
|
||||
/// Fetches the full Invidious feed and filters to channels with notifications enabled.
|
||||
private func performInvidiousAccountRefresh() async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
// Require authenticated Invidious instance
|
||||
guard let invidiousInstance = appEnvironment.instancesManager.instances
|
||||
.first(where: { $0.type == .invidious && $0.isEnabled }),
|
||||
let sid = appEnvironment.invidiousCredentialsManager.sid(for: invidiousInstance) else {
|
||||
LoggingService.shared.debug("Background refresh requires authenticated Invidious instance", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
let lastCheckDate = appEnvironment.settingsManager.lastBackgroundCheck ?? Date.distantPast
|
||||
|
||||
// Get channel IDs with notifications enabled from ChannelNotificationSettings
|
||||
let notifiableChannelIDs = Set(appEnvironment.dataManager.channelIDsWithNotificationsEnabled())
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Background refresh starting (Invidious account) | lastBackgroundCheck: \(lastCheckDate) | channels with notifications: \(notifiableChannelIDs.count)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
guard !notifiableChannelIDs.isEmpty else {
|
||||
LoggingService.shared.debug("No channels with notifications enabled", category: .notifications)
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.debug("Fetching feed from Invidious: \(invidiousInstance.url)", category: .notifications)
|
||||
|
||||
do {
|
||||
// Fetch the Invidious feed - fetch enough to cover recent videos from notifiable channels
|
||||
// Use a reasonable limit since we're filtering client-side
|
||||
let feedResponse = try await appEnvironment.invidiousAPI.feed(
|
||||
instance: invidiousInstance,
|
||||
sid: sid,
|
||||
page: 1,
|
||||
maxResults: 100
|
||||
)
|
||||
|
||||
LoggingService.shared.debug("API returned \(feedResponse.videos.count) total videos", category: .notifications)
|
||||
|
||||
// Load last notified video IDs per channel for deduplication
|
||||
let lastNotified = appEnvironment.settingsManager.lastNotifiedVideoPerChannel
|
||||
|
||||
// Filter to new videos from notifiable channels
|
||||
var channelFilteredCount = 0
|
||||
var dateFilteredCount = 0
|
||||
var dedupedCount = 0
|
||||
let newVideos: [(video: Video, channelName: String)] = feedResponse.videos.compactMap { video in
|
||||
// Only include videos from channels with notifications enabled
|
||||
guard notifiableChannelIDs.contains(video.author.id) else {
|
||||
return nil
|
||||
}
|
||||
channelFilteredCount += 1
|
||||
|
||||
// Only include videos published after last check
|
||||
guard let publishedAt = video.publishedAt,
|
||||
publishedAt > lastCheckDate else {
|
||||
return nil
|
||||
}
|
||||
dateFilteredCount += 1
|
||||
|
||||
// Skip if this is the last video we notified about for this channel
|
||||
if lastNotified[video.author.id] == video.id.videoID {
|
||||
dedupedCount += 1
|
||||
return nil
|
||||
}
|
||||
|
||||
return (video: video, channelName: video.author.name)
|
||||
}
|
||||
|
||||
LoggingService.shared.debug(
|
||||
"After channel filter: \(channelFilteredCount) videos from notifiable channels",
|
||||
category: .notifications
|
||||
)
|
||||
LoggingService.shared.debug(
|
||||
"After date filter: \(dateFilteredCount) videos newer than \(lastCheckDate)",
|
||||
category: .notifications
|
||||
)
|
||||
LoggingService.shared.debug(
|
||||
"After deduplication: \(newVideos.count) videos (removed \(dedupedCount) already-notified)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
// Update last notified video per channel (store newest video ID for each channel)
|
||||
var updatedLastNotified = lastNotified
|
||||
for (video, _) in newVideos {
|
||||
updatedLastNotified[video.author.id] = video.id.videoID
|
||||
}
|
||||
appEnvironment.settingsManager.lastNotifiedVideoPerChannel = updatedLastNotified
|
||||
|
||||
// Update timestamp before sending notification
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
|
||||
await sendNotificationsIfNeeded(newVideos: newVideos)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Background refresh failed (Invidious account)", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Piped Account Refresh
|
||||
|
||||
/// Performs background refresh for Piped subscription account.
|
||||
/// Fetches the Piped feed and filters to channels with notifications enabled.
|
||||
private func performPipedAccountRefresh() async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
// Get the instance ID from subscription account settings
|
||||
let account = appEnvironment.settingsManager.subscriptionAccount
|
||||
guard let instanceID = account.instanceID,
|
||||
let pipedInstance = appEnvironment.instancesManager.instances.first(where: { $0.id == instanceID }),
|
||||
let authToken = appEnvironment.pipedCredentialsManager.credential(for: pipedInstance) else {
|
||||
LoggingService.shared.debug("Background refresh requires authenticated Piped instance", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
let lastCheckDate = appEnvironment.settingsManager.lastBackgroundCheck ?? Date.distantPast
|
||||
|
||||
// Get channel IDs with notifications enabled from ChannelNotificationSettings
|
||||
let notifiableChannelIDs = Set(appEnvironment.dataManager.channelIDsWithNotificationsEnabled())
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Background refresh starting (Piped account) | lastBackgroundCheck: \(lastCheckDate) | channels with notifications: \(notifiableChannelIDs.count)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
guard !notifiableChannelIDs.isEmpty else {
|
||||
LoggingService.shared.debug("No channels with notifications enabled", category: .notifications)
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.debug("Fetching feed from Piped: \(pipedInstance.url)", category: .notifications)
|
||||
|
||||
do {
|
||||
// Fetch the Piped feed
|
||||
let pipedAPI = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
let feedVideos = try await pipedAPI.feed(instance: pipedInstance, authToken: authToken)
|
||||
|
||||
LoggingService.shared.debug("API returned \(feedVideos.count) total videos", category: .notifications)
|
||||
|
||||
// Load last notified video IDs per channel for deduplication
|
||||
let lastNotified = appEnvironment.settingsManager.lastNotifiedVideoPerChannel
|
||||
|
||||
// Filter to new videos from notifiable channels
|
||||
var channelFilteredCount = 0
|
||||
var dateFilteredCount = 0
|
||||
var dedupedCount = 0
|
||||
let newVideos: [(video: Video, channelName: String)] = feedVideos.compactMap { video in
|
||||
// Only include videos from channels with notifications enabled
|
||||
guard notifiableChannelIDs.contains(video.author.id) else {
|
||||
return nil
|
||||
}
|
||||
channelFilteredCount += 1
|
||||
|
||||
// Only include videos published after last check
|
||||
guard let publishedAt = video.publishedAt,
|
||||
publishedAt > lastCheckDate else {
|
||||
return nil
|
||||
}
|
||||
dateFilteredCount += 1
|
||||
|
||||
// Skip if this is the last video we notified about for this channel
|
||||
if lastNotified[video.author.id] == video.id.videoID {
|
||||
dedupedCount += 1
|
||||
return nil
|
||||
}
|
||||
|
||||
return (video: video, channelName: video.author.name)
|
||||
}
|
||||
|
||||
LoggingService.shared.debug(
|
||||
"After channel filter: \(channelFilteredCount) videos from notifiable channels",
|
||||
category: .notifications
|
||||
)
|
||||
LoggingService.shared.debug(
|
||||
"After date filter: \(dateFilteredCount) videos newer than \(lastCheckDate)",
|
||||
category: .notifications
|
||||
)
|
||||
LoggingService.shared.debug(
|
||||
"After deduplication: \(newVideos.count) videos (removed \(dedupedCount) already-notified)",
|
||||
category: .notifications
|
||||
)
|
||||
|
||||
// Update last notified video per channel (store newest video ID for each channel)
|
||||
var updatedLastNotified = lastNotified
|
||||
for (video, _) in newVideos {
|
||||
updatedLastNotified[video.author.id] = video.id.videoID
|
||||
}
|
||||
appEnvironment.settingsManager.lastNotifiedVideoPerChannel = updatedLastNotified
|
||||
|
||||
// Update timestamp before sending notification
|
||||
appEnvironment.settingsManager.lastBackgroundCheck = Date()
|
||||
|
||||
await sendNotificationsIfNeeded(newVideos: newVideos)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Background refresh failed (Piped account)", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Sending
|
||||
|
||||
/// Sends notifications for new videos if any were found.
|
||||
/// Filters out videos the user has already started or finished watching.
|
||||
/// - Parameter newVideos: Array of new videos with their channel names
|
||||
private func sendNotificationsIfNeeded(newVideos: [(video: Video, channelName: String)]) async {
|
||||
#if !os(tvOS)
|
||||
guard !newVideos.isEmpty else {
|
||||
LoggingService.shared.debug("No new videos found", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
var videosToNotify = newVideos
|
||||
|
||||
// Filter out videos the user has already started or finished watching
|
||||
if let appEnvironment {
|
||||
let watchEntries = appEnvironment.dataManager.watchEntriesMap()
|
||||
let originalCount = videosToNotify.count
|
||||
|
||||
videosToNotify = videosToNotify.filter { item in
|
||||
guard let entry = watchEntries[item.video.id.videoID] else {
|
||||
// No watch entry - video hasn't been watched, include it
|
||||
return true
|
||||
}
|
||||
// Skip if user has started watching (any progress) or finished
|
||||
return entry.watchedSeconds <= 0 && !entry.isFinished
|
||||
}
|
||||
|
||||
let filteredCount = originalCount - videosToNotify.count
|
||||
if filteredCount > 0 {
|
||||
LoggingService.shared.debug(
|
||||
"Filtered \(filteredCount) watched/started videos from notifications",
|
||||
category: .notifications
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !videosToNotify.isEmpty {
|
||||
await notificationManager.sendNotification(for: videosToNotify)
|
||||
LoggingService.shared.info(
|
||||
"Background refresh complete | Found \(videosToNotify.count) new videos from \(Set(videosToNotify.map(\.channelName)).count) channels | Notification sent: true",
|
||||
category: .notifications
|
||||
)
|
||||
} else {
|
||||
LoggingService.shared.info(
|
||||
"Background refresh complete | Found 0 new videos | Notification sent: false",
|
||||
category: .notifications
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
174
Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift
Normal file
174
Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// BackgroundRefreshManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Platform-specific background refresh orchestration.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import BackgroundTasks
|
||||
#endif
|
||||
|
||||
/// Manages background refresh scheduling and execution across platforms.
|
||||
@MainActor
|
||||
final class BackgroundRefreshManager {
|
||||
// MARK: - Constants
|
||||
|
||||
static let backgroundTaskIdentifier = AppIdentifiers.backgroundFeedRefresh
|
||||
|
||||
#if os(macOS)
|
||||
private var activityScheduler: NSBackgroundActivityScheduler?
|
||||
#endif
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private weak var appEnvironment: AppEnvironment?
|
||||
private let backgroundRefresher: BackgroundFeedRefresher
|
||||
private let notificationManager: NotificationManager
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(notificationManager: NotificationManager) {
|
||||
self.notificationManager = notificationManager
|
||||
self.backgroundRefresher = BackgroundFeedRefresher(notificationManager: notificationManager)
|
||||
}
|
||||
|
||||
func setAppEnvironment(_ environment: AppEnvironment) {
|
||||
self.appEnvironment = environment
|
||||
backgroundRefresher.setAppEnvironment(environment)
|
||||
}
|
||||
|
||||
// MARK: - Registration (call at app launch)
|
||||
|
||||
func registerBackgroundTasks() {
|
||||
#if os(iOS)
|
||||
registerIOSBackgroundTask()
|
||||
#elseif os(macOS)
|
||||
registerMacOSBackgroundActivity()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - iOS Implementation
|
||||
|
||||
#if os(iOS)
|
||||
private func registerIOSBackgroundTask() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.backgroundTaskIdentifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
guard let task = task as? BGAppRefreshTask else { return }
|
||||
self?.handleIOSBackgroundTask(task)
|
||||
}
|
||||
LoggingService.shared.info("Registered iOS background task", category: .notifications)
|
||||
}
|
||||
|
||||
func scheduleIOSBackgroundRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: Self.backgroundTaskIdentifier)
|
||||
// Request to run in ~15 minutes (system decides actual timing)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
LoggingService.shared.info("Scheduled iOS background refresh", category: .notifications)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Failed to schedule background refresh", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelIOSBackgroundRefresh() {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundTaskIdentifier)
|
||||
LoggingService.shared.debug("Cancelled iOS background refresh", category: .notifications)
|
||||
}
|
||||
|
||||
private func handleIOSBackgroundTask(_ task: BGAppRefreshTask) {
|
||||
LoggingService.shared.info("iOS background task started", category: .notifications)
|
||||
|
||||
// Schedule the next refresh immediately
|
||||
scheduleIOSBackgroundRefresh()
|
||||
|
||||
// Create a task to perform the refresh
|
||||
let refreshTask = Task { @MainActor in
|
||||
await backgroundRefresher.performBackgroundRefresh()
|
||||
}
|
||||
|
||||
// Set expiration handler
|
||||
task.expirationHandler = {
|
||||
LoggingService.shared.warning("iOS background task expired", category: .notifications)
|
||||
refreshTask.cancel()
|
||||
}
|
||||
|
||||
// Wait for completion
|
||||
Task {
|
||||
_ = await refreshTask.result
|
||||
task.setTaskCompleted(success: !refreshTask.isCancelled)
|
||||
LoggingService.shared.info("iOS background task completed", category: .notifications)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - macOS Implementation
|
||||
|
||||
#if os(macOS)
|
||||
private func registerMacOSBackgroundActivity() {
|
||||
let scheduler = NSBackgroundActivityScheduler(identifier: Self.backgroundTaskIdentifier)
|
||||
scheduler.repeats = true
|
||||
#if DEBUG
|
||||
scheduler.interval = 60 // 1 minute for testing
|
||||
scheduler.tolerance = 30
|
||||
#else
|
||||
scheduler.interval = 15 * 60 // 15 minutes
|
||||
scheduler.tolerance = 5 * 60 // 5 minute tolerance
|
||||
#endif
|
||||
scheduler.qualityOfService = .utility
|
||||
|
||||
scheduler.schedule { [weak self] completion in
|
||||
guard let self else {
|
||||
completion(.finished)
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.info("macOS background activity started", category: .notifications)
|
||||
await self.backgroundRefresher.performBackgroundRefresh()
|
||||
completion(.finished)
|
||||
LoggingService.shared.info("macOS background activity completed", category: .notifications)
|
||||
}
|
||||
}
|
||||
|
||||
self.activityScheduler = scheduler
|
||||
LoggingService.shared.info("Registered macOS background activity", category: .notifications)
|
||||
}
|
||||
|
||||
func invalidateMacOSScheduler() {
|
||||
activityScheduler?.invalidate()
|
||||
activityScheduler = nil
|
||||
LoggingService.shared.debug("Invalidated macOS background scheduler", category: .notifications)
|
||||
}
|
||||
|
||||
func restartMacOSScheduler() {
|
||||
invalidateMacOSScheduler()
|
||||
registerMacOSBackgroundActivity()
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Enable/Disable
|
||||
|
||||
func handleNotificationsEnabledChanged(_ enabled: Bool) {
|
||||
#if os(iOS)
|
||||
if enabled {
|
||||
scheduleIOSBackgroundRefresh()
|
||||
} else {
|
||||
cancelIOSBackgroundRefresh()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if enabled {
|
||||
if activityScheduler == nil {
|
||||
registerMacOSBackgroundActivity()
|
||||
}
|
||||
} else {
|
||||
invalidateMacOSScheduler()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
270
Yattee/Services/BackgroundRefresh/NotificationManager.swift
Normal file
270
Yattee/Services/BackgroundRefresh/NotificationManager.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// NotificationManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Local notification management for new video alerts.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Manages local notifications for new video alerts.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NotificationManager: NSObject {
|
||||
// MARK: - Constants
|
||||
|
||||
private static let notificationCategoryIdentifier = "NEW_VIDEO"
|
||||
private static let watchActionIdentifier = "WATCH_ACTION"
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||
|
||||
var isAuthorized: Bool {
|
||||
authorizationStatus == .authorized || authorizationStatus == .provisional
|
||||
}
|
||||
|
||||
/// UserDefaults key for pending navigation flag.
|
||||
private nonisolated static let pendingNavigationKey = "pendingSubscriptionsNavigation"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
/// Requests notification authorization from the user.
|
||||
func requestAuthorization() async -> Bool {
|
||||
do {
|
||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
)
|
||||
await refreshAuthorizationStatus()
|
||||
LoggingService.shared.info("Notification authorization: \(granted ? "granted" : "denied")", category: .notifications)
|
||||
return granted
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Failed to request notification authorization", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the current authorization status.
|
||||
func refreshAuthorizationStatus() async {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
authorizationStatus = settings.authorizationStatus
|
||||
}
|
||||
|
||||
/// Opens system settings for notification permissions.
|
||||
func openNotificationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
// MARK: - Notification Scheduling
|
||||
|
||||
/// Sends a single combined notification for new videos.
|
||||
func sendNotification(for videos: [(video: Video, channelName: String)]) async {
|
||||
await refreshAuthorizationStatus()
|
||||
|
||||
guard isAuthorized else {
|
||||
LoggingService.shared.debug("Skipping notification: not authorized", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
guard !videos.isEmpty else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.notificationCategoryIdentifier
|
||||
|
||||
let totalCount = videos.count
|
||||
|
||||
if totalCount == 1 {
|
||||
// Single video: "Channel Name" / "New video: Title"
|
||||
let video = videos[0].video
|
||||
let channelName = videos[0].channelName
|
||||
content.title = channelName
|
||||
content.body = String(
|
||||
format: NSLocalizedString("notification.singleVideo %@", comment: "Notification body for single video"),
|
||||
video.title
|
||||
)
|
||||
} else if totalCount <= 3 {
|
||||
// 2-3 videos: List each video as "Author: Title" on separate lines
|
||||
content.title = String(localized: "notification.newVideos.title")
|
||||
let videoLines = videos.map { item in
|
||||
String(
|
||||
format: NSLocalizedString("notification.videoItem %@ %@", comment: "Video item: Author: Title"),
|
||||
item.channelName,
|
||||
item.video.title
|
||||
)
|
||||
}
|
||||
content.body = videoLines.joined(separator: "\n")
|
||||
} else {
|
||||
// 4+ videos: Group by channel
|
||||
let groupedByChannel = Dictionary(grouping: videos) { $0.channelName }
|
||||
let channelCount = groupedByChannel.count
|
||||
|
||||
if channelCount == 1, let (channelName, channelVideos) = groupedByChannel.first {
|
||||
// Multiple videos from one channel: "4 new videos from X"
|
||||
content.title = String(localized: "notification.newVideos.title")
|
||||
content.body = String(
|
||||
format: NSLocalizedString("notification.multipleVideosOneChannel %lld %@", comment: "Multiple videos from one channel"),
|
||||
channelVideos.count,
|
||||
channelName
|
||||
)
|
||||
} else {
|
||||
// Multiple channels
|
||||
content.title = String(localized: "notification.newVideos.title")
|
||||
|
||||
let channelNames = groupedByChannel
|
||||
.sorted { $0.value.count > $1.value.count }
|
||||
.map { $0.key }
|
||||
|
||||
if channelNames.count == 2 {
|
||||
content.body = String(
|
||||
format: NSLocalizedString("notification.twoChannels %@ %@", comment: "Two channels notification"),
|
||||
channelNames[0],
|
||||
channelNames[1]
|
||||
)
|
||||
} else {
|
||||
let allButLast = channelNames.dropLast().joined(separator: ", ")
|
||||
let last = channelNames.last ?? ""
|
||||
content.body = String(
|
||||
format: NSLocalizedString("notification.multipleChannels %@ %@", comment: "Multiple channels notification"),
|
||||
allButLast,
|
||||
last
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "feed-update-\(Date().timeIntervalSince1970)",
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
let uniqueChannels = Set(videos.map { $0.channelName }).count
|
||||
LoggingService.shared.info("Sent notification for \(videos.count) videos from \(uniqueChannels) channels", category: .notifications)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Failed to schedule notification", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug / Testing
|
||||
|
||||
/// Sends a test notification to verify notification permissions and appearance.
|
||||
func sendTestNotification() async {
|
||||
await refreshAuthorizationStatus()
|
||||
|
||||
guard isAuthorized else {
|
||||
LoggingService.shared.warning("Cannot send test notification: not authorized", category: .notifications)
|
||||
return
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Test Channel"
|
||||
content.body = String(
|
||||
format: NSLocalizedString("notification.singleVideo %@", comment: ""),
|
||||
"Test Video Title"
|
||||
)
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.notificationCategoryIdentifier
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "test-notification-\(Date().timeIntervalSince1970)",
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
LoggingService.shared.info("Sent test notification", category: .notifications)
|
||||
} catch {
|
||||
LoggingService.shared.logNotificationError("Failed to send test notification", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers a real background refresh check manually (for debugging).
|
||||
/// Performs an actual API fetch like the real background refresh does.
|
||||
func triggerBackgroundRefresh(using appEnvironment: AppEnvironment) async {
|
||||
LoggingService.shared.info("Manual background refresh triggered - performing real API fetch", category: .notifications)
|
||||
|
||||
let refresher = BackgroundFeedRefresher(notificationManager: self)
|
||||
refresher.setAppEnvironment(appEnvironment)
|
||||
await refresher.performBackgroundRefresh()
|
||||
|
||||
LoggingService.shared.info("Manual background refresh completed", category: .notifications)
|
||||
}
|
||||
|
||||
// MARK: - Category Registration
|
||||
|
||||
func registerNotificationCategories() {
|
||||
let watchAction = UNNotificationAction(
|
||||
identifier: Self.watchActionIdentifier,
|
||||
title: String(localized: "notification.action.watch"),
|
||||
options: [.foreground]
|
||||
)
|
||||
|
||||
let category = UNNotificationCategory(
|
||||
identifier: Self.notificationCategoryIdentifier,
|
||||
actions: [watchAction],
|
||||
intentIdentifiers: []
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
// Show notifications even when app is in foreground
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
// Set flag in UserDefaults - completely thread-safe
|
||||
UserDefaults.standard.set(true, forKey: Self.pendingNavigationKey)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
/// Handles pending navigation if any. Call this when the app becomes active.
|
||||
func handlePendingNavigation(using coordinator: NavigationCoordinator) {
|
||||
guard UserDefaults.standard.bool(forKey: Self.pendingNavigationKey) else { return }
|
||||
UserDefaults.standard.set(false, forKey: Self.pendingNavigationKey)
|
||||
coordinator.navigateToSubscriptions()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user