# iOS Rollover Implementation — Comprehensive Review **Status**: Pre-Implementation Review **Date**: 2025-01-27 **Priority**: Reliability-First --- ## Table of Contents 1. [Plan Overview](#plan-overview) 2. [File Changes Summary](#file-changes-summary) 3. [Detailed File Modifications](#detailed-file-modifications) 4. [Integration Points](#integration-points) 5. [Dependencies & Order](#dependencies--order) 6. [Testing Strategy](#testing-strategy) 7. [Open Questions](#open-questions) --- ## Plan Overview ### Objective Implement Android-like automatic rollover for iOS notifications with comprehensive edge case handling. ### Key Features - ✅ Automatic rollover when notification fires (24 hours later) - ✅ DST-safe time calculations - ✅ Multi-level duplicate prevention - ✅ Time/timezone change detection and recovery - ✅ Race condition prevention - ✅ Comprehensive edge case handling ### Architecture Components 1. **TimeChangeDetector** — Detects time changes 2. **TimezoneChangeDetector** — Detects timezone changes 3. **RolloverCoordinator** — Coordinates rollover operations 4. **Enhanced Recovery Manager** — Integrates all edge case handling --- ## File Changes Summary | File | Change Type | Lines Added | Purpose | |------|-------------|-------------|---------| | `DailyNotificationScheduler.swift` | Add methods | ~150 | DST-safe calculation + rollover scheduling | | `DailyNotificationPlugin.swift` | Add method | ~50 | Rollover handler entry point | | `AppDelegate.swift` | Modify method | ~20 | Detect notification delivery (foreground) | | `DailyNotificationReactivationManager.swift` | Enhance | ~100 | Rollover on app launch recovery | | `DailyNotificationStorage.swift` | Add methods | ~30 | Rollover state tracking | | `DailyNotificationTimeChangeDetector.swift` | New file | ~200 | Time change detection | | `DailyNotificationTimezoneChangeDetector.swift` | New file | ~150 | Timezone change detection | | `DailyNotificationRolloverCoordinator.swift` | New file | ~250 | Rollover coordination | **Total**: ~950 lines of new/modified code --- ## Detailed File Modifications ### 1. DailyNotificationScheduler.swift **Location**: `ios/Plugin/DailyNotificationScheduler.swift` #### Change 1.1: Add DST-Safe Next Time Calculation **Insert after line 307** (after `calculateNextOccurrence` method): ```swift /** * 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) */ func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 { let calendar = Calendar.current let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0) // Add 24 hours (handles DST transitions automatically) guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else { // Fallback to simple 24-hour addition if calendar calculation fails print("\(Self.TAG): DST calculation failed, using fallback") return currentScheduledTime + (24 * 60 * 60 * 1000) } // 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 { print("\(Self.TAG): DST transition detected: \(currentHour):\(String(format: "%02d", currentMinute)) -> \(nextHour):\(String(format: "%02d", nextMinute))") } return Int64(nextDate.timeIntervalSince1970 * 1000) } ``` #### Change 1.2: Add Rollover Scheduling Method **Insert after line 202** (after `scheduleNotification` method): ```swift /** * 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 * @return true if next notification was scheduled successfully */ func scheduleNextNotification( _ content: NotificationContent, storage: DailyNotificationStorage?, fetcher: DailyNotificationFetcher? = nil ) async -> Bool { print("\(Self.TAG): RESCHEDULE_START id=\(content.id)") // Check 1: Rollover state tracking (prevent duplicate rollover attempts) if let storage = storage { let lastRolloverTime = await storage.getLastRolloverTime(for: content.id) let currentTime = Int64(Date().timeIntervalSince1970 * 1000) // If rollover was processed recently (< 1 hour ago), skip if let lastTime = lastRolloverTime, (currentTime - lastTime) < (60 * 60 * 1000) { print("\(Self.TAG): RESCHEDULE_SKIP id=\(content.id) already_processed") return false } } // Calculate next occurrence using DST-safe calculation let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) // 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 { print("\(Self.TAG): RESCHEDULE_DUPLICATE id=\(content.id) existing_id=\(existing.id) time_diff_ms=\(abs(existing.scheduledTime - nextScheduledTime))") return false // Skip rescheduling to prevent duplicate } } } // Check 3: System-level duplicate check (query UNUserNotificationCenter) let pendingNotifications = await notificationCenter.pendingNotificationRequests() 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 { print("\(Self.TAG): RESCHEDULE_DUPLICATE id=\(content.id) system_pending_id=\(pending.identifier)") 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 let scheduled = await scheduleNotification(nextContent) if scheduled { let nextTimeStr = formatTime(nextScheduledTime) print("\(Self.TAG): RESCHEDULE_OK id=\(content.id) next=\(nextTimeStr) nextId=\(nextId)") // Schedule background fetch for next notification (5 minutes before scheduled time) // Note: DailyNotificationFetcher integration deferred to Phase 2 if let fetcher = fetcher { let fetchTime = nextScheduledTime - (5 * 60 * 1000) // 5 minutes before let currentTime = Int64(Date().timeIntervalSince1970 * 1000) if fetchTime > currentTime { // TODO: Phase 2 - Implement fetcher.scheduleFetch(fetchTime) print("\(Self.TAG): RESCHEDULE_PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)") } else { // TODO: Phase 2 - Implement fetcher.scheduleImmediateFetch() print("\(Self.TAG): RESCHEDULE_PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)") } } else { print("\(Self.TAG): RESCHEDULE_PREFETCH_SKIP id=\(content.id) fetcher_not_available") } // Mark rollover as processed await storage?.saveLastRolloverTime(for: content.id, time: Int64(Date().timeIntervalSince1970 * 1000)) return true } else { print("\(Self.TAG): RESCHEDULE_ERR id=\(content.id) scheduling_failed") return false } } ``` **Note**: The `formatTime` method already exists (line 273), so no change needed there. --- ### 2. DailyNotificationPlugin.swift **Location**: `ios/Plugin/DailyNotificationPlugin.swift` #### Change 2.1: Add Rollover Handler Method + Notification Observer **Insert after line 77** (in `load()` method, after recovery manager initialization): ```swift // Register for notification delivery events (Notification Center pattern) NotificationCenter.default.addObserver( self, selector: #selector(handleNotificationDelivery(_:)), name: NSNotification.Name("DailyNotificationDelivered"), object: nil ) ``` **Insert after line 1242** (after `getNotificationStatus` method): ```swift /** * Handle notification delivery event (from Notification Center) * * This is called when AppDelegate posts notification delivery event * Matches Android's scheduleNextNotification() behavior * * @param notification NSNotification with userInfo containing notification_id and scheduled_time */ @objc private func handleNotificationDelivery(_ notification: Notification) { guard let userInfo = notification.userInfo, let notificationId = userInfo["notification_id"] as? String, let scheduledTime = userInfo["scheduled_time"] as? Int64 else { print("DNP-ROLLOVER: Invalid notification data") return } Task { await processRollover(notificationId: notificationId, scheduledTime: scheduledTime) } } /** * Process rollover for delivered notification * * @param notificationId ID of notification that was delivered * @param scheduledTime Scheduled time of delivered notification */ private func processRollover(notificationId: String, scheduledTime: Int64) async { guard let scheduler = scheduler, let storage = storage else { print("DNP-ROLLOVER: Plugin not initialized") return } // Get the notification content that was delivered guard let content = storage.getNotificationContent(id: notificationId) else { print("DNP-ROLLOVER: Could not find notification content for id=\(notificationId)") return } // Schedule next notification // Note: DailyNotificationFetcher integration deferred to Phase 2 let scheduled = await scheduler.scheduleNextNotification( content, storage: storage, fetcher: nil // TODO: Phase 2 - Add fetcher instance ) if scheduled { print("DNP-ROLLOVER: Successfully scheduled next notification for id=\(notificationId)") // Log success (non-fatal, background operation) } else { print("DNP-ROLLOVER: Failed to schedule next notification for id=\(notificationId)") // Log failure but continue (recovery will handle on next launch) } } ``` #### Change 2.2: Update getNotificationStatus to Include Rollover Info **Modify line 1229-1236** (in `getNotificationStatus` method): ```swift // Calculate next notification time let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0 // Get rollover status let lastRolloverTime = await storage?.getLastRolloverTime() ?? 0 var result: [String: Any] = [ "isEnabled": isEnabled, "isScheduled": pendingCount > 0, "lastNotificationTime": lastNotification?.scheduledTime ?? 0, "nextNotificationTime": nextNotificationTime, "pending": pendingCount, "rolloverEnabled": true, // Indicate rollover is active "lastRolloverTime": lastRolloverTime, // When last rollover occurred "settings": settings ] ``` --- ### 3. AppDelegate.swift **Location**: `test-apps/ios-test-app/ios/App/App/AppDelegate.swift` #### Change 3.1: Modify willPresent to Trigger Rollover **Replace lines 136-152** with: ```swift func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { NSLog("DNP-DEBUG: ✅ userNotificationCenter willPresent called!") NSLog("DNP-DEBUG: Notification received in foreground: %@", notification.request.identifier) NSLog("DNP-DEBUG: Notification title: %@", notification.request.content.title) NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body) // Extract notification info from userInfo for rollover let userInfo = notification.request.content.userInfo if let notificationId = userInfo["notification_id"] as? String, let scheduledTime = userInfo["scheduled_time"] as? Int64 { // Trigger rollover scheduling (async, non-blocking) Task { await handleNotificationRollover(notificationId: notificationId, scheduledTime: scheduledTime) } } // Show notification with banner, sound, and badge // Use .banner for iOS 14+, fallback to .alert for iOS 13 if #available(iOS 14.0, *) { completionHandler([.banner, .sound, .badge]) } else { completionHandler([.alert, .sound, .badge]) } NSLog("DNP-DEBUG: ✅ Completion handler called with presentation options") } ``` #### Change 3.2: Post Notification for Rollover (Notification Center Pattern) **Insert after line 152** (after `willPresent` completion handler): ```swift // Post notification to trigger rollover (decoupled pattern) NotificationCenter.default.post( name: NSNotification.Name("DailyNotificationDelivered"), object: nil, userInfo: [ "notification_id": notificationId, "scheduled_time": scheduledTime ] ) ``` **Note**: This uses Notification Center pattern for decoupling. Plugin will observe this notification. --- ### 4. DailyNotificationStorage.swift **Location**: `ios/Plugin/DailyNotificationStorage.swift` #### Change 4.1: Add Rollover State Tracking Methods **Insert after line 148** (after `getAllNotifications` method): ```swift /** * Get last rollover time for a notification ID * * @param notificationId Notification ID * @return Last rollover time in milliseconds, or nil if never rolled over */ func getLastRolloverTime(for notificationId: String) async -> Int64? { let key = "rollover_\(notificationId)" let lastTime = userDefaults.object(forKey: key) as? Int64 return lastTime } /** * Save last rollover time for a notification ID * * @param notificationId Notification ID * @param time Rollover time in milliseconds */ func saveLastRolloverTime(for notificationId: String, time: Int64) async { let key = "rollover_\(notificationId)" userDefaults.set(time, forKey: key) userDefaults.synchronize() } /** * Get last rollover time (any notification) * * @return Last rollover time in milliseconds, or 0 if never rolled over */ func getLastRolloverTime() -> Int64 { let key = "rollover_last" return Int64(userDefaults.integer(forKey: key)) } /** * Save last rollover time (any notification) * * @param time Rollover time in milliseconds */ func saveLastRolloverTime(_ time: Int64) { let key = "rollover_last" userDefaults.set(time, forKey: key) userDefaults.synchronize() } ``` --- ### 5. DailyNotificationReactivationManager.swift **Location**: `ios/Plugin/DailyNotificationReactivationManager.swift` #### Change 5.1: Add Rollover Check to Recovery **Insert after line 338** (in `performColdStartRecovery` method, after detecting missed notifications): ```swift // Step 4.5: Check for delivered notifications and trigger rollover // This handles notifications that were delivered while app was not running await checkAndProcessDeliveredNotifications() ``` #### Change 5.2: Add Delivered Notifications Check Method **Insert at end of class** (before closing brace): ```swift /** * Check for delivered notifications and trigger rollover * * This ensures rollover happens on app launch if notifications were delivered * while the app was not running */ private func checkAndProcessDeliveredNotifications() async { print("\(Self.TAG): Checking for delivered notifications to trigger rollover") // Get delivered notifications from system let deliveredNotifications = await notificationCenter.deliveredNotifications() // Get last processed rollover time from storage let lastProcessedTime = storage.getLastRolloverTime() for notification in deliveredNotifications { let userInfo = notification.request.content.userInfo guard let notificationId = userInfo["notification_id"] as? String, let scheduledTime = userInfo["scheduled_time"] as? Int64 else { continue } // Only process if this notification hasn't been processed yet if scheduledTime > lastProcessedTime { print("\(Self.TAG): Found delivered notification id=\(notificationId) scheduledTime=\(scheduledTime)") // Get notification content guard let content = storage.getNotificationContent(id: notificationId) else { print("\(Self.TAG): Could not find content for delivered notification id=\(notificationId)") continue } // Trigger rollover let scheduled = await scheduler.scheduleNextNotification( content, storage: storage, fetcher: nil // TODO: Add fetcher in Phase 2 ) if scheduled { print("\(Self.TAG): Successfully rolled over delivered notification id=\(notificationId)") // Update last processed time storage.saveLastRolloverTime(scheduledTime) } else { print("\(Self.TAG): Failed to roll over delivered notification id=\(notificationId)") } } } } ``` --- ## Integration Points ### 1. AppDelegate → Plugin (Notification Center Pattern) - **Flow**: AppDelegate detects notification → posts Notification Center event → plugin observes and handles - **Challenge**: Decoupling AppDelegate from plugin - **Solution**: Use Notification Center for decoupled communication ### 2. Plugin → Scheduler - **Flow**: Plugin receives rollover request → calls scheduler method - **Challenge**: Passing storage and fetcher instances - **Solution**: Plugin maintains references, passes to scheduler ### 3. Scheduler → Storage - **Flow**: Scheduler checks duplicates → queries storage - **Challenge**: Thread safety - **Solution**: Storage methods are already thread-safe (UserDefaults) ### 4. Recovery Manager → Scheduler - **Flow**: Recovery detects delivered notifications → triggers rollover - **Challenge**: Ensuring rollover happens on app launch - **Solution**: Integrate into existing recovery flow --- ## Dependencies & Order ### Implementation Order 1. **Phase 1: Core Infrastructure** - ✅ Add `calculateNextScheduledTime` to Scheduler - ✅ Add `scheduleNextNotification` to Scheduler - ✅ Add rollover state tracking to Storage - ✅ Add `handleNotificationRollover` to Plugin 2. **Phase 2: Detection Mechanisms** - ✅ Modify AppDelegate `willPresent` method - ✅ Add rollover check to Recovery Manager - ✅ Test foreground delivery 3. **Phase 3: Edge Case Handling** (Future) - Add TimeChangeDetector - Add TimezoneChangeDetector - Add RolloverCoordinator 4. **Phase 4: Integration** (Future) - Integrate fetcher for prefetch scheduling - Add comprehensive logging - Performance optimization --- ## Testing Strategy ### Test 1: Foreground Delivery - **Setup**: App running, notification fires - **Expected**: Rollover triggers via AppDelegate → Notification Center → Plugin - **Verify**: Next notification scheduled, logs show rollover success ### Test 2: Background Delivery - **Setup**: App not running, notification fires - **Expected**: Rollover triggers on app launch via Recovery Manager - **Verify**: Next notification scheduled, recovery logs show rollover ### Test 3: Duplicate Prevention - **Setup**: Trigger rollover multiple times (rapid fire) - **Expected**: Only one notification scheduled - **Verify**: No duplicates in system, logs show duplicate prevention ### Test 4: DST Transition - **Setup**: Schedule notification on DST transition day - **Expected**: 24-hour calculation handles DST correctly - **Verify**: Notification fires at correct time, logs show DST transition ### Test 5: Error Handling - **Setup**: Simulate failure (e.g., invalid notification ID) - **Expected**: Error logged, app continues, no crash - **Verify**: Logs show error, recovery handles on next launch --- ## Open Questions — RESOLVED **See**: `docs/ios-rollover-open-questions-answers.md` for detailed answers ### Summary of Decisions: 1. **Fetcher Integration**: ✅ Defer to Phase 2, use optional parameter pattern 2. **AppDelegate Access**: ✅ Use Notification Center pattern (decoupling, flexibility) 3. **Background Task**: ✅ Rely on existing recovery + AppDelegate (no dedicated task) 4. **Error Handling**: ✅ Log + Continue (non-fatal), no retry, no user notification 5. **Performance**: ✅ Process individually (low volume, simplicity) 6. **Testing**: ✅ Manual testing for Phase 1, automated tests for Phase 2 --- ## Next Steps 1. **Review this document** ✅ (Current step) 2. **Address open questions** 3. **Create implementation tasks** 4. **Implement Phase 1** (Core rollover) 5. **Test Phase 1** 6. **Implement Phase 2** (Edge case detection) 7. **Final testing and validation** --- ## References - Edge Case Plan: `docs/ios-rollover-edge-case-plan.md` - Android Implementation: `android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java` - iOS Scheduler: `ios/Plugin/DailyNotificationScheduler.swift` - iOS Plugin: `ios/Plugin/DailyNotificationPlugin.swift`