Files
daily-notification-plugin/docs/platform/ios/ROLLOVER_IMPLEMENTATION_REVIEW.md
Matthew Raymer c39bd7cec6 docs: Consolidate documentation structure (139 files, zero information loss)
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
2025-12-18 09:13:18 +00:00

23 KiB

iOS Rollover Implementation — Comprehensive Review

Status: Pre-Implementation Review
Date: 2025-01-27
Priority: Reliability-First


Table of Contents

  1. Plan Overview
  2. File Changes Summary
  3. Detailed File Modifications
  4. Integration Points
  5. Dependencies & Order
  6. Testing Strategy
  7. 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):

/**
 * 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):

/**
 * 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):

// 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):

/**
 * 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):

// 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:

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):

// 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):

/**
 * 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):

// 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):

/**
 * 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