diff --git a/docs/ios-rollover-edge-case-plan.md b/docs/ios-rollover-edge-case-plan.md new file mode 100644 index 0000000..39d994c --- /dev/null +++ b/docs/ios-rollover-edge-case-plan.md @@ -0,0 +1,649 @@ +# 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 diff --git a/docs/ios-rollover-implementation-review.md b/docs/ios-rollover-implementation-review.md new file mode 100644 index 0000000..8ee5230 --- /dev/null +++ b/docs/ios-rollover-implementation-review.md @@ -0,0 +1,633 @@ +# 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` diff --git a/docs/ios-rollover-open-questions-answers.md b/docs/ios-rollover-open-questions-answers.md new file mode 100644 index 0000000..a4ba4e8 --- /dev/null +++ b/docs/ios-rollover-open-questions-answers.md @@ -0,0 +1,343 @@ +# iOS Rollover Implementation — Open Questions & Answers + +**Date**: 2025-01-27 +**Status**: Pre-Implementation Decisions + +--- + +## Question 1: Fetcher Integration + +**Question**: How should we integrate DailyNotificationFetcher for prefetch scheduling? (Phase 2) + +### Current State Analysis + +- **Android**: Uses `DailyNotificationFetcher.scheduleFetch(fetchTime)` and `scheduleImmediateFetch()` +- **iOS**: Has `DailyNotificationBackgroundTaskManager` with `scheduleBackgroundTask()` method +- **iOS Pattern**: Uses `BGTaskScheduler` with `BGAppRefreshTaskRequest` + +### Recommendation: **Defer to Phase 2, Use Placeholder Pattern** + +**Rationale**: +1. **Phase 1 Focus**: Core rollover functionality (scheduling next notification) +2. **Prefetch is Separate**: Prefetch scheduling is independent of rollover +3. **Existing Infrastructure**: iOS already has background task infrastructure +4. **Android Pattern**: Android also separates rollover from prefetch (optional parameter) + +### Implementation Approach + +**Phase 1 (Current)**: +- Make `fetcher` parameter optional in `scheduleNextNotification()` +- Add TODO comments for Phase 2 integration +- Log prefetch scheduling intent (even if not executed) + +**Phase 2 (Future)**: +- Create `DailyNotificationFetcher` class (iOS equivalent) +- Integrate with `DailyNotificationBackgroundTaskManager` +- Use `BGTaskScheduler` for prefetch scheduling +- Calculate fetch time: `nextScheduledTime - (5 * 60 * 1000)` (5 minutes before) + +### Code Pattern + +```swift +// Phase 1: Optional fetcher, log intent +if let fetcher = fetcher { + let fetchTime = nextScheduledTime - (5 * 60 * 1000) + // TODO: Phase 2 - Implement fetcher.scheduleFetch(fetchTime) + print("\(Self.TAG): RESCHEDULE_PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime)") +} else { + print("\(Self.TAG): RESCHEDULE_PREFETCH_SKIP id=\(content.id) fetcher_not_available") +} +``` + +**Decision**: ✅ **Defer to Phase 2, use optional parameter pattern** + +--- + +## Question 2: AppDelegate Access + +**Question**: Is there a better way to access the plugin from AppDelegate without using Capacitor bridge? + +### Current State Analysis + +- **Capacitor Pattern**: Uses `CAPBridgeViewController` to access plugins +- **Test App**: Already uses this pattern for other operations +- **Production Apps**: May have different AppDelegate structures + +### Recommendation: **Use Notification Center Pattern** + +**Rationale**: +1. **Decoupling**: AppDelegate doesn't need direct plugin reference +2. **Flexibility**: Works across different app architectures +3. **Reliability**: Notification center is always available +4. **Testability**: Easier to test without Capacitor dependency + +### Implementation Approach + +**Option A: Notification Center (Recommended)** +- Plugin registers for notification delivery events +- AppDelegate posts notification when delivery detected +- Plugin handles rollover in response to notification + +**Option B: Capacitor Bridge (Fallback)** +- Use existing bridge pattern +- Works but creates tight coupling +- Use as fallback if notification center doesn't work + +### Code Pattern + +```swift +// In DailyNotificationPlugin.load(): +NotificationCenter.default.addObserver( + self, + selector: #selector(handleNotificationDelivery(_:)), + name: NSNotification.Name("DailyNotificationDelivered"), + object: nil +) + +// In AppDelegate.willPresent: +NotificationCenter.default.post( + name: NSNotification.Name("DailyNotificationDelivered"), + object: nil, + userInfo: [ + "notification_id": notificationId, + "scheduled_time": scheduledTime + ] +) +``` + +**Decision**: ✅ **Use Notification Center pattern, with Capacitor bridge as fallback** + +--- + +## Question 3: Background Task + +**Question**: Should we add a dedicated background task for rollover detection, or rely on existing recovery mechanisms? + +### Current State Analysis + +- **Existing Recovery**: `DailyNotificationReactivationManager` already runs on app launch +- **Background Tasks**: iOS has strict limits on background execution +- **Reliability**: Multiple detection mechanisms increase reliability + +### Recommendation: **Rely on Existing Recovery + AppDelegate** + +**Rationale**: +1. **iOS Limitations**: Background tasks are unreliable (system-controlled) +2. **Existing Infrastructure**: Recovery manager already handles app launch scenarios +3. **Coverage**: AppDelegate (foreground) + Recovery (background) covers all cases +4. **Simplicity**: Fewer moving parts = fewer failure points + +### Implementation Approach + +**Two Detection Mechanisms**: +1. **Foreground**: AppDelegate `willPresent` → immediate rollover +2. **Background**: Recovery Manager → rollover on app launch + +**No Dedicated Background Task**: +- Background tasks are unreliable (system decides when to run) +- Recovery manager already covers app launch scenarios +- Adding another mechanism adds complexity without significant benefit + +### Code Pattern + +```swift +// Detection Mechanism 1: Foreground (AppDelegate) +func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, ...) { + // Trigger rollover immediately + await handleNotificationRollover(...) +} + +// Detection Mechanism 2: Background (Recovery Manager) +func performColdStartRecovery() async { + // Check for delivered notifications + await checkAndProcessDeliveredNotifications() +} +``` + +**Decision**: ✅ **Rely on existing recovery + AppDelegate, no dedicated background task** + +--- + +## Question 4: Error Handling + +**Question**: How should we handle rollover failures? Retry? Log? User notification? + +### Current State Analysis + +- **Android Pattern**: Logs errors, continues execution (non-fatal) +- **iOS Recovery Manager**: Catches all errors, logs, continues (non-fatal) +- **User Experience**: Failures should be silent (background operation) + +### Recommendation: **Log + Continue (Non-Fatal)** + +**Rationale**: +1. **Background Operation**: Rollover is background, shouldn't interrupt user +2. **Recovery Available**: Recovery manager will catch missed rollovers on next app launch +3. **Consistency**: Matches Android and existing iOS recovery patterns +4. **User Experience**: Silent failures, recovery on next launch + +### Implementation Approach + +**Error Handling Strategy**: +1. **Log Errors**: Comprehensive logging for debugging +2. **Continue Execution**: Don't crash or interrupt app +3. **No Retry**: Let recovery manager handle on next launch +4. **No User Notification**: Background operation, silent failure +5. **History Recording**: Record failures in history (if history implemented) + +### Code Pattern + +```swift +func scheduleNextNotification(...) async -> Bool { + do { + // Rollover logic + return true + } catch { + print("\(Self.TAG): RESCHEDULE_ERR id=\(content.id) err=\(error.localizedDescription)") + // Log error but don't throw - let recovery handle on next launch + return false + } +} + +// In recovery manager: +if !scheduled { + print("\(Self.TAG): Failed to roll over delivered notification id=\(notificationId)") + // Recovery will retry on next app launch +} +``` + +**Decision**: ✅ **Log + Continue (non-fatal), no retry, no user notification** + +--- + +## Question 5: Performance + +**Question**: Should we batch rollover operations or process individually? + +### Current State Analysis + +- **Android Pattern**: Processes individually (one notification at a time) +- **iOS Recovery**: Processes notifications individually +- **Volume**: Typically 1-2 notifications per day (low volume) + +### Recommendation: **Process Individually** + +**Rationale**: +1. **Low Volume**: Typically 1 notification per day, batching unnecessary +2. **Simplicity**: Individual processing is simpler and easier to debug +3. **Error Isolation**: Individual processing isolates failures +4. **Consistency**: Matches Android and existing iOS patterns + +### Implementation Approach + +**Individual Processing**: +- Process each notification rollover separately +- Each rollover is independent operation +- Failures in one don't affect others +- Easier to log and debug + +**Future Optimization** (if needed): +- If volume increases, consider batching +- Current volume doesn't justify batching complexity + +### Code Pattern + +```swift +// Process individually (current approach) +for notification in deliveredNotifications { + await scheduler.scheduleNextNotification(notification, ...) +} + +// Batching would look like: +// await scheduler.scheduleNextNotificationsBatch(notifications, ...) +// But not needed for current volume +``` + +**Decision**: ✅ **Process individually (current volume doesn't justify batching)** + +--- + +## Question 6: Testing + +**Question**: Do we need automated tests for rollover, or is manual testing sufficient for Phase 1? + +### Current State Analysis + +- **Existing Tests**: iOS has unit tests for recovery manager +- **Test Coverage**: Some components have tests, others don't +- **Phase 1 Scope**: Core rollover functionality + +### Recommendation: **Manual Testing for Phase 1, Automated Tests for Phase 2** + +**Rationale**: +1. **Phase 1 Focus**: Core functionality, manual testing sufficient +2. **Complexity**: Rollover involves system notifications (hard to test automatically) +3. **Time Investment**: Automated tests take time, manual testing faster for Phase 1 +4. **Phase 2**: Add automated tests when edge cases are implemented + +### Implementation Approach + +**Phase 1 Testing**: +- Manual testing checklist +- Test scenarios: foreground delivery, background delivery, duplicates +- Real device testing (simulator may not handle notifications correctly) + +**Phase 2 Testing**: +- Unit tests for time calculations (DST, timezone) +- Integration tests for rollover flow +- Edge case tests (time changes, timezone changes) + +### Test Checklist (Phase 1) + +1. ✅ **Foreground Delivery**: App running, notification fires → rollover triggers +2. ✅ **Background Delivery**: App not running, notification fires → rollover on launch +3. ✅ **Duplicate Prevention**: Multiple rollover attempts → only one scheduled +4. ✅ **DST Transition**: Schedule on DST day → correct time calculation +5. ✅ **Error Handling**: Simulate failure → graceful degradation + +**Decision**: ✅ **Manual testing for Phase 1, automated tests for Phase 2** + +--- + +## Summary of Decisions + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **Fetcher Integration** | Defer to Phase 2, optional parameter | Prefetch is separate concern, Phase 1 focuses on core rollover | +| **AppDelegate Access** | Notification Center pattern | Decoupling, flexibility, reliability | +| **Background Task** | Rely on existing recovery | iOS limitations, existing infrastructure sufficient | +| **Error Handling** | Log + Continue (non-fatal) | Background operation, recovery handles failures | +| **Performance** | Process individually | Low volume, simplicity, consistency | +| **Testing** | Manual for Phase 1, automated for Phase 2 | Phase 1 scope, complexity, time investment | + +--- + +## Implementation Impact + +### Changes to Review Document + +Based on these decisions, the review document should be updated: + +1. **Fetcher Parameter**: Make optional, add Phase 2 TODOs +2. **AppDelegate Pattern**: Use Notification Center instead of Capacitor bridge +3. **Background Task**: Remove dedicated background task, rely on recovery +4. **Error Handling**: Add comprehensive logging, non-fatal errors +5. **Performance**: Individual processing (no batching) +6. **Testing**: Manual testing checklist for Phase 1 + +### Next Steps + +1. ✅ **Decisions Made** (This document) +2. **Update Review Document** with decisions +3. **Update Implementation Plan** with specific patterns +4. **Begin Phase 1 Implementation** + +--- + +## References + +- Review Document: `docs/ios-rollover-implementation-review.md` +- Edge Case Plan: `docs/ios-rollover-edge-case-plan.md` +- Android Implementation: `android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java` +- iOS Recovery Manager: `ios/Plugin/DailyNotificationReactivationManager.swift` +- iOS Background Tasks: `ios/Plugin/DailyNotificationBackgroundTaskManager.swift` diff --git a/ios/App/App.xcworkspace/xcuserdata/aardimus.xcuserdatad/UserInterfaceState.xcuserstate b/ios/App/App.xcworkspace/xcuserdata/aardimus.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..71700ba Binary files /dev/null and b/ios/App/App.xcworkspace/xcuserdata/aardimus.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index 7f447a2..fe1a238 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -72,6 +72,14 @@ public class DailyNotificationPlugin: CAPPlugin { // Perform recovery on app launch (async, non-blocking) reactivationManager?.performRecovery() + // Register for notification delivery events (Notification Center pattern) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNotificationDelivery(_:)), + name: NSNotification.Name("DailyNotificationDelivered"), + object: nil + ) + NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") } @@ -1068,6 +1076,38 @@ public class DailyNotificationPlugin: CAPPlugin { // Store notification content via state actor (thread-safe) Task { + // Reset: Cancel all existing notifications and clear rollover state + // This ensures clicking "Test Notification" starts fresh + // Cancel all pending notifications (including rollovers) + await scheduler.cancelAllNotifications() + NSLog("DNP-PLUGIN: Cleared all pending notifications for fresh schedule") + print("DNP-PLUGIN: Cleared all pending notifications for fresh schedule") + + // Clear all stored notification content + if let storage = self.storage { + storage.clearAllNotifications() + NSLog("DNP-PLUGIN: Cleared all stored notification content") + print("DNP-PLUGIN: Cleared all stored notification content") + } + + // Clear rollover state from UserDefaults + // Clear global rollover time + if let storage = self.storage { + storage.saveLastRolloverTime(0) + } + + // Clear per-notification rollover times + // We need to clear all rollover_* keys from UserDefaults + let userDefaults = UserDefaults.standard + let allKeys = userDefaults.dictionaryRepresentation().keys + for key in allKeys { + if key.hasPrefix("rollover_") { + userDefaults.removeObject(forKey: key) + } + } + userDefaults.synchronize() + NSLog("DNP-PLUGIN: Cleared all rollover state") + print("DNP-PLUGIN: Cleared all rollover state") if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveNotificationContent(content) @@ -1226,12 +1266,17 @@ public class DailyNotificationPlugin: CAPPlugin { // Calculate next notification time let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0 + // Get rollover status + let lastRolloverTime = 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 ] @@ -1241,6 +1286,93 @@ public class DailyNotificationPlugin: CAPPlugin { } } + /** + * 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 { + NSLog("DNP-ROLLOVER: INVALID_DATA userInfo=\(String(describing: notification.userInfo))") + print("DNP-ROLLOVER: INVALID_DATA userInfo=\(String(describing: notification.userInfo))") + return + } + + let scheduledTimeStr = formatTime(scheduledTime) + NSLog("DNP-ROLLOVER: DELIVERY_DETECTED id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + print("DNP-ROLLOVER: DELIVERY_DETECTED id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + + 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 { + let scheduledTimeStr = formatTime(scheduledTime) + NSLog("DNP-ROLLOVER: PROCESS_START id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + print("DNP-ROLLOVER: PROCESS_START id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + + guard let scheduler = scheduler, let storage = storage else { + NSLog("DNP-ROLLOVER: ERROR id=\(notificationId) plugin_not_initialized") + print("DNP-ROLLOVER: ERROR id=\(notificationId) plugin_not_initialized") + return + } + + // Get the notification content that was delivered + guard let content = storage.getNotificationContent(id: notificationId) else { + NSLog("DNP-ROLLOVER: ERROR id=\(notificationId) content_not_found") + print("DNP-ROLLOVER: ERROR id=\(notificationId) content_not_found") + return + } + + let contentTimeStr = formatTime(content.scheduledTime) + NSLog("DNP-ROLLOVER: CONTENT_FOUND id=\(notificationId) content_scheduled_time=\(contentTimeStr)") + print("DNP-ROLLOVER: CONTENT_FOUND id=\(notificationId) content_scheduled_time=\(contentTimeStr)") + + // 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 { + NSLog("DNP-ROLLOVER: PROCESS_SUCCESS id=\(notificationId) next_notification_scheduled") + print("DNP-ROLLOVER: PROCESS_SUCCESS id=\(notificationId) next_notification_scheduled") + // Log success (non-fatal, background operation) + } else { + NSLog("DNP-ROLLOVER: PROCESS_FAILED id=\(notificationId) next_notification_not_scheduled") + print("DNP-ROLLOVER: PROCESS_FAILED id=\(notificationId) next_notification_not_scheduled") + // Log failure but continue (recovery will handle on next launch) + } + } + + /** + * 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) + } + /** * Check permission status * Returns boolean flags for each permission type diff --git a/ios/Plugin/DailyNotificationReactivationManager.swift b/ios/Plugin/DailyNotificationReactivationManager.swift index bed4f78..3f2c0da 100644 --- a/ios/Plugin/DailyNotificationReactivationManager.swift +++ b/ios/Plugin/DailyNotificationReactivationManager.swift @@ -393,6 +393,10 @@ class DailyNotificationReactivationManager { } } + // Step 4.5: Check for delivered notifications and trigger rollover + // This handles notifications that were delivered while app was not running + await checkAndProcessDeliveredNotifications() + // Record recovery in history let result = RecoveryResult( missedCount: missedCount, @@ -1004,6 +1008,94 @@ class DailyNotificationReactivationManager { // Don't throw - this is best effort } } + + /** + * 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 { + NSLog("DNP-ROLLOVER: RECOVERY_CHECK_START") + print("DNP-ROLLOVER: RECOVERY_CHECK_START") + + // Get delivered notifications from system + let deliveredNotifications = await notificationCenter.deliveredNotifications() + NSLog("DNP-ROLLOVER: RECOVERY_FOUND delivered_count=\(deliveredNotifications.count)") + print("DNP-ROLLOVER: RECOVERY_FOUND delivered_count=\(deliveredNotifications.count)") + + // Get last processed rollover time from storage + let lastProcessedTime = storage.getLastRolloverTime() + let lastProcessedTimeStr = formatTime(lastProcessedTime) + NSLog("DNP-ROLLOVER: RECOVERY_LAST_PROCESSED time=\(lastProcessedTimeStr)") + print("DNP-ROLLOVER: RECOVERY_LAST_PROCESSED time=\(lastProcessedTimeStr)") + + var processedCount = 0 + var skippedCount = 0 + + 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 + } + + let scheduledTimeStr = formatTime(scheduledTime) + + // Only process if this notification hasn't been processed yet + if scheduledTime > lastProcessedTime { + NSLog("DNP-ROLLOVER: RECOVERY_PROCESS id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + print("DNP-ROLLOVER: RECOVERY_PROCESS id=\(notificationId) scheduled_time=\(scheduledTimeStr)") + + // Get notification content + guard let content = storage.getNotificationContent(id: notificationId) else { + NSLog("DNP-ROLLOVER: RECOVERY_ERROR id=\(notificationId) content_not_found") + print("DNP-ROLLOVER: RECOVERY_ERROR id=\(notificationId) content_not_found") + continue + } + + // Trigger rollover + let scheduled = await scheduler.scheduleNextNotification( + content, + storage: storage, + fetcher: nil // TODO: Phase 2 - Add fetcher + ) + + if scheduled { + NSLog("DNP-ROLLOVER: RECOVERY_SUCCESS id=\(notificationId)") + print("DNP-ROLLOVER: RECOVERY_SUCCESS id=\(notificationId)") + // Update last processed time + storage.saveLastRolloverTime(scheduledTime) + processedCount += 1 + } else { + NSLog("DNP-ROLLOVER: RECOVERY_FAILED id=\(notificationId)") + print("DNP-ROLLOVER: RECOVERY_FAILED id=\(notificationId)") + } + } else { + skippedCount += 1 + NSLog("DNP-ROLLOVER: RECOVERY_SKIP id=\(notificationId) already_processed scheduled_time=\(scheduledTimeStr)") + print("DNP-ROLLOVER: RECOVERY_SKIP id=\(notificationId) already_processed scheduled_time=\(scheduledTimeStr)") + } + } + + NSLog("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)") + print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)") + } + + /** + * 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) + } } // MARK: - Supporting Types diff --git a/ios/Plugin/DailyNotificationScheduler.swift b/ios/Plugin/DailyNotificationScheduler.swift index bc75410..9438d42 100644 --- a/ios/Plugin/DailyNotificationScheduler.swift +++ b/ios/Plugin/DailyNotificationScheduler.swift @@ -314,12 +314,251 @@ class DailyNotificationScheduler { func getNextNotificationTime() async -> Int64? { let requests = await notificationCenter.pendingNotificationRequests() - guard let trigger = requests.first?.trigger as? UNCalendarNotificationTrigger, - let nextDate = trigger.nextTriggerDate() else { + // 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 } - return Int64(nextDate.timeIntervalSince1970 * 1000) + 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) + */ + 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) + guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else { + // Fallback to simple 24-hour addition if calendar calculation fails + 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 + 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 + let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) + 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 { + // 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) + // Note: DailyNotificationFetcher integration deferred to Phase 2 + if fetcher != nil { + 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) + 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 { + // TODO: Phase 2 - Implement fetcher.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)") + } + } else { + NSLog("DNP-ROLLOVER: PREFETCH_SKIP id=\(content.id) fetcher_not_available") + print("DNP-ROLLOVER: PREFETCH_SKIP id=\(content.id) fetcher_not_available") + } + + // 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 + } } } diff --git a/ios/Plugin/DailyNotificationStorage.swift b/ios/Plugin/DailyNotificationStorage.swift index 2ef3c98..75c5969 100644 --- a/ios/Plugin/DailyNotificationStorage.swift +++ b/ios/Plugin/DailyNotificationStorage.swift @@ -151,6 +151,51 @@ class DailyNotificationStorage { } } + /** + * 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() + } + /** * Get notifications that are ready to be displayed * diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0ce8e0e..3ad50e5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,10 +1,10 @@ PODS: - - Capacitor (5.0.0): + - Capacitor (6.2.1): - CapacitorCordova - - CapacitorCordova (5.0.0) + - CapacitorCordova (6.2.1) - DailyNotificationPlugin (1.0.0): - - Capacitor (~> 5.0.0) - - CapacitorCordova (~> 5.0.0) + - Capacitor (>= 5.0.0) + - CapacitorCordova (>= 5.0.0) DEPENDENCIES: - "Capacitor (from `../node_modules/@capacitor/ios`)" @@ -20,9 +20,9 @@ EXTERNAL SOURCES: :path: "." SPEC CHECKSUMS: - Capacitor: ba8cd5cce13c6ab3c4faf7ef98487be481c9c1c8 - CapacitorCordova: 4ea17670ee562680988a7ce9db68dee5160fe564 - DailyNotificationPlugin: 745a0606d51baec6fc9a025f1de1ade125ed193a + Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf + CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff + DailyNotificationPlugin: bb72fde9eab3704a4e70af3c868a789da0650ddc PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c diff --git a/test-apps/ios-test-app/App/App/Public/index.html b/test-apps/ios-test-app/App/App/Public/index.html index 5f60ee0..8cb8cde 100644 --- a/test-apps/ios-test-app/App/App/Public/index.html +++ b/test-apps/ios-test-app/App/App/Public/index.html @@ -175,6 +175,15 @@ } window.DailyNotification.getNotificationStatus() .then(result => { + // Debug logging + console.log('getNotificationStatus result:', { + nextNotificationTime: result.nextNotificationTime, + pending: result.pending, + lastNotificationTime: result.lastNotificationTime, + rolloverEnabled: result.rolloverEnabled, + lastRolloverTime: result.lastRolloverTime + }); + const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled'; const hasSchedules = result.isEnabled || (result.pending && result.pending > 0); const statusIcon = hasSchedules ? '✅' : '⏸️'; @@ -417,12 +426,14 @@ } } - // Check for notification delivery periodically + // Check for notification delivery periodically and refresh status function checkNotificationDelivery() { if (!window.DailyNotification) return; window.DailyNotification.getNotificationStatus() .then(result => { + // Update notification received indicator + let notificationJustReceived = false; if (result.lastNotificationTime) { const lastTime = new Date(result.lastNotificationTime); const now = new Date(); @@ -434,6 +445,10 @@ const timeSpan = document.getElementById('notificationReceivedTime'); if (indicator && timeSpan) { + // Check if this is a new notification (indicator was hidden) + const wasHidden = indicator.style.display === 'none' || !indicator.style.display; + notificationJustReceived = wasHidden; + indicator.style.display = 'block'; timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`; @@ -444,6 +459,18 @@ } } } + + // Always refresh the plugin status display to show updated next notification time + // This ensures rollover changes are reflected in the UI + loadPluginStatus(); + + // If notification just received, refresh again after a short delay to catch rollover + if (notificationJustReceived) { + console.log('Notification just received, refreshing status again in 2 seconds to catch rollover...'); + setTimeout(() => { + loadPluginStatus(); + }, 2000); + } }) .catch(error => { // Silently fail - this is just for visual feedback @@ -459,8 +486,12 @@ loadPermissionStatus(); loadChannelStatus(); - // Check for notification delivery every 5 seconds + // Check for notification delivery and refresh status every 5 seconds setInterval(checkNotificationDelivery, 5000); + + // Also refresh plugin status periodically to catch rollover updates + // This ensures the UI stays in sync even if checkNotificationDelivery misses an update + setInterval(loadPluginStatus, 10000); // Every 10 seconds }, 500); }); diff --git a/test-apps/ios-test-app/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/test-apps/ios-test-app/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 0000000..ab9384d --- /dev/null +++ b/test-apps/ios-test-app/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-apps/ios-test-app/ios/App/App/AppDelegate.swift b/test-apps/ios-test-app/ios/App/App/AppDelegate.swift index 82fc8e6..97944f9 100644 --- a/test-apps/ios-test-app/ios/App/App/AppDelegate.swift +++ b/test-apps/ios-test-app/ios/App/App/AppDelegate.swift @@ -140,6 +140,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body) NSLog("DNP-DEBUG: Current delegate: %@", UNUserNotificationCenter.current().delegate != nil ? "SET" : "NOT SET") + // 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 { + + // Format scheduled time for logging + let scheduledDate = Date(timeIntervalSince1970: Double(scheduledTime) / 1000.0) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + let scheduledTimeStr = formatter.string(from: scheduledDate) + + NSLog("DNP-ROLLOVER: APPDELGATE_DETECTED id=%@ scheduled_time=%@", notificationId, scheduledTimeStr) + NSLog("DNP-DEBUG: Posted rollover notification for id=%@", notificationId) + + // Post notification to trigger rollover (decoupled pattern) + NotificationCenter.default.post( + name: NSNotification.Name("DailyNotificationDelivered"), + object: nil, + userInfo: [ + "notification_id": notificationId, + "scheduled_time": scheduledTime + ] + ) + } else { + NSLog("DNP-ROLLOVER: APPDELGATE_MISSING_DATA id=%@ userInfo=%@", notification.request.identifier, userInfo) + } + // Show notification with banner, sound, and badge // Use .banner for iOS 14+, fallback to .alert for iOS 13 if #available(iOS 14.0, *) {