Files
daily-notification-plugin/ios/Plugin/DailyNotificationScheduler.swift
Jose Olarte III d8a0eaf413 refactor(android,ios): rename package com.timesafari to org.timesafari.dailynotification
- Android: move plugin source to org/timesafari/dailynotification, update
  namespace, manifest package, and all package/imports; change intent actions
  to org.timesafari.daily.NOTIFICATION and DISMISS
- iOS: update bundle IDs, BGTask identifiers, subsystem labels, and queue
  names in Plugin and Xcode projects
- Capacitor: update plugin class registration and appIds in configs
- Test apps (android-test-app, daily-notification-test, ios-test-app):
  applicationId/bundleId, manifests, ProGuard, scripts, and docs
- Docs: bulk update references; add CONSUMING_APP_MIGRATION_COM_TO_ORG.md
  for consuming app migration

BREAKING CHANGE: Consuming apps must update plugin class to
org.timesafari.dailynotification.DailyNotificationPlugin, manifest
receivers/actions, and iOS BGTask identifiers per migration doc.
2026-03-12 14:26:07 +08:00

579 lines
25 KiB
Swift

/**
* DailyNotificationScheduler.swift
*
* Handles scheduling and timing of daily notifications using UNUserNotificationCenter
* Implements calendar-based triggers with timing tolerance (±180s) and permission auto-healing
*
* @author Matthew Raymer
* @version 1.0.0
*/
import Foundation
import UserNotifications
/**
* Protocol for scheduling background fetches
*/
protocol DailyNotificationFetchScheduling {
func scheduleFetch(atMillis: Int64)
func scheduleImmediateFetch()
}
/**
* No-op implementation for when fetcher is not available
*/
final class NoopFetcherScheduler: DailyNotificationFetchScheduling {
func scheduleFetch(atMillis: Int64) { /* intentionally noop */ }
func scheduleImmediateFetch() { /* intentionally noop */ }
}
/**
* Manages scheduling of daily notifications using UNUserNotificationCenter
*
* This class handles the scheduling aspect of the prefetch cache schedule display pipeline.
* It supports calendar-based triggers with iOS timing tolerance (±180s).
*/
class DailyNotificationScheduler {
// MARK: - Constants
private static let TAG = "DailyNotificationScheduler"
private static let NOTIFICATION_CATEGORY_ID = "DAILY_NOTIFICATION"
private static let TIMING_TOLERANCE_SECONDS: TimeInterval = 180 // ±180 seconds tolerance
// MARK: - Properties
private let notificationCenter: UNUserNotificationCenter
private var scheduledNotifications: Set<String> = []
private let schedulerQueue = DispatchQueue(label: "org.timesafari.dailynotification.scheduler", attributes: .concurrent)
// TTL enforcement
private weak var ttlEnforcer: DailyNotificationTTLEnforcer?
// Fetch scheduling
private let fetchScheduler: DailyNotificationFetchScheduling
// MARK: - Initialization
/**
* Initialize scheduler
*
* @param fetchScheduler Optional fetch scheduler (defaults to NoopFetcherScheduler)
*/
init(fetchScheduler: DailyNotificationFetchScheduling = NoopFetcherScheduler()) {
self.notificationCenter = UNUserNotificationCenter.current()
self.fetchScheduler = fetchScheduler
setupNotificationCategory()
}
/**
* Set TTL enforcer for freshness validation
*
* @param ttlEnforcer TTL enforcement instance
*/
func setTTLEnforcer(_ ttlEnforcer: DailyNotificationTTLEnforcer) {
self.ttlEnforcer = ttlEnforcer
print("\(Self.TAG): TTL enforcer set for freshness validation")
}
// MARK: - Notification Category Setup
/**
* Setup notification category for actions
*/
private func setupNotificationCategory() {
let category = UNNotificationCategory(
identifier: Self.NOTIFICATION_CATEGORY_ID,
actions: [],
intentIdentifiers: [],
options: []
)
notificationCenter.setNotificationCategories([category])
print("\(Self.TAG): Notification category setup complete")
}
// MARK: - Permission Management
/**
* Check notification permission status
*
* @return Authorization status
*/
func checkPermissionStatus() async -> UNAuthorizationStatus {
let settings = await notificationCenter.notificationSettings()
return settings.authorizationStatus
}
/**
* Request notification permissions
*
* @return true if permissions granted
*/
func requestPermissions() async -> Bool {
do {
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
print("\(Self.TAG): Permission request result: \(granted)")
return granted
} catch {
print("\(Self.TAG): Permission request failed: \(error)")
return false
}
}
/**
* Auto-heal permissions: Check and request if needed
*
* @return Authorization status after auto-healing
*/
func autoHealPermissions() async -> UNAuthorizationStatus {
let status = await checkPermissionStatus()
switch status {
case .notDetermined:
// Request permissions
let granted = await requestPermissions()
return granted ? .authorized : .denied
case .denied:
// Cannot auto-heal denied permissions
return .denied
case .authorized, .provisional, .ephemeral:
return status
@unknown default:
return .notDetermined
}
}
// MARK: - Scheduling
/**
* Schedule a notification for delivery
*
* @param content Notification content to schedule
* @return true if scheduling was successful
*/
func scheduleNotification(_ content: NotificationContent) async -> Bool {
do {
print("\(Self.TAG): Scheduling notification: \(content.id)")
// Permission auto-healing
let permissionStatus = await autoHealPermissions()
if permissionStatus != .authorized && permissionStatus != .provisional {
print("\(Self.TAG): Notifications denied, cannot schedule")
// Log error code for debugging
print("\(Self.TAG): Error code: \(DailyNotificationErrorCodes.NOTIFICATIONS_DENIED)")
return false
}
// TTL validation before arming
if let ttlEnforcer = ttlEnforcer {
let okToArm = ttlEnforcer.validateBeforeArming(content)
if !okToArm {
print("\(Self.TAG): TTL validation failed, skipping schedule for \(content.id)")
return false
}
}
// Cancel any existing notification for this ID
await cancelNotification(id: content.id)
// Create notification content
let notificationContent = UNMutableNotificationContent()
notificationContent.title = content.title ?? "Daily Update"
notificationContent.body = content.body ?? "Your daily notification is ready"
notificationContent.sound = .default
notificationContent.categoryIdentifier = Self.NOTIFICATION_CATEGORY_ID
notificationContent.userInfo = [
"notification_id": content.id,
"scheduled_time": content.scheduledTime,
"fetched_at": content.fetchedAt
]
// Create calendar trigger for daily scheduling
let scheduledDate = content.getScheduledTimeAsDate()
let calendar = Calendar.current
let dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledDate)
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: false
)
// Create notification request
let request = UNNotificationRequest(
identifier: content.id,
content: notificationContent,
trigger: trigger
)
// Schedule notification
try await notificationCenter.add(request)
schedulerQueue.async(flags: .barrier) {
self.scheduledNotifications.insert(content.id)
}
// Log pending count for test scripts (matches Android's alarm count logging)
// Use NSLog to ensure it appears in system logs (print() may not always be captured)
let pendingCount = await getPendingNotificationCount()
NSLog("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)")
print("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)")
return true
} catch {
print("\(Self.TAG): Error scheduling notification: \(error)")
return false
}
}
/**
* Cancel a notification by ID
*
* @param id Notification ID
*/
func cancelNotification(id: String) async {
notificationCenter.removePendingNotificationRequests(withIdentifiers: [id])
schedulerQueue.async(flags: .barrier) {
self.scheduledNotifications.remove(id)
}
print("\(Self.TAG): Notification cancelled: \(id)")
}
/**
* Cancel all scheduled notifications
*/
func cancelAllNotifications() async {
notificationCenter.removeAllPendingNotificationRequests()
schedulerQueue.async(flags: .barrier) {
self.scheduledNotifications.removeAll()
}
print("\(Self.TAG): All notifications cancelled")
}
// MARK: - Status Queries
/**
* Get pending notification requests
*
* @return Array of pending notification identifiers
*/
func getPendingNotifications() async -> [String] {
let requests = await notificationCenter.pendingNotificationRequests()
return requests.map { $0.identifier }
}
/**
* Get notification status
*
* @param id Notification ID
* @return true if notification is scheduled
*/
func isNotificationScheduled(id: String) async -> Bool {
let requests = await notificationCenter.pendingNotificationRequests()
return requests.contains { $0.identifier == id }
}
/**
* Get count of pending notifications
*
* @return Count of pending notifications
*/
func getPendingNotificationCount() async -> Int {
let requests = await notificationCenter.pendingNotificationRequests()
return requests.count
}
// MARK: - Helper Methods
/**
* 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)
}
/**
* Calculate next occurrence of a daily time
*
* Matches Android calculateNextOccurrence() functionality
*
* @param hour Hour of day (0-23)
* @param minute Minute of hour (0-59)
* @return Timestamp in milliseconds of next occurrence
*/
func calculateNextOccurrence(hour: Int, minute: Int) -> Int64 {
let calendar = Calendar.current
let now = Date()
var components = calendar.dateComponents([.year, .month, .day], from: now)
components.hour = hour
components.minute = minute
components.second = 0
var scheduledDate = calendar.date(from: components) ?? now
// If time has passed today, schedule for tomorrow
if scheduledDate <= now {
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
}
return Int64(scheduledDate.timeIntervalSince1970 * 1000)
}
/**
* Get next notification time from pending notifications
*
* @return Timestamp in milliseconds of next notification or nil
*/
func getNextNotificationTime() async -> Int64? {
let requests = await notificationCenter.pendingNotificationRequests()
// 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
}
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 (24h or rollover interval minutes). DST-safe.
* When rolloverIntervalMinutes > 0 (dev/testing), adds that many minutes; otherwise adds 24 hours.
*/
func calculateNextScheduledTime(_ currentScheduledTime: Int64, rolloverIntervalMinutes: Int? = nil) -> Int64 {
let calendar = Calendar.current
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
let currentTimeStr = formatTime(currentScheduledTime)
let addMinutes = (rolloverIntervalMinutes ?? 0) > 0 ? rolloverIntervalMinutes! : (24 * 60)
guard let nextDate = calendar.date(byAdding: .minute, value: addMinutes, to: currentDate) else {
let fallbackTime = currentScheduledTime + (Int64(addMinutes) * 60 * 1000)
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback add_minutes=\(addMinutes)")
return fallbackTime
}
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
let nextTimeStr = formatTime(nextTime)
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)) rollover_min=\(rolloverIntervalMinutes ?? 0)")
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
// TESTING: Change `(60 * 60 * 1000)` to `(60 * 1000)` for 1-minute threshold when testing with 2-minute intervals
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 (use stored rollover interval for dev/testing, else 24h)
let rolloverMin = (content.rolloverIntervalMinutes ?? 0) > 0 ? content.rolloverIntervalMinutes : nil
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime, rolloverIntervalMinutes: rolloverMin)
// If next scheduled time is in the past, keep calculating forward until we get a future time
// This handles cases where the notification fired more than 2 minutes ago
while nextScheduledTime < currentTime {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime, rolloverIntervalMinutes: rolloverMin)
}
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,
body: content.body,
scheduledTime: nextScheduledTime,
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: content.url,
payload: content.payload,
etag: content.etag,
rolloverIntervalMinutes: content.rolloverIntervalMinutes
)
// 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 {
// Save notification content to storage so it can be retrieved when rollover fires
// This is critical: without saving, processRollover won't find the content
storage?.saveNotificationContent(nextContent)
NSLog("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
print("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
// 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)
let fetchTime = nextScheduledTime - (5 * 60 * 1000) // 5 minutes before
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
if fetchTime > currentTime {
print("\(Self.TAG): scheduling fetch at \(fetchTime)")
fetchScheduler.scheduleFetch(atMillis: 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 {
print("\(Self.TAG): scheduling immediate fetch")
fetchScheduler.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)")
}
// 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
}
}
}