fix(ios): correct next notification time and improve rollover UI refresh
- Fix getNextNotificationTime() to find earliest scheduled notification instead of using first request (pendingNotificationRequests doesn't guarantee order) - Add comprehensive logging for rollover tracking with DNP-ROLLOVER prefix for Xcode console filtering - Reset all notifications and rollover state when scheduling new notification via scheduleDailyNotification() to ensure clean test state - Fix userInfo scope error in handleNotificationDelivery error handler - Update test app UI to refresh status every 5-10 seconds and immediately after notification delivery to reflect rollover changes - Add console logging in UI to debug getNotificationStatus() results This ensures the UI correctly displays the next notification time after rollover completes, and test notifications start with a clean slate.
This commit is contained in:
649
docs/ios-rollover-edge-case-plan.md
Normal file
649
docs/ios-rollover-edge-case-plan.md
Normal file
@@ -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<String> = []
|
||||||
|
private let scheduler: DailyNotificationScheduler
|
||||||
|
private let storage: DailyNotificationStorage
|
||||||
|
|
||||||
|
func processRollover(for notificationId: String) async -> Bool {
|
||||||
|
// Check if already processing
|
||||||
|
if processingNotifications.contains(notificationId) {
|
||||||
|
print("RolloverCoordinator: Already processing \(notificationId)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processing
|
||||||
|
processingNotifications.insert(notificationId)
|
||||||
|
defer {
|
||||||
|
processingNotifications.remove(notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform rollover
|
||||||
|
// ... (rollover logic)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Architecture
|
||||||
|
|
||||||
|
### Component 1: TimeChangeDetector
|
||||||
|
|
||||||
|
**Purpose**: Detect time changes and trigger recovery
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Store last known system time
|
||||||
|
- Compare on app launch/background tasks
|
||||||
|
- Detect significant time jumps
|
||||||
|
- Trigger time change recovery
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationTimeChangeDetector.swift`
|
||||||
|
|
||||||
|
### Component 2: TimezoneChangeDetector
|
||||||
|
|
||||||
|
**Purpose**: Detect timezone changes and trigger recalculation
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Store current timezone
|
||||||
|
- Compare on access
|
||||||
|
- Detect timezone changes
|
||||||
|
- Trigger timezone change recovery
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationTimezoneChangeDetector.swift`
|
||||||
|
|
||||||
|
### Component 3: RolloverCoordinator
|
||||||
|
|
||||||
|
**Purpose**: Coordinate rollover operations with duplicate prevention
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Manage rollover state
|
||||||
|
- Prevent duplicate rollovers
|
||||||
|
- Coordinate multiple detection mechanisms
|
||||||
|
- Handle race conditions
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationRolloverCoordinator.swift`
|
||||||
|
|
||||||
|
### Component 4: Enhanced Recovery Manager
|
||||||
|
|
||||||
|
**Purpose**: Extend existing recovery manager with time/timezone change handling
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Integrate time change detection
|
||||||
|
- Integrate timezone change detection
|
||||||
|
- Coordinate with rollover coordinator
|
||||||
|
- Handle all edge cases
|
||||||
|
|
||||||
|
**Location**: `ios/Plugin/DailyNotificationReactivationManager.swift` (enhance existing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Test Category 1: Time Changes
|
||||||
|
|
||||||
|
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
|
||||||
633
docs/ios-rollover-implementation-review.md
Normal file
633
docs/ios-rollover-implementation-review.md
Normal file
@@ -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`
|
||||||
343
docs/ios-rollover-open-questions-answers.md
Normal file
343
docs/ios-rollover-open-questions-answers.md
Normal file
@@ -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`
|
||||||
BIN
ios/App/App.xcworkspace/xcuserdata/aardimus.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
BIN
ios/App/App.xcworkspace/xcuserdata/aardimus.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
Binary file not shown.
@@ -72,6 +72,14 @@ public class DailyNotificationPlugin: CAPPlugin {
|
|||||||
// Perform recovery on app launch (async, non-blocking)
|
// Perform recovery on app launch (async, non-blocking)
|
||||||
reactivationManager?.performRecovery()
|
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")
|
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
|
||||||
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
|
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)
|
// Store notification content via state actor (thread-safe)
|
||||||
Task {
|
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 #available(iOS 13.0, *) {
|
||||||
if let stateActor = await self.stateActor {
|
if let stateActor = await self.stateActor {
|
||||||
await stateActor.saveNotificationContent(content)
|
await stateActor.saveNotificationContent(content)
|
||||||
@@ -1226,12 +1266,17 @@ public class DailyNotificationPlugin: CAPPlugin {
|
|||||||
// Calculate next notification time
|
// Calculate next notification time
|
||||||
let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0
|
let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0
|
||||||
|
|
||||||
|
// Get rollover status
|
||||||
|
let lastRolloverTime = storage?.getLastRolloverTime() ?? 0
|
||||||
|
|
||||||
var result: [String: Any] = [
|
var result: [String: Any] = [
|
||||||
"isEnabled": isEnabled,
|
"isEnabled": isEnabled,
|
||||||
"isScheduled": pendingCount > 0,
|
"isScheduled": pendingCount > 0,
|
||||||
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
|
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
|
||||||
"nextNotificationTime": nextNotificationTime,
|
"nextNotificationTime": nextNotificationTime,
|
||||||
"pending": pendingCount,
|
"pending": pendingCount,
|
||||||
|
"rolloverEnabled": true, // Indicate rollover is active
|
||||||
|
"lastRolloverTime": lastRolloverTime, // When last rollover occurred
|
||||||
"settings": settings
|
"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
|
* Check permission status
|
||||||
* Returns boolean flags for each permission type
|
* Returns boolean flags for each permission type
|
||||||
|
|||||||
@@ -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
|
// Record recovery in history
|
||||||
let result = RecoveryResult(
|
let result = RecoveryResult(
|
||||||
missedCount: missedCount,
|
missedCount: missedCount,
|
||||||
@@ -1004,6 +1008,94 @@ class DailyNotificationReactivationManager {
|
|||||||
// Don't throw - this is best effort
|
// 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
|
// MARK: - Supporting Types
|
||||||
|
|||||||
@@ -314,12 +314,251 @@ class DailyNotificationScheduler {
|
|||||||
func getNextNotificationTime() async -> Int64? {
|
func getNextNotificationTime() async -> Int64? {
|
||||||
let requests = await notificationCenter.pendingNotificationRequests()
|
let requests = await notificationCenter.pendingNotificationRequests()
|
||||||
|
|
||||||
guard let trigger = requests.first?.trigger as? UNCalendarNotificationTrigger,
|
// Find the earliest scheduled notification by checking all requests
|
||||||
let nextDate = trigger.nextTriggerDate() else {
|
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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Get notifications that are ready to be displayed
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Capacitor (5.0.0):
|
- Capacitor (6.2.1):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorCordova (5.0.0)
|
- CapacitorCordova (6.2.1)
|
||||||
- DailyNotificationPlugin (1.0.0):
|
- DailyNotificationPlugin (1.0.0):
|
||||||
- Capacitor (~> 5.0.0)
|
- Capacitor (>= 5.0.0)
|
||||||
- CapacitorCordova (~> 5.0.0)
|
- CapacitorCordova (>= 5.0.0)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- "Capacitor (from `../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../node_modules/@capacitor/ios`)"
|
||||||
@@ -20,9 +20,9 @@ EXTERNAL SOURCES:
|
|||||||
:path: "."
|
:path: "."
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: ba8cd5cce13c6ab3c4faf7ef98487be481c9c1c8
|
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
||||||
CapacitorCordova: 4ea17670ee562680988a7ce9db68dee5160fe564
|
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
|
||||||
DailyNotificationPlugin: 745a0606d51baec6fc9a025f1de1ade125ed193a
|
DailyNotificationPlugin: bb72fde9eab3704a4e70af3c868a789da0650ddc
|
||||||
|
|
||||||
PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c
|
PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,15 @@
|
|||||||
}
|
}
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.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 nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
|
||||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||||
@@ -417,12 +426,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for notification delivery periodically
|
// Check for notification delivery periodically and refresh status
|
||||||
function checkNotificationDelivery() {
|
function checkNotificationDelivery() {
|
||||||
if (!window.DailyNotification) return;
|
if (!window.DailyNotification) return;
|
||||||
|
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
// Update notification received indicator
|
||||||
|
let notificationJustReceived = false;
|
||||||
if (result.lastNotificationTime) {
|
if (result.lastNotificationTime) {
|
||||||
const lastTime = new Date(result.lastNotificationTime);
|
const lastTime = new Date(result.lastNotificationTime);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -434,6 +445,10 @@
|
|||||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
const timeSpan = document.getElementById('notificationReceivedTime');
|
||||||
|
|
||||||
if (indicator && timeSpan) {
|
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';
|
indicator.style.display = 'block';
|
||||||
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`;
|
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 => {
|
.catch(error => {
|
||||||
// Silently fail - this is just for visual feedback
|
// Silently fail - this is just for visual feedback
|
||||||
@@ -459,8 +486,12 @@
|
|||||||
loadPermissionStatus();
|
loadPermissionStatus();
|
||||||
loadChannelStatus();
|
loadChannelStatus();
|
||||||
|
|
||||||
// Check for notification delivery every 5 seconds
|
// Check for notification delivery and refresh status every 5 seconds
|
||||||
setInterval(checkNotificationDelivery, 5000);
|
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);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "0920"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
|
BuildableName = "App.app"
|
||||||
|
BlueprintName = "App"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
|
BuildableName = "App.app"
|
||||||
|
BlueprintName = "App"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "504EC3031FED79650016851F"
|
||||||
|
BuildableName = "App.app"
|
||||||
|
BlueprintName = "App"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -140,6 +140,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||||||
NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body)
|
NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body)
|
||||||
NSLog("DNP-DEBUG: Current delegate: %@", UNUserNotificationCenter.current().delegate != nil ? "SET" : "NOT SET")
|
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
|
// Show notification with banner, sound, and badge
|
||||||
// Use .banner for iOS 14+, fallback to .alert for iOS 13
|
// Use .banner for iOS 14+, fallback to .alert for iOS 13
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
|
|||||||
Reference in New Issue
Block a user