/** * 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 /** * Protocol for scheduling background fetches */ protocol DailyNotificationFetchScheduling { func scheduleFetch(atMillis: Int64) func scheduleImmediateFetch() } /** * No-op implementation for when fetcher is not available */ final class NoopFetcherScheduler: DailyNotificationFetchScheduling { func scheduleFetch(atMillis: Int64) { /* intentionally noop */ } func scheduleImmediateFetch() { /* intentionally noop */ } } /** * 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? // Fetch scheduling private let fetchScheduler: DailyNotificationFetchScheduling // MARK: - Initialization /** * Initialize scheduler * * @param fetchScheduler Optional fetch scheduler (defaults to NoopFetcherScheduler) */ init(fetchScheduler: DailyNotificationFetchScheduling = NoopFetcherScheduler()) { self.notificationCenter = UNUserNotificationCenter.current() self.fetchScheduler = fetchScheduler 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 { let okToArm = ttlEnforcer.validateBeforeArming(content) if !okToArm { print("\(Self.TAG): TTL validation failed, skipping schedule for \(content.id)") return false } } // 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) } // Log pending count for test scripts (matches Android's alarm count logging) // Use NSLog to ensure it appears in system logs (print() may not always be captured) let pendingCount = await getPendingNotificationCount() NSLog("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)") print("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)") 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() // Find the earliest scheduled notification by checking all requests var earliestDate: Date? = nil var earliestRequestId: String? = nil var allTimes: [(String, String)] = [] for request in requests { var requestTime: Date? = nil if let trigger = request.trigger as? UNCalendarNotificationTrigger, let nextDate = trigger.nextTriggerDate() { requestTime = nextDate } else if let trigger = request.trigger as? UNTimeIntervalNotificationTrigger, let nextDate = trigger.nextTriggerDate() { requestTime = nextDate } if let time = requestTime { let timeStr = formatTime(Int64(time.timeIntervalSince1970 * 1000)) allTimes.append((request.identifier, timeStr)) if earliestDate == nil || time < earliestDate! { earliestDate = time earliestRequestId = request.identifier } } } guard let nextDate = earliestDate else { NSLog("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests") print("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests") return nil } let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000) let nextTimeStr = formatTime(nextTime) let allTimesStr = allTimes.map { "\($0.0):\($0.1)" }.joined(separator: ", ") NSLog("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]") print("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]") return nextTime } /** * Calculate next scheduled time from current scheduled time (24 hours later, DST-safe) * * Matches Android calculateNextScheduledTime() functionality * Handles DST transitions automatically using Calendar * * @param currentScheduledTime Current scheduled time in milliseconds * @return Next scheduled time in milliseconds (24 hours later) * * TESTING: To test with shorter intervals (e.g., 2 minutes), change: * - Line ~404: `.hour, value: 24` → `.minute, value: 2` * - Line ~407: `(24 * 60 * 60 * 1000)` → `(2 * 60 * 1000)` */ func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 { let calendar = Calendar.current let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0) let currentTimeStr = formatTime(currentScheduledTime) // Add 24 hours (handles DST transitions automatically) // TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else { // Fallback to simple 24-hour addition if calendar calculation fails // TESTING: Change `(24 * 60 * 60 * 1000)` to `(2 * 60 * 1000)` for 2-minute testing let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000) let fallbackTimeStr = formatTime(fallbackTime) NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)") print("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)") return fallbackTime } let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000) let nextTimeStr = formatTime(nextTime) // Validate: Log DST transitions for debugging let currentHour = calendar.component(.hour, from: currentDate) let currentMinute = calendar.component(.minute, from: currentDate) let nextHour = calendar.component(.hour, from: nextDate) let nextMinute = calendar.component(.minute, from: nextDate) if currentHour != nextHour || currentMinute != nextMinute { NSLog("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))") print("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))") } // Log the calculation result let timeDiffMs = nextTime - currentScheduledTime let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0 NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))") print("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))") return nextTime } /** * Schedule next notification after current one fires (rollover) * * Matches Android scheduleNextNotification() functionality * Implements multi-level duplicate prevention * * @param content Current notification content that just fired * @param storage Storage instance for duplicate checking * @param fetcher Optional fetcher for scheduling prefetch (Phase 2) * @return true if next notification was scheduled successfully */ func scheduleNextNotification( _ content: NotificationContent, storage: DailyNotificationStorage?, fetcher: Any? = nil ) async -> Bool { let currentTime = Int64(Date().timeIntervalSince1970 * 1000) let currentTimeStr = formatTime(currentTime) let currentScheduledTimeStr = formatTime(content.scheduledTime) NSLog("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)") print("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)") // Check 1: Rollover state tracking (prevent duplicate rollover attempts) if let storage = storage { let lastRolloverTime = await storage.getLastRolloverTime(for: content.id) // If rollover was processed recently (< 1 hour ago), skip // TESTING: Change `(60 * 60 * 1000)` to `(60 * 1000)` for 1-minute threshold when testing with 2-minute intervals if let lastTime = lastRolloverTime, (currentTime - lastTime) < (60 * 60 * 1000) { let lastTimeStr = formatTime(lastTime) let timeSinceRollover = (currentTime - lastTime) / 1000 / 60 // minutes NSLog("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago") print("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago") return false } } // Calculate next occurrence using DST-safe calculation var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) // If next scheduled time is in the past, keep calculating forward until we get a future time // This handles cases where the notification fired more than 2 minutes ago while nextScheduledTime < currentTime { let nextTimeStr = formatTime(nextScheduledTime) NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward") print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward") nextScheduledTime = calculateNextScheduledTime(nextScheduledTime) } let nextScheduledTimeStr = formatTime(nextScheduledTime) let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0 NSLog("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))") print("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))") // Check 2: Storage-level duplicate check (prevent duplicate notifications) if let storage = storage { let existingNotifications = storage.getAllNotifications() let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance for DST shifts for existing in existingNotifications { if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs { let existingTimeStr = formatTime(existing.scheduledTime) let timeDiffMs = abs(existing.scheduledTime - nextScheduledTime) NSLog("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)") print("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)") return false // Skip rescheduling to prevent duplicate } } } // Check 3: System-level duplicate check (query UNUserNotificationCenter) let pendingNotifications = await notificationCenter.pendingNotificationRequests() NSLog("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)") print("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)") for pending in pendingNotifications { if let trigger = pending.trigger as? UNCalendarNotificationTrigger, let nextDate = trigger.nextTriggerDate() { let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000) let toleranceMs: Int64 = 60 * 1000 if abs(pendingTime - nextScheduledTime) <= toleranceMs { let pendingTimeStr = formatTime(pendingTime) let timeDiffMs = abs(pendingTime - nextScheduledTime) NSLog("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)") print("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)") return false } } } // Extract hour:minute from current scheduled time for logging let calendar = Calendar.current let scheduledDate = content.getScheduledTimeAsDate() let hour = calendar.component(.hour, from: scheduledDate) let minute = calendar.component(.minute, from: scheduledDate) // Create new notification content for next occurrence // Note: Content will be refreshed by prefetch, but we need placeholder let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))" let nextContent = NotificationContent( id: nextId, title: content.title, // Will be updated by prefetch body: content.body, // Will be updated by prefetch scheduledTime: nextScheduledTime, fetchedAt: Int64(Date().timeIntervalSince1970 * 1000), url: content.url, payload: content.payload, etag: content.etag ) // Schedule the next notification NSLog("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)") print("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)") let scheduled = await scheduleNotification(nextContent) if scheduled { // Save notification content to storage so it can be retrieved when rollover fires // This is critical: without saving, processRollover won't find the content storage?.saveNotificationContent(nextContent) NSLog("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage") print("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage") // Verify the notification was actually scheduled let pendingCount = await getPendingNotificationCount() let isScheduled = await isNotificationScheduled(id: nextId) NSLog("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)") print("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)") // Log time difference verification let timeDiffMs = nextScheduledTime - content.scheduledTime let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0 NSLog("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))") print("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))") // Schedule background fetch for next notification (5 minutes before scheduled time) let fetchTime = nextScheduledTime - (5 * 60 * 1000) // 5 minutes before let currentTime = Int64(Date().timeIntervalSince1970 * 1000) if fetchTime > currentTime { print("\(Self.TAG): scheduling fetch at \(fetchTime)") fetchScheduler.scheduleFetch(atMillis: fetchTime) NSLog("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)") print("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)") } else { print("\(Self.TAG): scheduling immediate fetch") fetchScheduler.scheduleImmediateFetch() NSLog("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)") print("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)") } // Mark rollover as processed let rolloverProcessedTime = Int64(Date().timeIntervalSince1970 * 1000) await storage?.saveLastRolloverTime(for: content.id, time: rolloverProcessedTime) NSLog("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)") print("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)") return true } else { NSLog("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)") print("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)") return false } } }