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:
@@ -314,12 +314,251 @@ class DailyNotificationScheduler {
|
||||
func getNextNotificationTime() async -> Int64? {
|
||||
let requests = await notificationCenter.pendingNotificationRequests()
|
||||
|
||||
guard let trigger = requests.first?.trigger as? UNCalendarNotificationTrigger,
|
||||
let nextDate = trigger.nextTriggerDate() else {
|
||||
// Find the earliest scheduled notification by checking all requests
|
||||
var earliestDate: Date? = nil
|
||||
var earliestRequestId: String? = nil
|
||||
var allTimes: [(String, String)] = []
|
||||
|
||||
for request in requests {
|
||||
var requestTime: Date? = nil
|
||||
|
||||
if let trigger = request.trigger as? UNCalendarNotificationTrigger,
|
||||
let nextDate = trigger.nextTriggerDate() {
|
||||
requestTime = nextDate
|
||||
} else if let trigger = request.trigger as? UNTimeIntervalNotificationTrigger,
|
||||
let nextDate = trigger.nextTriggerDate() {
|
||||
requestTime = nextDate
|
||||
}
|
||||
|
||||
if let time = requestTime {
|
||||
let timeStr = formatTime(Int64(time.timeIntervalSince1970 * 1000))
|
||||
allTimes.append((request.identifier, timeStr))
|
||||
|
||||
if earliestDate == nil || time < earliestDate! {
|
||||
earliestDate = time
|
||||
earliestRequestId = request.identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let nextDate = earliestDate else {
|
||||
NSLog("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests")
|
||||
print("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests")
|
||||
return nil
|
||||
}
|
||||
|
||||
return Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||
let nextTimeStr = formatTime(nextTime)
|
||||
let allTimesStr = allTimes.map { "\($0.0):\($0.1)" }.joined(separator: ", ")
|
||||
NSLog("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]")
|
||||
print("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]")
|
||||
|
||||
return nextTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next scheduled time from current scheduled time (24 hours later, DST-safe)
|
||||
*
|
||||
* Matches Android calculateNextScheduledTime() functionality
|
||||
* Handles DST transitions automatically using Calendar
|
||||
*
|
||||
* @param currentScheduledTime Current scheduled time in milliseconds
|
||||
* @return Next scheduled time in milliseconds (24 hours later)
|
||||
*/
|
||||
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
|
||||
let calendar = Calendar.current
|
||||
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
|
||||
let currentTimeStr = formatTime(currentScheduledTime)
|
||||
|
||||
// Add 24 hours (handles DST transitions automatically)
|
||||
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
||||
// Fallback to simple 24-hour addition if calendar calculation fails
|
||||
let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000)
|
||||
let fallbackTimeStr = formatTime(fallbackTime)
|
||||
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
||||
print("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
||||
return fallbackTime
|
||||
}
|
||||
|
||||
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||
let nextTimeStr = formatTime(nextTime)
|
||||
|
||||
// Validate: Log DST transitions for debugging
|
||||
let currentHour = calendar.component(.hour, from: currentDate)
|
||||
let currentMinute = calendar.component(.minute, from: currentDate)
|
||||
let nextHour = calendar.component(.hour, from: nextDate)
|
||||
let nextMinute = calendar.component(.minute, from: nextDate)
|
||||
|
||||
if currentHour != nextHour || currentMinute != nextMinute {
|
||||
NSLog("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
||||
print("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
||||
}
|
||||
|
||||
// Log the calculation result
|
||||
let timeDiffMs = nextTime - currentScheduledTime
|
||||
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
||||
NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
||||
print("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
||||
|
||||
return nextTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next notification after current one fires (rollover)
|
||||
*
|
||||
* Matches Android scheduleNextNotification() functionality
|
||||
* Implements multi-level duplicate prevention
|
||||
*
|
||||
* @param content Current notification content that just fired
|
||||
* @param storage Storage instance for duplicate checking
|
||||
* @param fetcher Optional fetcher for scheduling prefetch (Phase 2)
|
||||
* @return true if next notification was scheduled successfully
|
||||
*/
|
||||
func scheduleNextNotification(
|
||||
_ content: NotificationContent,
|
||||
storage: DailyNotificationStorage?,
|
||||
fetcher: Any? = nil
|
||||
) async -> Bool {
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let currentTimeStr = formatTime(currentTime)
|
||||
let currentScheduledTimeStr = formatTime(content.scheduledTime)
|
||||
|
||||
NSLog("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)")
|
||||
print("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)")
|
||||
|
||||
// Check 1: Rollover state tracking (prevent duplicate rollover attempts)
|
||||
if let storage = storage {
|
||||
let lastRolloverTime = await storage.getLastRolloverTime(for: content.id)
|
||||
|
||||
// If rollover was processed recently (< 1 hour ago), skip
|
||||
if let lastTime = lastRolloverTime,
|
||||
(currentTime - lastTime) < (60 * 60 * 1000) {
|
||||
let lastTimeStr = formatTime(lastTime)
|
||||
let timeSinceRollover = (currentTime - lastTime) / 1000 / 60 // minutes
|
||||
NSLog("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago")
|
||||
print("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next occurrence using DST-safe calculation
|
||||
let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
|
||||
let nextScheduledTimeStr = formatTime(nextScheduledTime)
|
||||
let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0
|
||||
|
||||
NSLog("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))")
|
||||
print("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))")
|
||||
|
||||
// Check 2: Storage-level duplicate check (prevent duplicate notifications)
|
||||
if let storage = storage {
|
||||
let existingNotifications = storage.getAllNotifications()
|
||||
let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance for DST shifts
|
||||
|
||||
for existing in existingNotifications {
|
||||
if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs {
|
||||
let existingTimeStr = formatTime(existing.scheduledTime)
|
||||
let timeDiffMs = abs(existing.scheduledTime - nextScheduledTime)
|
||||
NSLog("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)")
|
||||
print("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)")
|
||||
return false // Skip rescheduling to prevent duplicate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: System-level duplicate check (query UNUserNotificationCenter)
|
||||
let pendingNotifications = await notificationCenter.pendingNotificationRequests()
|
||||
NSLog("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)")
|
||||
print("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)")
|
||||
|
||||
for pending in pendingNotifications {
|
||||
if let trigger = pending.trigger as? UNCalendarNotificationTrigger,
|
||||
let nextDate = trigger.nextTriggerDate() {
|
||||
let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
||||
let toleranceMs: Int64 = 60 * 1000
|
||||
|
||||
if abs(pendingTime - nextScheduledTime) <= toleranceMs {
|
||||
let pendingTimeStr = formatTime(pendingTime)
|
||||
let timeDiffMs = abs(pendingTime - nextScheduledTime)
|
||||
NSLog("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)")
|
||||
print("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract hour:minute from current scheduled time for logging
|
||||
let calendar = Calendar.current
|
||||
let scheduledDate = content.getScheduledTimeAsDate()
|
||||
let hour = calendar.component(.hour, from: scheduledDate)
|
||||
let minute = calendar.component(.minute, from: scheduledDate)
|
||||
|
||||
// Create new notification content for next occurrence
|
||||
// Note: Content will be refreshed by prefetch, but we need placeholder
|
||||
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
||||
let nextContent = NotificationContent(
|
||||
id: nextId,
|
||||
title: content.title, // Will be updated by prefetch
|
||||
body: content.body, // Will be updated by prefetch
|
||||
scheduledTime: nextScheduledTime,
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
url: content.url,
|
||||
payload: content.payload,
|
||||
etag: content.etag
|
||||
)
|
||||
|
||||
// Schedule the next notification
|
||||
NSLog("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
||||
print("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
||||
|
||||
let scheduled = await scheduleNotification(nextContent)
|
||||
|
||||
if scheduled {
|
||||
// Verify the notification was actually scheduled
|
||||
let pendingCount = await getPendingNotificationCount()
|
||||
let isScheduled = await isNotificationScheduled(id: nextId)
|
||||
|
||||
NSLog("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)")
|
||||
print("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)")
|
||||
|
||||
// Log time difference verification
|
||||
let timeDiffMs = nextScheduledTime - content.scheduledTime
|
||||
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
||||
NSLog("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
||||
print("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
||||
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
// Note: DailyNotificationFetcher integration deferred to Phase 2
|
||||
if fetcher != nil {
|
||||
let fetchTime = nextScheduledTime - (5 * 60 * 1000) // 5 minutes before
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
if fetchTime > currentTime {
|
||||
// TODO: Phase 2 - Implement fetcher.scheduleFetch(fetchTime)
|
||||
NSLog("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)")
|
||||
print("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)")
|
||||
} else {
|
||||
// TODO: Phase 2 - Implement fetcher.scheduleImmediateFetch()
|
||||
NSLog("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)")
|
||||
print("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)")
|
||||
}
|
||||
} else {
|
||||
NSLog("DNP-ROLLOVER: PREFETCH_SKIP id=\(content.id) fetcher_not_available")
|
||||
print("DNP-ROLLOVER: PREFETCH_SKIP id=\(content.id) fetcher_not_available")
|
||||
}
|
||||
|
||||
// Mark rollover as processed
|
||||
let rolloverProcessedTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
await storage?.saveLastRolloverTime(for: content.id, time: rolloverProcessedTime)
|
||||
|
||||
NSLog("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
||||
print("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
||||
|
||||
return true
|
||||
} else {
|
||||
NSLog("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)")
|
||||
print("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user