Files
yattee/Yattee/Services/BackgroundRefresh/NotificationManager.swift
Arkadiusz Fal cf90eb4d4e Dismiss sheets when navigation arrives from outside the app
Tapping a new-videos notification while Home's Settings sheet was up
switched to the Subscriptions feed underneath the sheet, so the app
came back still showing Settings. The existing dismissSettings() hook
was never called.

NavigationCoordinator.dismissPresentations() bumps a generation that
the main window's root sheet host and screen sheets (Home's Settings,
Remote Devices, Customize Home; Subscriptions' view options and data)
observe via .dismissesOnExternalNavigation(_:). The notification,
deep links and Handoff call it; in-app navigation and layout swaps do
not. The expanded player keeps its own sheets.

The notification also collapses the expanded player so the feed it
opens is visible.
2026-10-04 23:50:39 +02:00

273 lines
10 KiB
Swift

//
// 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.dismissPresentations()
// The feed has to be visible: collapse the player that would cover it.
coordinator.navigateToSubscriptions(collapsePlayer: true)
}
#endif
}