You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
403 lines
14 KiB
403 lines
14 KiB
/**
|
|
* DailyNotificationRollingWindow.swift
|
|
*
|
|
* iOS Rolling window safety for notification scheduling
|
|
* Ensures today's notifications are always armed and tomorrow's are armed within iOS caps
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
import Foundation
|
|
import UserNotifications
|
|
|
|
/**
|
|
* Manages rolling window safety for notification scheduling on iOS
|
|
*
|
|
* This class implements the critical rolling window logic:
|
|
* - Today's remaining notifications are always armed
|
|
* - Tomorrow's notifications are armed only if within iOS capacity limits
|
|
* - Automatic window maintenance as time progresses
|
|
* - iOS-specific capacity management
|
|
*/
|
|
class DailyNotificationRollingWindow {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let TAG = "DailyNotificationRollingWindow"
|
|
|
|
// iOS notification limits
|
|
private static let IOS_MAX_PENDING_NOTIFICATIONS = 64
|
|
private static let IOS_MAX_DAILY_NOTIFICATIONS = 20
|
|
|
|
// Window maintenance intervals
|
|
private static let WINDOW_MAINTENANCE_INTERVAL_SECONDS: TimeInterval = 15 * 60 // 15 minutes
|
|
|
|
// MARK: - Properties
|
|
|
|
private let ttlEnforcer: DailyNotificationTTLEnforcer
|
|
private let database: DailyNotificationDatabase?
|
|
private let useSharedStorage: Bool
|
|
|
|
// Window state
|
|
private var lastMaintenanceTime: Date = Date.distantPast
|
|
private var currentPendingCount: Int = 0
|
|
private var currentDailyCount: Int = 0
|
|
|
|
// MARK: - Initialization
|
|
|
|
/**
|
|
* Initialize rolling window manager
|
|
*
|
|
* @param ttlEnforcer TTL enforcement instance
|
|
* @param database SQLite database (nil if using UserDefaults)
|
|
* @param useSharedStorage Whether to use SQLite or UserDefaults
|
|
*/
|
|
init(ttlEnforcer: DailyNotificationTTLEnforcer,
|
|
database: DailyNotificationDatabase?,
|
|
useSharedStorage: Bool) {
|
|
self.ttlEnforcer = ttlEnforcer
|
|
self.database = database
|
|
self.useSharedStorage = useSharedStorage
|
|
|
|
print("\(Self.TAG): Rolling window initialized for iOS")
|
|
}
|
|
|
|
// MARK: - Window Maintenance
|
|
|
|
/**
|
|
* Maintain the rolling window by ensuring proper notification coverage
|
|
*
|
|
* This method should be called periodically to maintain the rolling window:
|
|
* - Arms today's remaining notifications
|
|
* - Arms tomorrow's notifications if within capacity limits
|
|
* - Updates window state and statistics
|
|
*/
|
|
func maintainRollingWindow() {
|
|
do {
|
|
let currentTime = Date()
|
|
|
|
// Check if maintenance is needed
|
|
if currentTime.timeIntervalSince(lastMaintenanceTime) < Self.WINDOW_MAINTENANCE_INTERVAL_SECONDS {
|
|
print("\(Self.TAG): Window maintenance not needed yet")
|
|
return
|
|
}
|
|
|
|
print("\(Self.TAG): Starting rolling window maintenance")
|
|
|
|
// Update current state
|
|
updateWindowState()
|
|
|
|
// Arm today's remaining notifications
|
|
armTodaysRemainingNotifications()
|
|
|
|
// Arm tomorrow's notifications if within capacity
|
|
armTomorrowsNotificationsIfWithinCapacity()
|
|
|
|
// Update maintenance time
|
|
lastMaintenanceTime = currentTime
|
|
|
|
print("\(Self.TAG): Rolling window maintenance completed: pending=\(currentPendingCount), daily=\(currentDailyCount)")
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error during rolling window maintenance: \(error)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Arm today's remaining notifications
|
|
*
|
|
* Ensures all notifications for today that haven't fired yet are armed
|
|
*/
|
|
private func armTodaysRemainingNotifications() {
|
|
do {
|
|
print("\(Self.TAG): Arming today's remaining notifications")
|
|
|
|
// Get today's date
|
|
let today = Date()
|
|
let todayDate = formatDate(today)
|
|
|
|
// Get all notifications for today
|
|
let todaysNotifications = getNotificationsForDate(todayDate)
|
|
|
|
var armedCount = 0
|
|
var skippedCount = 0
|
|
|
|
for notification in todaysNotifications {
|
|
// Check if notification is in the future
|
|
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000)
|
|
if scheduledTime > Date() {
|
|
|
|
// Check TTL before arming
|
|
if !ttlEnforcer.validateBeforeArming(notification) {
|
|
print("\(Self.TAG): Skipping today's notification due to TTL: \(notification.id)")
|
|
skippedCount += 1
|
|
continue
|
|
}
|
|
|
|
// Arm the notification
|
|
let armed = armNotification(notification)
|
|
if armed {
|
|
armedCount += 1
|
|
currentPendingCount += 1
|
|
} else {
|
|
print("\(Self.TAG): Failed to arm today's notification: \(notification.id)")
|
|
}
|
|
}
|
|
}
|
|
|
|
print("\(Self.TAG): Today's notifications: armed=\(armedCount), skipped=\(skippedCount)")
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error arming today's remaining notifications: \(error)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Arm tomorrow's notifications if within capacity limits
|
|
*
|
|
* Only arms tomorrow's notifications if we're within iOS capacity limits
|
|
*/
|
|
private func armTomorrowsNotificationsIfWithinCapacity() {
|
|
do {
|
|
print("\(Self.TAG): Checking capacity for tomorrow's notifications")
|
|
|
|
// Check if we're within capacity limits
|
|
if !isWithinCapacityLimits() {
|
|
print("\(Self.TAG): At capacity limit, skipping tomorrow's notifications")
|
|
return
|
|
}
|
|
|
|
// Get tomorrow's date
|
|
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
|
|
let tomorrowDate = formatDate(tomorrow)
|
|
|
|
// Get all notifications for tomorrow
|
|
let tomorrowsNotifications = getNotificationsForDate(tomorrowDate)
|
|
|
|
var armedCount = 0
|
|
var skippedCount = 0
|
|
|
|
for notification in tomorrowsNotifications {
|
|
// Check TTL before arming
|
|
if !ttlEnforcer.validateBeforeArming(notification) {
|
|
print("\(Self.TAG): Skipping tomorrow's notification due to TTL: \(notification.id)")
|
|
skippedCount += 1
|
|
continue
|
|
}
|
|
|
|
// Arm the notification
|
|
let armed = armNotification(notification)
|
|
if armed {
|
|
armedCount += 1
|
|
currentPendingCount += 1
|
|
currentDailyCount += 1
|
|
} else {
|
|
print("\(Self.TAG): Failed to arm tomorrow's notification: \(notification.id)")
|
|
}
|
|
|
|
// Check capacity after each arm
|
|
if !isWithinCapacityLimits() {
|
|
print("\(Self.TAG): Reached capacity limit while arming tomorrow's notifications")
|
|
break
|
|
}
|
|
}
|
|
|
|
print("\(Self.TAG): Tomorrow's notifications: armed=\(armedCount), skipped=\(skippedCount)")
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error arming tomorrow's notifications: \(error)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if we're within iOS capacity limits
|
|
*
|
|
* @return true if within limits
|
|
*/
|
|
private func isWithinCapacityLimits() -> Bool {
|
|
let withinPendingLimit = currentPendingCount < Self.IOS_MAX_PENDING_NOTIFICATIONS
|
|
let withinDailyLimit = currentDailyCount < Self.IOS_MAX_DAILY_NOTIFICATIONS
|
|
|
|
print("\(Self.TAG): Capacity check: pending=\(currentPendingCount)/\(Self.IOS_MAX_PENDING_NOTIFICATIONS), daily=\(currentDailyCount)/\(Self.IOS_MAX_DAILY_NOTIFICATIONS), within=\(withinPendingLimit && withinDailyLimit)")
|
|
|
|
return withinPendingLimit && withinDailyLimit
|
|
}
|
|
|
|
/**
|
|
* Update window state by counting current notifications
|
|
*/
|
|
private func updateWindowState() {
|
|
do {
|
|
print("\(Self.TAG): Updating window state")
|
|
|
|
// Count pending notifications
|
|
currentPendingCount = countPendingNotifications()
|
|
|
|
// Count today's notifications
|
|
let today = Date()
|
|
let todayDate = formatDate(today)
|
|
currentDailyCount = countNotificationsForDate(todayDate)
|
|
|
|
print("\(Self.TAG): Window state updated: pending=\(currentPendingCount), daily=\(currentDailyCount)")
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error updating window state: \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Management
|
|
|
|
/**
|
|
* Arm a notification using UNUserNotificationCenter
|
|
*
|
|
* @param notification Notification to arm
|
|
* @return true if successfully armed
|
|
*/
|
|
private func armNotification(_ notification: NotificationContent) -> Bool {
|
|
do {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = notification.title ?? "Daily Notification"
|
|
content.body = notification.body ?? "Your daily notification is ready"
|
|
content.sound = UNNotificationSound.default
|
|
|
|
// Create trigger for scheduled time
|
|
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000)
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false)
|
|
|
|
// Create request
|
|
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)
|
|
|
|
// Schedule notification
|
|
UNUserNotificationCenter.current().add(request) { error in
|
|
if let error = error {
|
|
print("\(Self.TAG): Failed to arm notification \(notification.id): \(error)")
|
|
} else {
|
|
print("\(Self.TAG): Successfully armed notification: \(notification.id)")
|
|
}
|
|
}
|
|
|
|
return true
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error arming notification \(notification.id): \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Access
|
|
|
|
/**
|
|
* Count pending notifications
|
|
*
|
|
* @return Number of pending notifications
|
|
*/
|
|
private func countPendingNotifications() -> Int {
|
|
do {
|
|
// This would typically query the storage for pending notifications
|
|
// For now, we'll use a placeholder implementation
|
|
return 0 // TODO: Implement actual counting logic
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error counting pending notifications: \(error)")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count notifications for a specific date
|
|
*
|
|
* @param date Date in YYYY-MM-DD format
|
|
* @return Number of notifications for the date
|
|
*/
|
|
private func countNotificationsForDate(_ date: String) -> Int {
|
|
do {
|
|
// This would typically query the storage for notifications on a specific date
|
|
// For now, we'll use a placeholder implementation
|
|
return 0 // TODO: Implement actual counting logic
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error counting notifications for date: \(date), error: \(error)")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get notifications for a specific date
|
|
*
|
|
* @param date Date in YYYY-MM-DD format
|
|
* @return List of notifications for the date
|
|
*/
|
|
private func getNotificationsForDate(_ date: String) -> [NotificationContent] {
|
|
do {
|
|
// This would typically query the storage for notifications on a specific date
|
|
// For now, we'll return an empty array
|
|
return [] // TODO: Implement actual retrieval logic
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error getting notifications for date: \(date), error: \(error)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format date as YYYY-MM-DD
|
|
*
|
|
* @param date Date to format
|
|
* @return Formatted date string
|
|
*/
|
|
private func formatDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/**
|
|
* Get rolling window statistics
|
|
*
|
|
* @return Statistics string
|
|
*/
|
|
func getRollingWindowStats() -> String {
|
|
do {
|
|
return String(format: "Rolling window stats: pending=%d/%d, daily=%d/%d, platform=iOS",
|
|
currentPendingCount, Self.IOS_MAX_PENDING_NOTIFICATIONS,
|
|
currentDailyCount, Self.IOS_MAX_DAILY_NOTIFICATIONS)
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error getting rolling window stats: \(error)")
|
|
return "Error retrieving rolling window statistics"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force window maintenance (for testing or manual triggers)
|
|
*/
|
|
func forceMaintenance() {
|
|
print("\(Self.TAG): Forcing rolling window maintenance")
|
|
lastMaintenanceTime = Date.distantPast // Reset maintenance time
|
|
maintainRollingWindow()
|
|
}
|
|
|
|
/**
|
|
* Check if window maintenance is needed
|
|
*
|
|
* @return true if maintenance is needed
|
|
*/
|
|
func isMaintenanceNeeded() -> Bool {
|
|
let currentTime = Date()
|
|
return currentTime.timeIntervalSince(lastMaintenanceTime) >= Self.WINDOW_MAINTENANCE_INTERVAL_SECONDS
|
|
}
|
|
|
|
/**
|
|
* Get time until next maintenance
|
|
*
|
|
* @return Seconds until next maintenance
|
|
*/
|
|
func getTimeUntilNextMaintenance() -> TimeInterval {
|
|
let currentTime = Date()
|
|
let nextMaintenanceTime = lastMaintenanceTime.addingTimeInterval(Self.WINDOW_MAINTENANCE_INTERVAL_SECONDS)
|
|
return max(0, nextMaintenanceTime.timeIntervalSince(currentTime))
|
|
}
|
|
}
|
|
|