# iOS Rollover Implementation — Edge Case Handling Plan **Status**: Planning Phase **Priority**: Reliability-First **Author**: AI Assistant **Date**: 2025-01-27 ## Objective Implement Android-like automatic rollover for iOS notifications with comprehensive edge case handling to ensure reliability across all scenarios, including time changes, timezone changes, DST transitions, and race conditions. --- ## Edge Case Categories ### 1. **Time Changes** - Manual clock adjustments (user changes device time) - System clock corrections (NTP sync) - Clock drift corrections - Time jumps (forward/backward) ### 2. **Timezone Changes** - User changes device timezone - Automatic timezone detection changes - Travel across timezones - Timezone database updates ### 3. **DST Transitions** - Spring forward (lose 1 hour) - Fall back (gain 1 hour) - DST rule changes - Regions that don't observe DST ### 4. **Race Conditions** - Multiple rollover attempts for same notification - Concurrent scheduling operations - App state transitions during rollover - Background task conflicts ### 5. **System Events** - Device reboots - App termination - Low memory conditions - Background execution limits ### 6. **Notification System Edge Cases** - Notification limit reached (64 pending) - Notification delivery failures - System notification queue issues - Permission changes --- ## Detection Mechanisms ### A. Time Change Detection **iOS Limitation**: iOS doesn't provide direct time change notifications like Android's `ACTION_TIME_CHANGED` broadcast. **Solution**: Multi-layered detection: 1. **App Launch Detection** - Store last known system time on app exit - Compare on app launch - Detect significant time jumps (>5 minutes) 2. **Background Task Detection** - Store timestamp when scheduling notification - Compare with current time when background task runs - Detect time discrepancies 3. **Notification Delivery Detection** - Compare scheduled time with actual delivery time - Flag if delivery time is significantly different 4. **Periodic Validation** - Background task validates scheduled notifications - Checks if notification times are still valid - Adjusts if time change detected ### B. Timezone Change Detection **iOS Limitation**: No direct timezone change notification. **Solution**: 1. **Store Timezone on Schedule** - Save timezone identifier when scheduling - Store as part of notification metadata 2. **Compare on Access** - Check current timezone vs stored timezone - Detect changes on app launch, background tasks, rollover 3. **Recalculate on Change** - If timezone changed, recalculate all scheduled times - Maintain same local time (e.g., 9:00 AM stays 9:00 AM) ### C. DST Transition Detection **Solution**: Use Calendar API for DST-aware calculations: 1. **Calendar-Based Calculation** - Use `Calendar.date(byAdding: .hour, value: 24, to:)` - Automatically handles DST transitions - No manual DST detection needed 2. **Validation After Calculation** - Verify calculated time is exactly 24 hours later in local time - Log DST transitions for debugging - Handle edge cases (e.g., 2:00 AM → 3:00 AM spring forward) ### D. Duplicate Prevention **Solution**: Multi-level idempotence checks: 1. **Database-Level Check** - Store rollover state per notification ID - Track last processed rollover time - Prevent duplicate rollover attempts 2. **Storage-Level Check** - Check for existing notifications at same scheduled time - Use tolerance window (1 minute) for DST shifts - Compare notification IDs and scheduled times 3. **System-Level Check** - Query `UNUserNotificationCenter` for pending notifications - Check if notification already scheduled - Cancel and reschedule if needed 4. **Request-Level Check** - Use unique notification IDs - Include timestamp in ID generation - Prevent ID collisions --- ## Handling Strategies ### Strategy 1: Time Change Handling **When Detected**: 1. **Validate All Scheduled Notifications** - Check if scheduled times are still valid - Recalculate if time change was significant - Cancel invalid notifications 2. **Recalculate Rollover Times** - If time changed, recalculate next notification time - Use DST-safe calculation - Maintain same local time (e.g., 9:00 AM) 3. **Reschedule Affected Notifications** - Cancel old notifications - Schedule with corrected times - Update storage with new times 4. **Log Time Change Event** - Record time change in history - Log old time, new time, delta - Track which notifications were affected **Implementation**: ```swift func handleTimeChange( lastKnownTime: Int64, currentTime: Int64, scheduledNotifications: [NotificationContent] ) async { let timeDelta = abs(currentTime - lastKnownTime) // Only handle significant time changes (>5 minutes) guard timeDelta > (5 * 60 * 1000) else { return // Ignore small clock adjustments } // Recalculate all scheduled notifications for notification in scheduledNotifications { // Recalculate using original scheduled time let originalScheduledTime = notification.scheduledTime let newScheduledTime = recalculateScheduledTime( originalTime: originalScheduledTime, timeDelta: timeDelta ) // Cancel old notification await scheduler.cancelNotification(id: notification.id) // Reschedule with corrected time let updatedNotification = NotificationContent( id: notification.id, title: notification.title, body: notification.body, scheduledTime: newScheduledTime, fetchedAt: notification.fetchedAt, url: notification.url, payload: notification.payload, etag: notification.etag ) await scheduler.scheduleNotification(updatedNotification) } // Record time change in history await recordTimeChangeEvent( oldTime: lastKnownTime, newTime: currentTime, delta: timeDelta ) } ``` ### Strategy 2: Timezone Change Handling **When Detected**: 1. **Detect Timezone Change** - Compare current timezone with stored timezone - Detect on app launch, background tasks, rollover 2. **Recalculate All Scheduled Times** - Maintain same local time (e.g., 9:00 AM) - Convert to new timezone - Update scheduled times 3. **Reschedule All Notifications** - Cancel existing notifications - Schedule with new times - Update storage **Implementation**: ```swift func handleTimezoneChange( oldTimezone: TimeZone, newTimezone: TimeZone, scheduledNotifications: [NotificationContent] ) async { // Extract local time from each notification for notification in scheduledNotifications { // Get local time components (hour, minute) let scheduledDate = notification.getScheduledTimeAsDate() let calendar = Calendar.current let hour = calendar.component(.hour, from: scheduledDate) let minute = calendar.component(.minute, from: scheduledDate) // Recalculate in new timezone let newScheduledTime = calculateNextOccurrence( hour: hour, minute: minute, timezone: newTimezone ) // Cancel old notification await scheduler.cancelNotification(id: notification.id) // Reschedule with new time let updatedNotification = NotificationContent( id: notification.id, title: notification.title, body: notification.body, scheduledTime: newScheduledTime, fetchedAt: notification.fetchedAt, url: notification.url, payload: notification.payload, etag: notification.etag ) await scheduler.scheduleNotification(updatedNotification) } // Update stored timezone await storage.saveTimezone(newTimezone.identifier) } ``` ### Strategy 3: DST Transition Handling **When Detected**: 1. **Use Calendar API** - `Calendar.date(byAdding: .hour, value: 24, to:)` handles DST automatically - No manual DST detection needed 2. **Validate Calculation** - Verify 24-hour addition results in correct local time - Log DST transitions for debugging - Handle edge cases (2:00 AM → 3:00 AM) 3. **Handle Edge Cases** - Spring forward: Notification might be scheduled for 2:00 AM (doesn't exist) - Fall back: Notification might be scheduled for 2:00 AM (occurs twice) - Use system's automatic handling **Implementation**: ```swift func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 { let calendar = Calendar.current let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0) // Add 24 hours (handles DST automatically) guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else { // Fallback to simple addition return currentScheduledTime + (24 * 60 * 60 * 1000) } // Validate: Ensure it's exactly 24 hours later in local time 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) // Log DST transitions if currentHour != nextHour || currentMinute != nextMinute { print("\(Self.TAG): DST transition detected: \(currentHour):\(currentMinute) -> \(nextHour):\(nextMinute)") } return Int64(nextDate.timeIntervalSince1970 * 1000) } ``` ### Strategy 4: Duplicate Prevention **Multi-Level Checks**: 1. **Rollover State Tracking** - Store rollover state in database - Track last processed notification ID - Prevent duplicate rollover attempts 2. **Time-Based Deduplication** - Check for existing notifications at same scheduled time - Use tolerance window (1 minute) for DST shifts - Compare notification IDs 3. **System-Level Verification** - Query `UNUserNotificationCenter` for pending notifications - Check if notification already scheduled - Cancel and reschedule if needed **Implementation**: ```swift func scheduleNextNotification( _ content: NotificationContent, storage: DailyNotificationStorage?, fetcher: DailyNotificationFetcher? = nil ) async -> Bool { // Check 1: Rollover state tracking 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 time let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) // Check 2: Storage-level duplicate check if let storage = storage { let existingNotifications = storage.getAllNotifications() let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance for existing in existingNotifications { if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs { print("\(Self.TAG): RESCHEDULE_DUPLICATE id=\(content.id) existing_id=\(existing.id)") return false } } } // Check 3: System-level duplicate check 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 } } } // All checks passed, proceed with scheduling // ... (rest of scheduling logic) // Mark rollover as processed await storage?.saveLastRolloverTime(for: content.id, time: Int64(Date().timeIntervalSince1970 * 1000)) return true } ``` ### Strategy 5: Race Condition Prevention **Solution**: Use serial queue + state tracking 1. **Serial Queue for Rollover** - Use dedicated serial queue for rollover operations - Prevent concurrent rollover attempts - Ensure atomic operations 2. **State Machine** - Track rollover state (pending, processing, completed) - Prevent duplicate processing - Handle failures gracefully 3. **Locking Mechanism** - Use actor or serial queue for thread safety - Prevent race conditions - Ensure atomic updates **Implementation**: ```swift actor RolloverCoordinator { private var processingNotifications: Set = [] private let scheduler: DailyNotificationScheduler private let storage: DailyNotificationStorage func processRollover(for notificationId: String) async -> Bool { // Check if already processing if processingNotifications.contains(notificationId) { print("RolloverCoordinator: Already processing \(notificationId)") return false } // Mark as processing processingNotifications.insert(notificationId) defer { processingNotifications.remove(notificationId) } // Perform rollover // ... (rollover logic) return true } } ``` --- ## Implementation Architecture ### Component 1: TimeChangeDetector **Purpose**: Detect time changes and trigger recovery **Responsibilities**: - Store last known system time - Compare on app launch/background tasks - Detect significant time jumps - Trigger time change recovery **Location**: `ios/Plugin/DailyNotificationTimeChangeDetector.swift` ### Component 2: TimezoneChangeDetector **Purpose**: Detect timezone changes and trigger recalculation **Responsibilities**: - Store current timezone - Compare on access - Detect timezone changes - Trigger timezone change recovery **Location**: `ios/Plugin/DailyNotificationTimezoneChangeDetector.swift` ### Component 3: RolloverCoordinator **Purpose**: Coordinate rollover operations with duplicate prevention **Responsibilities**: - Manage rollover state - Prevent duplicate rollovers - Coordinate multiple detection mechanisms - Handle race conditions **Location**: `ios/Plugin/DailyNotificationRolloverCoordinator.swift` ### Component 4: Enhanced Recovery Manager **Purpose**: Extend existing recovery manager with time/timezone change handling **Responsibilities**: - Integrate time change detection - Integrate timezone change detection - Coordinate with rollover coordinator - Handle all edge cases **Location**: `ios/Plugin/DailyNotificationReactivationManager.swift` (enhance existing) --- ## Testing Strategy ### Test Category 1: Time Changes 1. **Manual Clock Adjustment** - Set device time forward 1 hour - Verify notifications rescheduled correctly - Verify rollover still works 2. **Clock Jump Forward** - Set device time forward 24 hours - Verify all notifications recalculated - Verify no duplicates created 3. **Clock Jump Backward** - Set device time backward 1 hour - Verify notifications still valid - Verify rollover works correctly ### Test Category 2: Timezone Changes 1. **Timezone Change** - Change device timezone - Verify notifications rescheduled to same local time - Verify rollover maintains local time 2. **Travel Simulation** - Change timezone multiple times - Verify notifications always at correct local time - Verify no duplicates ### Test Category 3: DST Transitions 1. **Spring Forward** - Test on DST spring forward day - Verify 24-hour calculation handles correctly - Verify notification fires at correct time 2. **Fall Back** - Test on DST fall back day - Verify 24-hour calculation handles correctly - Verify no duplicate notifications ### Test Category 4: Race Conditions 1. **Concurrent Rollover** - Trigger multiple rollover attempts simultaneously - Verify only one succeeds - Verify no duplicates 2. **App State Transitions** - Trigger rollover during app state changes - Verify rollover completes correctly - Verify no data corruption ### Test Category 5: Edge Cases 1. **Notification Limit** - Schedule 64 notifications - Verify rollover still works - Verify proper error handling 2. **Permission Changes** - Revoke notification permission - Verify graceful failure - Verify recovery when permission restored --- ## Implementation Phases ### Phase 1: Core Rollover (Week 1) - ✅ DST-safe time calculation - ✅ Basic rollover scheduling - ✅ Duplicate prevention (storage + system level) - ✅ AppDelegate integration ### Phase 2: Edge Case Detection (Week 2) - ✅ Time change detection - ✅ Timezone change detection - ✅ Rollover state tracking - ✅ Race condition prevention ### Phase 3: Recovery Integration (Week 3) - ✅ Time change recovery - ✅ Timezone change recovery - ✅ Enhanced recovery manager - ✅ Background task integration ### Phase 4: Testing & Validation (Week 4) - ✅ Comprehensive edge case testing - ✅ Real device testing - ✅ DST transition testing - ✅ Performance optimization --- ## Success Criteria 1. **Reliability**: 99%+ rollover success rate across all edge cases 2. **No Duplicates**: Zero duplicate notifications in any scenario 3. **Time Accuracy**: Notifications fire within 1 minute of scheduled time 4. **Recovery**: All edge cases handled gracefully with recovery 5. **Performance**: Rollover completes in <1 second 6. **Logging**: Comprehensive logging for debugging --- ## Risk Mitigation ### Risk 1: iOS Background Execution Limits **Mitigation**: Multiple detection mechanisms (delegate + background + recovery) ### Risk 2: Time Change Detection Reliability **Mitigation**: Store timestamps, compare on every access, validate scheduled times ### Risk 3: Race Conditions **Mitigation**: Serial queue, state machine, actor-based coordination ### Risk 4: DST Edge Cases **Mitigation**: Use Calendar API, validate calculations, comprehensive testing ### Risk 5: Notification System Limits **Mitigation**: Check pending count, handle gracefully, provide user feedback --- ## Next Steps 1. **Review & Approve Plan** (This document) 2. **Create Implementation Tasks** (Break down into specific tasks) 3. **Implement Phase 1** (Core rollover functionality) 4. **Test Phase 1** (Basic functionality) 5. **Implement Phase 2** (Edge case detection) 6. **Test Phase 2** (Edge case scenarios) 7. **Implement Phase 3** (Recovery integration) 8. **Test Phase 3** (Recovery scenarios) 9. **Final Testing** (Comprehensive validation) 10. **Documentation** (Update docs with edge case handling) --- ## References - Android Implementation: `DailyNotificationWorker.java` (scheduleNextNotification) - Android Time Change Handling: `DailyNotificationRebootRecoveryManager.java` - iOS Calendar API: `Calendar.date(byAdding:to:)` documentation - iOS Background Tasks: `BGTaskScheduler` documentation - iOS Notifications: `UNUserNotificationCenter` documentation