Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View 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
}
}

View 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
}
}

View 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
}