/** * DailyNotificationScheduler.swift * * Handles scheduling and timing of daily notifications using UNUserNotificationCenter * Implements calendar-based triggers with timing tolerance (±180s) and permission auto-healing * * @author Matthew Raymer * @version 1.0.0 */ import Foundation import UserNotifications /** * Manages scheduling of daily notifications using UNUserNotificationCenter * * This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline. * It supports calendar-based triggers with iOS timing tolerance (±180s). */ class DailyNotificationScheduler { // MARK: - Constants private static let TAG = "DailyNotificationScheduler" private static let NOTIFICATION_CATEGORY_ID = "DAILY_NOTIFICATION" private static let TIMING_TOLERANCE_SECONDS: TimeInterval = 180 // ±180 seconds tolerance // MARK: - Properties private let notificationCenter: UNUserNotificationCenter private var scheduledNotifications: Set = [] private let schedulerQueue = DispatchQueue(label: "com.timesafari.dailynotification.scheduler", attributes: .concurrent) // TTL enforcement private weak var ttlEnforcer: DailyNotificationTTLEnforcer? // MARK: - Initialization /** * Initialize scheduler */ init() { self.notificationCenter = UNUserNotificationCenter.current() setupNotificationCategory() } /** * Set TTL enforcer for freshness validation * * @param ttlEnforcer TTL enforcement instance */ func setTTLEnforcer(_ ttlEnforcer: DailyNotificationTTLEnforcer) { self.ttlEnforcer = ttlEnforcer print("\(Self.TAG): TTL enforcer set for freshness validation") } // MARK: - Notification Category Setup /** * Setup notification category for actions */ private func setupNotificationCategory() { let category = UNNotificationCategory( identifier: Self.NOTIFICATION_CATEGORY_ID, actions: [], intentIdentifiers: [], options: [] ) notificationCenter.setNotificationCategories([category]) print("\(Self.TAG): Notification category setup complete") } // MARK: - Permission Management /** * Check notification permission status * * @return Authorization status */ func checkPermissionStatus() async -> UNAuthorizationStatus { let settings = await notificationCenter.notificationSettings() return settings.authorizationStatus } /** * Request notification permissions * * @return true if permissions granted */ func requestPermissions() async -> Bool { do { let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) print("\(Self.TAG): Permission request result: \(granted)") return granted } catch { print("\(Self.TAG): Permission request failed: \(error)") return false } } /** * Auto-heal permissions: Check and request if needed * * @return Authorization status after auto-healing */ func autoHealPermissions() async -> UNAuthorizationStatus { let status = await checkPermissionStatus() switch status { case .notDetermined: // Request permissions let granted = await requestPermissions() return granted ? .authorized : .denied case .denied: // Cannot auto-heal denied permissions return .denied case .authorized, .provisional, .ephemeral: return status @unknown default: return .notDetermined } } // MARK: - Scheduling /** * Schedule a notification for delivery * * @param content Notification content to schedule * @return true if scheduling was successful */ func scheduleNotification(_ content: NotificationContent) async -> Bool { do { print("\(Self.TAG): Scheduling notification: \(content.id)") // Permission auto-healing let permissionStatus = await autoHealPermissions() if permissionStatus != .authorized && permissionStatus != .provisional { print("\(Self.TAG): Notifications denied, cannot schedule") // Log error code for debugging print("\(Self.TAG): Error code: \(DailyNotificationErrorCodes.NOTIFICATIONS_DENIED)") return false } // TTL validation before arming if let ttlEnforcer = ttlEnforcer { // TODO: Implement TTL validation // For Phase 1, skip TTL validation (deferred to Phase 2) } // Cancel any existing notification for this ID await cancelNotification(id: content.id) // Create notification content let notificationContent = UNMutableNotificationContent() notificationContent.title = content.title ?? "Daily Update" notificationContent.body = content.body ?? "Your daily notification is ready" notificationContent.sound = .default notificationContent.categoryIdentifier = Self.NOTIFICATION_CATEGORY_ID notificationContent.userInfo = [ "notification_id": content.id, "scheduled_time": content.scheduledTime, "fetched_at": content.fetchedAt ] // Create calendar trigger for daily scheduling let scheduledDate = content.getScheduledTimeAsDate() let calendar = Calendar.current let dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledDate) let trigger = UNCalendarNotificationTrigger( dateMatching: dateComponents, repeats: false ) // Create notification request let request = UNNotificationRequest( identifier: content.id, content: notificationContent, trigger: trigger ) // Schedule notification try await notificationCenter.add(request) schedulerQueue.async(flags: .barrier) { self.scheduledNotifications.insert(content.id) } print("\(Self.TAG): Notification scheduled successfully for \(scheduledDate)") return true } catch { print("\(Self.TAG): Error scheduling notification: \(error)") return false } } /** * Cancel a notification by ID * * @param id Notification ID */ func cancelNotification(id: String) async { notificationCenter.removePendingNotificationRequests(withIdentifiers: [id]) schedulerQueue.async(flags: .barrier) { self.scheduledNotifications.remove(id) } print("\(Self.TAG): Notification cancelled: \(id)") } /** * Cancel all scheduled notifications */ func cancelAllNotifications() async { notificationCenter.removeAllPendingNotificationRequests() schedulerQueue.async(flags: .barrier) { self.scheduledNotifications.removeAll() } print("\(Self.TAG): All notifications cancelled") } // MARK: - Status Queries /** * Get pending notification requests * * @return Array of pending notification identifiers */ func getPendingNotifications() async -> [String] { let requests = await notificationCenter.pendingNotificationRequests() return requests.map { $0.identifier } } /** * Get notification status * * @param id Notification ID * @return true if notification is scheduled */ func isNotificationScheduled(id: String) async -> Bool { let requests = await notificationCenter.pendingNotificationRequests() return requests.contains { $0.identifier == id } } /** * Get count of pending notifications * * @return Count of pending notifications */ func getPendingNotificationCount() async -> Int { let requests = await notificationCenter.pendingNotificationRequests() return requests.count } // MARK: - Helper Methods /** * Format time for logging * * @param timestamp Timestamp in milliseconds * @return Formatted time string */ private func formatTime(_ timestamp: Int64) -> String { let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0) let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return formatter.string(from: date) } /** * Calculate next occurrence of a daily time * * Matches Android calculateNextOccurrence() functionality * * @param hour Hour of day (0-23) * @param minute Minute of hour (0-59) * @return Timestamp in milliseconds of next occurrence */ func calculateNextOccurrence(hour: Int, minute: Int) -> Int64 { let calendar = Calendar.current let now = Date() var components = calendar.dateComponents([.year, .month, .day], from: now) components.hour = hour components.minute = minute components.second = 0 var scheduledDate = calendar.date(from: components) ?? now // If time has passed today, schedule for tomorrow if scheduledDate <= now { scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate } return Int64(scheduledDate.timeIntervalSince1970 * 1000) } /** * Get next notification time from pending notifications * * @return Timestamp in milliseconds of next notification or nil */ func getNextNotificationTime() async -> Int64? { let requests = await notificationCenter.pendingNotificationRequests() guard let trigger = requests.first?.trigger as? UNCalendarNotificationTrigger, let nextDate = trigger.nextTriggerDate() else { return nil } return Int64(nextDate.timeIntervalSince1970 * 1000) } }