Consolidate all markdown documentation into organized structure per CONSOLIDATION_DIRECTIVE. All files preserved (canonical, merged, or archived). - docs/integration/ - Integration documentation (7 files) - docs/platform/ios/ - iOS platform docs (12 files) - docs/platform/android/ - Android platform docs (9 files) - docs/testing/ - Testing documentation (15 files) - docs/design/ - Design & research (5 files) - docs/ai/ - AI/ChatGPT artifacts (7 files) - docs/archive/2025-legacy-doc/ - Historical docs (17 files) - Integration: Root INTEGRATION_GUIDE.md → docs/integration/ - Platform: Separated iOS and Android into platform/ subdirectories - Testing: Consolidated all testing docs to docs/testing/ - Legacy: Archived entire doc/ directory to archive/ - AI: Moved all ChatGPT artifacts to docs/ai/ - Added docs/00-INDEX.md - Central navigation hub - Added docs/CONSOLIDATION_SOURCE_MAP.md - Complete audit trail - Added docs/CONSOLIDATION_COMPLETE.md - Consolidation summary - Updated README.md with links to documentation index - All 139 files have destinations (see CONSOLIDATION_SOURCE_MAP.md) - Zero information loss (all files preserved) - Archive preserves original structure - Index provides clear navigation - 87 files moved/created/updated - Root-level docs consolidated - Legacy doc/ directory archived - Test app docs remain with test apps (indexed) Ref: CONSOLIDATION_DIRECTIVE Author: Matthew Raymer
19 KiB
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:
-
App Launch Detection
- Store last known system time on app exit
- Compare on app launch
- Detect significant time jumps (>5 minutes)
-
Background Task Detection
- Store timestamp when scheduling notification
- Compare with current time when background task runs
- Detect time discrepancies
-
Notification Delivery Detection
- Compare scheduled time with actual delivery time
- Flag if delivery time is significantly different
-
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:
-
Store Timezone on Schedule
- Save timezone identifier when scheduling
- Store as part of notification metadata
-
Compare on Access
- Check current timezone vs stored timezone
- Detect changes on app launch, background tasks, rollover
-
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:
-
Calendar-Based Calculation
- Use
Calendar.date(byAdding: .hour, value: 24, to:) - Automatically handles DST transitions
- No manual DST detection needed
- Use
-
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:
-
Database-Level Check
- Store rollover state per notification ID
- Track last processed rollover time
- Prevent duplicate rollover attempts
-
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
-
System-Level Check
- Query
UNUserNotificationCenterfor pending notifications - Check if notification already scheduled
- Cancel and reschedule if needed
- Query
-
Request-Level Check
- Use unique notification IDs
- Include timestamp in ID generation
- Prevent ID collisions
Handling Strategies
Strategy 1: Time Change Handling
When Detected:
-
Validate All Scheduled Notifications
- Check if scheduled times are still valid
- Recalculate if time change was significant
- Cancel invalid notifications
-
Recalculate Rollover Times
- If time changed, recalculate next notification time
- Use DST-safe calculation
- Maintain same local time (e.g., 9:00 AM)
-
Reschedule Affected Notifications
- Cancel old notifications
- Schedule with corrected times
- Update storage with new times
-
Log Time Change Event
- Record time change in history
- Log old time, new time, delta
- Track which notifications were affected
Implementation:
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:
-
Detect Timezone Change
- Compare current timezone with stored timezone
- Detect on app launch, background tasks, rollover
-
Recalculate All Scheduled Times
- Maintain same local time (e.g., 9:00 AM)
- Convert to new timezone
- Update scheduled times
-
Reschedule All Notifications
- Cancel existing notifications
- Schedule with new times
- Update storage
Implementation:
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:
-
Use Calendar API
Calendar.date(byAdding: .hour, value: 24, to:)handles DST automatically- No manual DST detection needed
-
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)
-
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:
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:
-
Rollover State Tracking
- Store rollover state in database
- Track last processed notification ID
- Prevent duplicate rollover attempts
-
Time-Based Deduplication
- Check for existing notifications at same scheduled time
- Use tolerance window (1 minute) for DST shifts
- Compare notification IDs
-
System-Level Verification
- Query
UNUserNotificationCenterfor pending notifications - Check if notification already scheduled
- Cancel and reschedule if needed
- Query
Implementation:
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
-
Serial Queue for Rollover
- Use dedicated serial queue for rollover operations
- Prevent concurrent rollover attempts
- Ensure atomic operations
-
State Machine
- Track rollover state (pending, processing, completed)
- Prevent duplicate processing
- Handle failures gracefully
-
Locking Mechanism
- Use actor or serial queue for thread safety
- Prevent race conditions
- Ensure atomic updates
Implementation:
actor RolloverCoordinator {
private var processingNotifications: Set<String> = []
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
-
Manual Clock Adjustment
- Set device time forward 1 hour
- Verify notifications rescheduled correctly
- Verify rollover still works
-
Clock Jump Forward
- Set device time forward 24 hours
- Verify all notifications recalculated
- Verify no duplicates created
-
Clock Jump Backward
- Set device time backward 1 hour
- Verify notifications still valid
- Verify rollover works correctly
Test Category 2: Timezone Changes
-
Timezone Change
- Change device timezone
- Verify notifications rescheduled to same local time
- Verify rollover maintains local time
-
Travel Simulation
- Change timezone multiple times
- Verify notifications always at correct local time
- Verify no duplicates
Test Category 3: DST Transitions
-
Spring Forward
- Test on DST spring forward day
- Verify 24-hour calculation handles correctly
- Verify notification fires at correct time
-
Fall Back
- Test on DST fall back day
- Verify 24-hour calculation handles correctly
- Verify no duplicate notifications
Test Category 4: Race Conditions
-
Concurrent Rollover
- Trigger multiple rollover attempts simultaneously
- Verify only one succeeds
- Verify no duplicates
-
App State Transitions
- Trigger rollover during app state changes
- Verify rollover completes correctly
- Verify no data corruption
Test Category 5: Edge Cases
-
Notification Limit
- Schedule 64 notifications
- Verify rollover still works
- Verify proper error handling
-
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
- Reliability: 99%+ rollover success rate across all edge cases
- No Duplicates: Zero duplicate notifications in any scenario
- Time Accuracy: Notifications fire within 1 minute of scheduled time
- Recovery: All edge cases handled gracefully with recovery
- Performance: Rollover completes in <1 second
- 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
- Review & Approve Plan (This document)
- Create Implementation Tasks (Break down into specific tasks)
- Implement Phase 1 (Core rollover functionality)
- Test Phase 1 (Basic functionality)
- Implement Phase 2 (Edge case detection)
- Test Phase 2 (Edge case scenarios)
- Implement Phase 3 (Recovery integration)
- Test Phase 3 (Recovery scenarios)
- Final Testing (Comprehensive validation)
- 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:
BGTaskSchedulerdocumentation - iOS Notifications:
UNUserNotificationCenterdocumentation