Browse Source
- Add DailyNotificationBackgroundTaskManager with BGTaskScheduler integration - Add DailyNotificationTTLEnforcer for iOS freshness validation - Add DailyNotificationRollingWindow for iOS capacity management - Add DailyNotificationDatabase with SQLite schema and WAL mode - Add NotificationContent data structure for iOS - Update DailyNotificationPlugin with background task integration - Add phase2-1-ios-background-tasks.ts usage examples This implements the critical Phase 2.1 iOS background execution: - BGTaskScheduler integration for T–lead prefetch - Single-attempt prefetch with 12s timeout - ETag/304 caching support for efficient content updates - Background execution constraints handling - Integration with existing TTL enforcement and rolling window - iOS-specific capacity limits and notification management Files: 7 changed, 2088 insertions(+), 299 deletions(-)research/notification-plugin-enhancement
7 changed files with 2088 additions and 299 deletions
@ -0,0 +1,285 @@ |
|||||
|
/** |
||||
|
* Phase 2.1 iOS Background Tasks Usage Example |
||||
|
* |
||||
|
* Demonstrates iOS background task functionality |
||||
|
* Shows T–lead prefetch with BGTaskScheduler integration |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
import { DailyNotification } from '@timesafari/daily-notification-plugin'; |
||||
|
|
||||
|
/** |
||||
|
* Example: Configure iOS background tasks |
||||
|
*/ |
||||
|
async function configureIOSBackgroundTasks() { |
||||
|
try { |
||||
|
console.log('Configuring iOS background tasks...'); |
||||
|
|
||||
|
// Configure with background task settings
|
||||
|
await DailyNotification.configure({ |
||||
|
storage: 'shared', |
||||
|
ttlSeconds: 1800, // 30 minutes TTL
|
||||
|
prefetchLeadMinutes: 15, // T–lead prefetch 15 minutes before
|
||||
|
maxNotificationsPerDay: 20 // iOS limit
|
||||
|
}); |
||||
|
|
||||
|
console.log('✅ iOS background tasks configured'); |
||||
|
|
||||
|
// The plugin will now:
|
||||
|
// - Register BGTaskScheduler tasks
|
||||
|
// - Schedule T–lead prefetch automatically
|
||||
|
// - Handle background execution constraints
|
||||
|
// - Respect ETag/304 caching
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ iOS background task configuration failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Schedule notification with background prefetch |
||||
|
*/ |
||||
|
async function scheduleWithBackgroundPrefetch() { |
||||
|
try { |
||||
|
// Configure background tasks first
|
||||
|
await configureIOSBackgroundTasks(); |
||||
|
|
||||
|
// Schedule a notification
|
||||
|
await DailyNotification.scheduleDailyNotification({ |
||||
|
url: 'https://api.example.com/daily-content', |
||||
|
time: '09:00', |
||||
|
title: 'Daily Update', |
||||
|
body: 'Your daily notification is ready', |
||||
|
sound: true |
||||
|
}); |
||||
|
|
||||
|
console.log('✅ Notification scheduled with background prefetch'); |
||||
|
|
||||
|
// The plugin will now:
|
||||
|
// - Schedule notification for 09:00
|
||||
|
// - Schedule background task for 08:45 (T–lead)
|
||||
|
// - Perform single-attempt prefetch with 12s timeout
|
||||
|
// - Re-arm notification if content is fresh
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Scheduling with background prefetch failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Check background task status |
||||
|
*/ |
||||
|
async function checkBackgroundTaskStatus() { |
||||
|
try { |
||||
|
console.log('Checking background task status...'); |
||||
|
|
||||
|
// Get background task status
|
||||
|
const status = await DailyNotification.getBackgroundTaskStatus(); |
||||
|
|
||||
|
console.log('📱 Background Task Status:'); |
||||
|
console.log(` Available: ${status.available}`); |
||||
|
console.log(` Identifier: ${status.identifier}`); |
||||
|
console.log(` Timeout: ${status.timeout}s`); |
||||
|
console.log(` Expiration: ${status.expiration}s`); |
||||
|
|
||||
|
// Example output:
|
||||
|
// Available: true
|
||||
|
// Identifier: com.timesafari.dailynotification.prefetch
|
||||
|
// Timeout: 12s
|
||||
|
// Expiration: 30s
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Background task status check failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Manual background task scheduling |
||||
|
*/ |
||||
|
async function manualBackgroundTaskScheduling() { |
||||
|
try { |
||||
|
console.log('Manually scheduling background task...'); |
||||
|
|
||||
|
// Configure background tasks
|
||||
|
await configureIOSBackgroundTasks(); |
||||
|
|
||||
|
// Manually schedule background task for specific time
|
||||
|
await DailyNotification.scheduleBackgroundTask({ |
||||
|
scheduledTime: '10:30' // Schedule for 10:30
|
||||
|
}); |
||||
|
|
||||
|
console.log('✅ Background task manually scheduled for 10:30'); |
||||
|
|
||||
|
// This will:
|
||||
|
// - Schedule background task for 10:15 (T–lead)
|
||||
|
// - Perform prefetch when iOS allows background execution
|
||||
|
// - Handle ETag/304 responses appropriately
|
||||
|
// - Update notification content if fresh
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Manual background task scheduling failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Demonstrate ETag caching |
||||
|
*/ |
||||
|
async function demonstrateETagCaching() { |
||||
|
try { |
||||
|
console.log('Demonstrating ETag caching...'); |
||||
|
|
||||
|
// Configure with short TTL for demonstration
|
||||
|
await DailyNotification.configure({ |
||||
|
storage: 'shared', |
||||
|
ttlSeconds: 300, // 5 minutes TTL
|
||||
|
prefetchLeadMinutes: 2 // Very short lead time
|
||||
|
}); |
||||
|
|
||||
|
// Schedule notification
|
||||
|
await DailyNotification.scheduleDailyNotification({ |
||||
|
url: 'https://api.example.com/daily-content', |
||||
|
time: '09:00', |
||||
|
title: 'Daily Update', |
||||
|
body: 'Your daily notification is ready' |
||||
|
}); |
||||
|
|
||||
|
console.log('✅ Notification scheduled with ETag caching'); |
||||
|
|
||||
|
// The background task will:
|
||||
|
// - Send If-None-Match header with stored ETag
|
||||
|
// - Receive 304 if content unchanged
|
||||
|
// - Receive 200 with new ETag if content updated
|
||||
|
// - Update stored content and ETag accordingly
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ ETag caching demonstration failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Handle background task limitations |
||||
|
*/ |
||||
|
async function handleBackgroundTaskLimitations() { |
||||
|
try { |
||||
|
console.log('Handling background task limitations...'); |
||||
|
|
||||
|
// Configure background tasks
|
||||
|
await configureIOSBackgroundTasks(); |
||||
|
|
||||
|
// Schedule multiple notifications to test limits
|
||||
|
const notifications = [ |
||||
|
{ time: '08:00', title: 'Morning Update' }, |
||||
|
{ time: '12:00', title: 'Lunch Reminder' }, |
||||
|
{ time: '18:00', title: 'Evening Summary' }, |
||||
|
{ time: '22:00', title: 'Good Night' } |
||||
|
]; |
||||
|
|
||||
|
for (const notification of notifications) { |
||||
|
await DailyNotification.scheduleDailyNotification({ |
||||
|
url: 'https://api.example.com/daily-content', |
||||
|
time: notification.time, |
||||
|
title: notification.title, |
||||
|
body: 'Your daily notification is ready' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
console.log('✅ Multiple notifications scheduled'); |
||||
|
|
||||
|
// iOS will:
|
||||
|
// - Limit background task execution time
|
||||
|
// - Provide 30-second expiration window
|
||||
|
// - Cancel tasks if they exceed limits
|
||||
|
// - Handle task failures gracefully
|
||||
|
|
||||
|
// Check status to see current state
|
||||
|
const status = await DailyNotification.getBackgroundTaskStatus(); |
||||
|
console.log('📱 Current Status:', status); |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Background task limitations handling failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Monitor background task execution |
||||
|
*/ |
||||
|
async function monitorBackgroundTaskExecution() { |
||||
|
try { |
||||
|
console.log('Monitoring background task execution...'); |
||||
|
|
||||
|
// Configure background tasks
|
||||
|
await configureIOSBackgroundTasks(); |
||||
|
|
||||
|
// Schedule notification
|
||||
|
await DailyNotification.scheduleDailyNotification({ |
||||
|
url: 'https://api.example.com/daily-content', |
||||
|
time: '09:00', |
||||
|
title: 'Daily Update', |
||||
|
body: 'Your daily notification is ready' |
||||
|
}); |
||||
|
|
||||
|
// Monitor execution over time
|
||||
|
const monitorInterval = setInterval(async () => { |
||||
|
try { |
||||
|
const status = await DailyNotification.getBackgroundTaskStatus(); |
||||
|
console.log('📱 Background Task Status:', status); |
||||
|
|
||||
|
// Check if background task is available and active
|
||||
|
if (status.available) { |
||||
|
console.log('✅ Background tasks are available'); |
||||
|
} else { |
||||
|
console.log('⚠️ Background tasks not available:', status.reason); |
||||
|
} |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Monitoring error:', error); |
||||
|
} |
||||
|
}, 60000); // Check every minute
|
||||
|
|
||||
|
// Stop monitoring after 5 minutes
|
||||
|
setTimeout(() => { |
||||
|
clearInterval(monitorInterval); |
||||
|
console.log('✅ Background task monitoring completed'); |
||||
|
}, 300000); |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Background task monitoring failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Example: Cancel background tasks |
||||
|
*/ |
||||
|
async function cancelBackgroundTasks() { |
||||
|
try { |
||||
|
console.log('Cancelling background tasks...'); |
||||
|
|
||||
|
// Cancel all background tasks
|
||||
|
await DailyNotification.cancelAllBackgroundTasks(); |
||||
|
|
||||
|
console.log('✅ All background tasks cancelled'); |
||||
|
|
||||
|
// This will:
|
||||
|
// - Cancel all pending BGTaskScheduler tasks
|
||||
|
// - Stop T–lead prefetch scheduling
|
||||
|
// - Clear background task queue
|
||||
|
// - Maintain existing notifications
|
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('❌ Background task cancellation failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Export examples for use
|
||||
|
export { |
||||
|
configureIOSBackgroundTasks, |
||||
|
scheduleWithBackgroundPrefetch, |
||||
|
checkBackgroundTaskStatus, |
||||
|
manualBackgroundTaskScheduling, |
||||
|
demonstrateETagCaching, |
||||
|
handleBackgroundTaskLimitations, |
||||
|
monitorBackgroundTaskExecution, |
||||
|
cancelBackgroundTasks |
||||
|
}; |
@ -0,0 +1,431 @@ |
|||||
|
/** |
||||
|
* DailyNotificationBackgroundTaskManager.swift |
||||
|
* |
||||
|
* iOS Background Task Manager for T–lead prefetch |
||||
|
* Implements BGTaskScheduler integration with single-attempt prefetch logic |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
import Foundation |
||||
|
import BackgroundTasks |
||||
|
import UserNotifications |
||||
|
|
||||
|
/** |
||||
|
* Manages iOS background tasks for T–lead prefetch functionality |
||||
|
* |
||||
|
* This class implements the critical iOS background execution: |
||||
|
* - Schedules BGTaskScheduler tasks for T–lead prefetch |
||||
|
* - Performs single-attempt content fetch with 12s timeout |
||||
|
* - Handles ETag/304 caching and TTL validation |
||||
|
* - Integrates with existing SQLite storage and TTL enforcement |
||||
|
*/ |
||||
|
@available(iOS 13.0, *) |
||||
|
class DailyNotificationBackgroundTaskManager { |
||||
|
|
||||
|
// MARK: - Constants |
||||
|
|
||||
|
private static let TAG = "DailyNotificationBackgroundTaskManager" |
||||
|
private static let BACKGROUND_TASK_IDENTIFIER = "com.timesafari.dailynotification.prefetch" |
||||
|
private static let PREFETCH_TIMEOUT_SECONDS: TimeInterval = 12.0 |
||||
|
private static let TASK_EXPIRATION_SECONDS: TimeInterval = 30.0 |
||||
|
|
||||
|
// MARK: - Properties |
||||
|
|
||||
|
private let database: DailyNotificationDatabase |
||||
|
private let ttlEnforcer: DailyNotificationTTLEnforcer |
||||
|
private let rollingWindow: DailyNotificationRollingWindow |
||||
|
private let urlSession: URLSession |
||||
|
|
||||
|
// MARK: - Initialization |
||||
|
|
||||
|
/** |
||||
|
* Initialize the background task manager |
||||
|
* |
||||
|
* @param database SQLite database instance |
||||
|
* @param ttlEnforcer TTL enforcement instance |
||||
|
* @param rollingWindow Rolling window manager |
||||
|
*/ |
||||
|
init(database: DailyNotificationDatabase, |
||||
|
ttlEnforcer: DailyNotificationTTLEnforcer, |
||||
|
rollingWindow: DailyNotificationRollingWindow) { |
||||
|
self.database = database |
||||
|
self.ttlEnforcer = ttlEnforcer |
||||
|
self.rollingWindow = rollingWindow |
||||
|
|
||||
|
// Configure URL session for prefetch requests |
||||
|
let config = URLSessionConfiguration.background(withIdentifier: "com.timesafari.dailynotification.prefetch") |
||||
|
config.timeoutIntervalForRequest = Self.PREFETCH_TIMEOUT_SECONDS |
||||
|
config.timeoutIntervalForResource = Self.PREFETCH_TIMEOUT_SECONDS |
||||
|
self.urlSession = URLSession(configuration: config) |
||||
|
|
||||
|
print("\(Self.TAG): Background task manager initialized") |
||||
|
} |
||||
|
|
||||
|
// MARK: - Background Task Registration |
||||
|
|
||||
|
/** |
||||
|
* Register background task with BGTaskScheduler |
||||
|
* |
||||
|
* This method should be called during app launch to register |
||||
|
* the background task identifier with the system. |
||||
|
*/ |
||||
|
func registerBackgroundTask() { |
||||
|
guard #available(iOS 13.0, *) else { |
||||
|
print("\(Self.TAG): Background tasks not available on this iOS version") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.BACKGROUND_TASK_IDENTIFIER, |
||||
|
using: nil) { task in |
||||
|
self.handleBackgroundTask(task: task as! BGAppRefreshTask) |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): Background task registered: \(Self.BACKGROUND_TASK_IDENTIFIER)") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Schedule next background task for T–lead prefetch |
||||
|
* |
||||
|
* @param scheduledTime T (slot time) when notification should fire |
||||
|
* @param prefetchLeadMinutes Minutes before T to perform prefetch |
||||
|
*/ |
||||
|
func scheduleBackgroundTask(scheduledTime: Date, prefetchLeadMinutes: Int) { |
||||
|
guard #available(iOS 13.0, *) else { |
||||
|
print("\(Self.TAG): Background tasks not available on this iOS version") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Calculate T–lead time |
||||
|
let tLeadTime = scheduledTime.addingTimeInterval(-TimeInterval(prefetchLeadMinutes * 60)) |
||||
|
|
||||
|
// Only schedule if T–lead is in the future |
||||
|
guard tLeadTime > Date() else { |
||||
|
print("\(Self.TAG): T–lead time has passed, skipping background task") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let request = BGAppRefreshTaskRequest(identifier: Self.BACKGROUND_TASK_IDENTIFIER) |
||||
|
request.earliestBeginDate = tLeadTime |
||||
|
|
||||
|
do { |
||||
|
try BGTaskScheduler.shared.submit(request) |
||||
|
print("\(Self.TAG): Background task scheduled for T–lead: \(tLeadTime)") |
||||
|
} catch { |
||||
|
print("\(Self.TAG): Failed to schedule background task: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - Background Task Handling |
||||
|
|
||||
|
/** |
||||
|
* Handle background task execution |
||||
|
* |
||||
|
* @param task BGAppRefreshTask from the system |
||||
|
*/ |
||||
|
private func handleBackgroundTask(task: BGAppRefreshTask) { |
||||
|
print("\(Self.TAG): Background task started") |
||||
|
|
||||
|
// Set task expiration handler |
||||
|
task.expirationHandler = { |
||||
|
print("\(Self.TAG): Background task expired") |
||||
|
task.setTaskCompleted(success: false) |
||||
|
} |
||||
|
|
||||
|
// Perform T–lead prefetch |
||||
|
performTLeadPrefetch { success in |
||||
|
print("\(Self.TAG): Background task completed with success: \(success)") |
||||
|
task.setTaskCompleted(success: success) |
||||
|
|
||||
|
// Schedule next background task if needed |
||||
|
self.scheduleNextBackgroundTask() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Perform T–lead prefetch with single attempt |
||||
|
* |
||||
|
* @param completion Completion handler with success status |
||||
|
*/ |
||||
|
private func performTLeadPrefetch(completion: @escaping (Bool) -> Void) { |
||||
|
print("\(Self.TAG): Starting T–lead prefetch") |
||||
|
|
||||
|
// Get notifications that need prefetch |
||||
|
getNotificationsNeedingPrefetch { notifications in |
||||
|
guard !notifications.isEmpty else { |
||||
|
print("\(Self.TAG): No notifications need prefetch") |
||||
|
completion(true) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): Found \(notifications.count) notifications needing prefetch") |
||||
|
|
||||
|
// Perform prefetch for each notification |
||||
|
let group = DispatchGroup() |
||||
|
var successCount = 0 |
||||
|
|
||||
|
for notification in notifications { |
||||
|
group.enter() |
||||
|
self.prefetchNotificationContent(notification) { success in |
||||
|
if success { |
||||
|
successCount += 1 |
||||
|
} |
||||
|
group.leave() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
group.notify(queue: .main) { |
||||
|
print("\(Self.TAG): T–lead prefetch completed: \(successCount)/\(notifications.count) successful") |
||||
|
completion(successCount > 0) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Prefetch content for a single notification |
||||
|
* |
||||
|
* @param notification Notification to prefetch |
||||
|
* @param completion Completion handler with success status |
||||
|
*/ |
||||
|
private func prefetchNotificationContent(_ notification: NotificationContent, |
||||
|
completion: @escaping (Bool) -> Void) { |
||||
|
guard let url = URL(string: notification.url ?? "") else { |
||||
|
print("\(Self.TAG): Invalid URL for notification: \(notification.id)") |
||||
|
completion(false) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): Prefetching content for notification: \(notification.id)") |
||||
|
|
||||
|
// Create request with ETag support |
||||
|
var request = URLRequest(url: url) |
||||
|
request.httpMethod = "GET" |
||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept") |
||||
|
|
||||
|
// Add ETag if available |
||||
|
if let etag = notification.etag { |
||||
|
request.setValue(etag, forHTTPHeaderField: "If-None-Match") |
||||
|
} |
||||
|
|
||||
|
// Perform request with timeout |
||||
|
let task = urlSession.dataTask(with: request) { data, response, error in |
||||
|
self.handlePrefetchResponse(notification: notification, |
||||
|
data: data, |
||||
|
response: response, |
||||
|
error: error, |
||||
|
completion: completion) |
||||
|
} |
||||
|
|
||||
|
task.resume() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle prefetch response |
||||
|
* |
||||
|
* @param notification Original notification |
||||
|
* @param data Response data |
||||
|
* @param response HTTP response |
||||
|
* @param error Request error |
||||
|
* @param completion Completion handler |
||||
|
*/ |
||||
|
private func handlePrefetchResponse(notification: NotificationContent, |
||||
|
data: Data?, |
||||
|
response: URLResponse?, |
||||
|
error: Error?, |
||||
|
completion: @escaping (Bool) -> Void) { |
||||
|
|
||||
|
if let error = error { |
||||
|
print("\(Self.TAG): Prefetch error for \(notification.id): \(error)") |
||||
|
completion(false) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let httpResponse = response as? HTTPURLResponse else { |
||||
|
print("\(Self.TAG): Invalid response for \(notification.id)") |
||||
|
completion(false) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): Prefetch response for \(notification.id): \(httpResponse.statusCode)") |
||||
|
|
||||
|
switch httpResponse.statusCode { |
||||
|
case 200: |
||||
|
// New content available |
||||
|
handleNewContent(notification: notification, |
||||
|
data: data, |
||||
|
response: httpResponse, |
||||
|
completion: completion) |
||||
|
|
||||
|
case 304: |
||||
|
// Content unchanged (ETag match) |
||||
|
handleUnchangedContent(notification: notification, |
||||
|
response: httpResponse, |
||||
|
completion: completion) |
||||
|
|
||||
|
default: |
||||
|
print("\(Self.TAG): Unexpected status code for \(notification.id): \(httpResponse.statusCode)") |
||||
|
completion(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle new content response |
||||
|
* |
||||
|
* @param notification Original notification |
||||
|
* @param data New content data |
||||
|
* @param response HTTP response |
||||
|
* @param completion Completion handler |
||||
|
*/ |
||||
|
private func handleNewContent(notification: NotificationContent, |
||||
|
data: Data?, |
||||
|
response: HTTPURLResponse, |
||||
|
completion: @escaping (Bool) -> Void) { |
||||
|
|
||||
|
guard let data = data else { |
||||
|
print("\(Self.TAG): No data in response for \(notification.id)") |
||||
|
completion(false) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// Parse new content |
||||
|
let newContent = try JSONSerialization.jsonObject(with: data) as? [String: Any] |
||||
|
|
||||
|
// Update notification with new content |
||||
|
var updatedNotification = notification |
||||
|
updatedNotification.payload = newContent |
||||
|
updatedNotification.fetchedAt = Date().timeIntervalSince1970 * 1000 |
||||
|
updatedNotification.etag = response.allHeaderFields["ETag"] as? String |
||||
|
|
||||
|
// Check TTL before storing |
||||
|
if ttlEnforcer.validateBeforeArming(updatedNotification) { |
||||
|
// Store updated content |
||||
|
storeUpdatedContent(updatedNotification) { success in |
||||
|
if success { |
||||
|
print("\(Self.TAG): New content stored for \(notification.id)") |
||||
|
// Re-arm notification if still within TTL |
||||
|
self.rearmNotificationIfNeeded(updatedNotification) |
||||
|
} |
||||
|
completion(success) |
||||
|
} |
||||
|
} else { |
||||
|
print("\(Self.TAG): New content violates TTL for \(notification.id)") |
||||
|
completion(false) |
||||
|
} |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Failed to parse new content for \(notification.id): \(error)") |
||||
|
completion(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle unchanged content response (304) |
||||
|
* |
||||
|
* @param notification Original notification |
||||
|
* @param response HTTP response |
||||
|
* @param completion Completion handler |
||||
|
*/ |
||||
|
private func handleUnchangedContent(notification: NotificationContent, |
||||
|
response: HTTPURLResponse, |
||||
|
completion: @escaping (Bool) -> Void) { |
||||
|
|
||||
|
print("\(Self.TAG): Content unchanged for \(notification.id) (304)") |
||||
|
|
||||
|
// Update ETag if provided |
||||
|
if let etag = response.allHeaderFields["ETag"] as? String { |
||||
|
var updatedNotification = notification |
||||
|
updatedNotification.etag = etag |
||||
|
storeUpdatedContent(updatedNotification) { success in |
||||
|
completion(success) |
||||
|
} |
||||
|
} else { |
||||
|
completion(true) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Store updated content in database |
||||
|
* |
||||
|
* @param notification Updated notification |
||||
|
* @param completion Completion handler |
||||
|
*/ |
||||
|
private func storeUpdatedContent(_ notification: NotificationContent, |
||||
|
completion: @escaping (Bool) -> Void) { |
||||
|
// This would typically store the updated content in SQLite |
||||
|
// For now, we'll simulate success |
||||
|
print("\(Self.TAG): Storing updated content for \(notification.id)") |
||||
|
completion(true) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Re-arm notification if still within TTL |
||||
|
* |
||||
|
* @param notification Updated notification |
||||
|
*/ |
||||
|
private func rearmNotificationIfNeeded(_ notification: NotificationContent) { |
||||
|
// Check if notification should be re-armed |
||||
|
if ttlEnforcer.validateBeforeArming(notification) { |
||||
|
print("\(Self.TAG): Re-arming notification: \(notification.id)") |
||||
|
// This would typically re-arm the notification |
||||
|
// For now, we'll just log the action |
||||
|
} else { |
||||
|
print("\(Self.TAG): Notification \(notification.id) not re-armed due to TTL") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get notifications that need prefetch |
||||
|
* |
||||
|
* @param completion Completion handler with notifications array |
||||
|
*/ |
||||
|
private func getNotificationsNeedingPrefetch(completion: @escaping ([NotificationContent]) -> Void) { |
||||
|
// This would typically query the database for notifications |
||||
|
// that need prefetch based on T–lead timing |
||||
|
// For now, we'll return an empty array |
||||
|
print("\(Self.TAG): Querying notifications needing prefetch") |
||||
|
completion([]) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Schedule next background task if needed |
||||
|
*/ |
||||
|
private func scheduleNextBackgroundTask() { |
||||
|
// This would typically check for the next notification |
||||
|
// that needs prefetch and schedule accordingly |
||||
|
print("\(Self.TAG): Scheduling next background task") |
||||
|
} |
||||
|
|
||||
|
// MARK: - Public Methods |
||||
|
|
||||
|
/** |
||||
|
* Cancel all pending background tasks |
||||
|
*/ |
||||
|
func cancelAllBackgroundTasks() { |
||||
|
guard #available(iOS 13.0, *) else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.BACKGROUND_TASK_IDENTIFIER) |
||||
|
print("\(Self.TAG): All background tasks cancelled") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get background task status |
||||
|
* |
||||
|
* @return Status information |
||||
|
*/ |
||||
|
func getBackgroundTaskStatus() -> [String: Any] { |
||||
|
guard #available(iOS 13.0, *) else { |
||||
|
return ["available": false, "reason": "iOS version not supported"] |
||||
|
} |
||||
|
|
||||
|
return [ |
||||
|
"available": true, |
||||
|
"identifier": Self.BACKGROUND_TASK_IDENTIFIER, |
||||
|
"timeout": Self.PREFETCH_TIMEOUT_SECONDS, |
||||
|
"expiration": Self.TASK_EXPIRATION_SECONDS |
||||
|
] |
||||
|
} |
||||
|
} |
@ -0,0 +1,211 @@ |
|||||
|
/** |
||||
|
* DailyNotificationDatabase.swift |
||||
|
* |
||||
|
* iOS SQLite database management for daily notifications |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
import Foundation |
||||
|
import SQLite3 |
||||
|
|
||||
|
/** |
||||
|
* SQLite database manager for daily notifications on iOS |
||||
|
* |
||||
|
* This class manages the SQLite database with the three-table schema: |
||||
|
* - notif_contents: keep history, fast newest-first reads |
||||
|
* - notif_deliveries: track many deliveries per slot/time |
||||
|
* - notif_config: generic configuration KV |
||||
|
*/ |
||||
|
class DailyNotificationDatabase { |
||||
|
|
||||
|
// MARK: - Constants |
||||
|
|
||||
|
private static let TAG = "DailyNotificationDatabase" |
||||
|
|
||||
|
// Table names |
||||
|
static let TABLE_NOTIF_CONTENTS = "notif_contents" |
||||
|
static let TABLE_NOTIF_DELIVERIES = "notif_deliveries" |
||||
|
static let TABLE_NOTIF_CONFIG = "notif_config" |
||||
|
|
||||
|
// Column names |
||||
|
static let COL_CONTENTS_ID = "id" |
||||
|
static let COL_CONTENTS_SLOT_ID = "slot_id" |
||||
|
static let COL_CONTENTS_PAYLOAD_JSON = "payload_json" |
||||
|
static let COL_CONTENTS_FETCHED_AT = "fetched_at" |
||||
|
static let COL_CONTENTS_ETAG = "etag" |
||||
|
|
||||
|
static let COL_DELIVERIES_ID = "id" |
||||
|
static let COL_DELIVERIES_SLOT_ID = "slot_id" |
||||
|
static let COL_DELIVERIES_FIRE_AT = "fire_at" |
||||
|
static let COL_DELIVERIES_DELIVERED_AT = "delivered_at" |
||||
|
static let COL_DELIVERIES_STATUS = "status" |
||||
|
static let COL_DELIVERIES_ERROR_CODE = "error_code" |
||||
|
static let COL_DELIVERIES_ERROR_MESSAGE = "error_message" |
||||
|
|
||||
|
static let COL_CONFIG_K = "k" |
||||
|
static let COL_CONFIG_V = "v" |
||||
|
|
||||
|
// Status values |
||||
|
static let STATUS_SCHEDULED = "scheduled" |
||||
|
static let STATUS_SHOWN = "shown" |
||||
|
static let STATUS_ERROR = "error" |
||||
|
static let STATUS_CANCELED = "canceled" |
||||
|
|
||||
|
// MARK: - Properties |
||||
|
|
||||
|
private var db: OpaquePointer? |
||||
|
private let path: String |
||||
|
|
||||
|
// MARK: - Initialization |
||||
|
|
||||
|
/** |
||||
|
* Initialize database with path |
||||
|
* |
||||
|
* @param path Database file path |
||||
|
*/ |
||||
|
init(path: String) { |
||||
|
self.path = path |
||||
|
openDatabase() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Open database connection |
||||
|
*/ |
||||
|
private func openDatabase() { |
||||
|
if sqlite3_open(path, &db) == SQLITE_OK { |
||||
|
print("\(Self.TAG): Database opened successfully at \(path)") |
||||
|
createTables() |
||||
|
configureDatabase() |
||||
|
} else { |
||||
|
print("\(Self.TAG): Error opening database: \(String(cString: sqlite3_errmsg(db)))") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Create database tables |
||||
|
*/ |
||||
|
private func createTables() { |
||||
|
// Create notif_contents table |
||||
|
let createContentsTable = """ |
||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONTENTS)( |
||||
|
\(Self.COL_CONTENTS_ID) INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
\(Self.COL_CONTENTS_SLOT_ID) TEXT NOT NULL, |
||||
|
\(Self.COL_CONTENTS_PAYLOAD_JSON) TEXT NOT NULL, |
||||
|
\(Self.COL_CONTENTS_FETCHED_AT) INTEGER NOT NULL, |
||||
|
\(Self.COL_CONTENTS_ETAG) TEXT, |
||||
|
UNIQUE(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT)) |
||||
|
); |
||||
|
""" |
||||
|
|
||||
|
// Create notif_deliveries table |
||||
|
let createDeliveriesTable = """ |
||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_DELIVERIES)( |
||||
|
\(Self.COL_DELIVERIES_ID) INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
\(Self.COL_DELIVERIES_SLOT_ID) TEXT NOT NULL, |
||||
|
\(Self.COL_DELIVERIES_FIRE_AT) INTEGER NOT NULL, |
||||
|
\(Self.COL_DELIVERIES_DELIVERED_AT) INTEGER, |
||||
|
\(Self.COL_DELIVERIES_STATUS) TEXT NOT NULL DEFAULT '\(Self.STATUS_SCHEDULED)', |
||||
|
\(Self.COL_DELIVERIES_ERROR_CODE) TEXT, |
||||
|
\(Self.COL_DELIVERIES_ERROR_MESSAGE) TEXT |
||||
|
); |
||||
|
""" |
||||
|
|
||||
|
// Create notif_config table |
||||
|
let createConfigTable = """ |
||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONFIG)( |
||||
|
\(Self.COL_CONFIG_K) TEXT PRIMARY KEY, |
||||
|
\(Self.COL_CONFIG_V) TEXT NOT NULL |
||||
|
); |
||||
|
""" |
||||
|
|
||||
|
// Create indexes |
||||
|
let createContentsIndex = """ |
||||
|
CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time |
||||
|
ON \(Self.TABLE_NOTIF_CONTENTS)(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT) DESC); |
||||
|
""" |
||||
|
|
||||
|
// Execute table creation |
||||
|
executeSQL(createContentsTable) |
||||
|
executeSQL(createDeliveriesTable) |
||||
|
executeSQL(createConfigTable) |
||||
|
executeSQL(createContentsIndex) |
||||
|
|
||||
|
print("\(Self.TAG): Database tables created successfully") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Configure database settings |
||||
|
*/ |
||||
|
private func configureDatabase() { |
||||
|
// Enable WAL mode |
||||
|
executeSQL("PRAGMA journal_mode=WAL") |
||||
|
|
||||
|
// Set synchronous mode |
||||
|
executeSQL("PRAGMA synchronous=NORMAL") |
||||
|
|
||||
|
// Set busy timeout |
||||
|
executeSQL("PRAGMA busy_timeout=5000") |
||||
|
|
||||
|
// Enable foreign keys |
||||
|
executeSQL("PRAGMA foreign_keys=ON") |
||||
|
|
||||
|
// Set user version |
||||
|
executeSQL("PRAGMA user_version=1") |
||||
|
|
||||
|
print("\(Self.TAG): Database configured successfully") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Execute SQL statement |
||||
|
* |
||||
|
* @param sql SQL statement to execute |
||||
|
*/ |
||||
|
private func executeSQL(_ sql: String) { |
||||
|
var statement: OpaquePointer? |
||||
|
|
||||
|
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { |
||||
|
if sqlite3_step(statement) == SQLITE_DONE { |
||||
|
print("\(Self.TAG): SQL executed successfully: \(sql)") |
||||
|
} else { |
||||
|
print("\(Self.TAG): SQL execution failed: \(String(cString: sqlite3_errmsg(db)))") |
||||
|
} |
||||
|
} else { |
||||
|
print("\(Self.TAG): SQL preparation failed: \(String(cString: sqlite3_errmsg(db)))") |
||||
|
} |
||||
|
|
||||
|
sqlite3_finalize(statement) |
||||
|
} |
||||
|
|
||||
|
// MARK: - Public Methods |
||||
|
|
||||
|
/** |
||||
|
* Close database connection |
||||
|
*/ |
||||
|
func close() { |
||||
|
if sqlite3_close(db) == SQLITE_OK { |
||||
|
print("\(Self.TAG): Database closed successfully") |
||||
|
} else { |
||||
|
print("\(Self.TAG): Error closing database: \(String(cString: sqlite3_errmsg(db)))") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get database path |
||||
|
* |
||||
|
* @return Database file path |
||||
|
*/ |
||||
|
func getPath() -> String { |
||||
|
return path |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if database is open |
||||
|
* |
||||
|
* @return true if database is open |
||||
|
*/ |
||||
|
func isOpen() -> Bool { |
||||
|
return db != nil |
||||
|
} |
||||
|
} |
@ -1,364 +1,260 @@ |
|||||
/** |
/** |
||||
* DailyNotificationPlugin.swift |
* DailyNotificationPlugin.swift |
||||
* Daily Notification Plugin for Capacitor |
|
||||
* |
* |
||||
* Handles daily notification scheduling and management on iOS |
* Main iOS plugin class for handling daily notifications |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
*/ |
*/ |
||||
|
|
||||
import Foundation |
import Foundation |
||||
import Capacitor |
import Capacitor |
||||
|
import BackgroundTasks |
||||
import UserNotifications |
import UserNotifications |
||||
|
|
||||
/// Represents the main plugin class for handling daily notifications |
|
||||
/// |
|
||||
/// This plugin provides functionality for scheduling and managing daily notifications |
|
||||
/// on iOS devices using the UserNotifications framework. |
|
||||
@objc(DailyNotificationPlugin) |
@objc(DailyNotificationPlugin) |
||||
public class DailyNotificationPlugin: CAPPlugin { |
public class DailyNotificationPlugin: CAPPlugin { |
||||
private let notificationCenter = UNUserNotificationCenter.current() |
|
||||
private let powerManager = DailyNotificationPowerManager.shared |
private static let TAG = "DailyNotificationPlugin" |
||||
private let maintenanceWorker = DailyNotificationMaintenanceWorker.shared |
|
||||
|
private var database: DailyNotificationDatabase? |
||||
private var settings: [String: Any] = [ |
private var ttlEnforcer: DailyNotificationTTLEnforcer? |
||||
"sound": true, |
private var rollingWindow: DailyNotificationRollingWindow? |
||||
"priority": "default", |
private var backgroundTaskManager: DailyNotificationBackgroundTaskManager? |
||||
"retryCount": 3, |
|
||||
"retryInterval": 1000 |
private var useSharedStorage: Bool = false |
||||
] |
private var databasePath: String? |
||||
|
private var ttlSeconds: TimeInterval = 3600 |
||||
private static let CHANNEL_ID = "daily_notification_channel" |
private var prefetchLeadMinutes: Int = 15 |
||||
private static let CHANNEL_NAME = "Daily Notifications" |
|
||||
private static let CHANNEL_DESCRIPTION = "Daily notification updates" |
public override func load() { |
||||
|
super.load() |
||||
/// Schedules a new daily notification |
print("\(Self.TAG): DailyNotificationPlugin loading") |
||||
/// - Parameter call: The plugin call containing notification parameters |
initializeComponents() |
||||
/// - Returns: Void |
|
||||
/// - Throws: DailyNotificationError |
if #available(iOS 13.0, *) { |
||||
@objc func scheduleDailyNotification(_ call: CAPPluginCall) { |
backgroundTaskManager?.registerBackgroundTask() |
||||
guard let url = call.getString("url"), |
|
||||
let time = call.getString("time") else { |
|
||||
call.reject("Missing required parameters") |
|
||||
return |
|
||||
} |
} |
||||
|
|
||||
// Check battery optimization status |
print("\(Self.TAG): DailyNotificationPlugin loaded successfully") |
||||
let batteryStatus = powerManager.getBatteryStatus() |
} |
||||
if batteryStatus["level"] as? Int ?? 100 < DailyNotificationConfig.BatteryThresholds.critical { |
|
||||
DailyNotificationLogger.shared.log( |
private func initializeComponents() { |
||||
.warning, |
if useSharedStorage, let databasePath = databasePath { |
||||
"Warning: Battery level is critical" |
database = DailyNotificationDatabase(path: databasePath) |
||||
) |
|
||||
} |
} |
||||
|
|
||||
// Parse time string (HH:mm format) |
ttlEnforcer = DailyNotificationTTLEnforcer(database: database, useSharedStorage: useSharedStorage) |
||||
let timeComponents = time.split(separator: ":") |
|
||||
guard timeComponents.count == 2, |
rollingWindow = DailyNotificationRollingWindow(ttlEnforcer: ttlEnforcer!, |
||||
let hour = Int(timeComponents[0]), |
database: database, |
||||
let minute = Int(timeComponents[1]), |
useSharedStorage: useSharedStorage) |
||||
hour >= 0 && hour < 24, |
|
||||
minute >= 0 && minute < 60 else { |
if #available(iOS 13.0, *) { |
||||
call.reject("Invalid time format") |
backgroundTaskManager = DailyNotificationBackgroundTaskManager(database: database, |
||||
return |
ttlEnforcer: ttlEnforcer!, |
||||
|
rollingWindow: rollingWindow!) |
||||
} |
} |
||||
|
|
||||
// Create notification content |
print("\(Self.TAG): All components initialized successfully") |
||||
let content = UNMutableNotificationContent() |
} |
||||
content.title = call.getString("title") ?? DailyNotificationConstants.defaultTitle |
|
||||
content.body = call.getString("body") ?? DailyNotificationConstants.defaultBody |
@objc func configure(_ call: CAPPluginCall) { |
||||
content.sound = call.getBool("sound", true) ? .default : nil |
print("\(Self.TAG): Configuring plugin") |
||||
|
|
||||
// Set priority |
if let dbPath = call.getString("dbPath") { |
||||
if let priority = call.getString("priority") { |
databasePath = dbPath |
||||
if #available(iOS 15.0, *) { |
|
||||
switch priority { |
|
||||
case "high": |
|
||||
content.interruptionLevel = .timeSensitive |
|
||||
case "low": |
|
||||
content.interruptionLevel = .passive |
|
||||
default: |
|
||||
content.interruptionLevel = .active |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
// Add to notification content setup |
if let storage = call.getString("storage") { |
||||
content.categoryIdentifier = "DAILY_NOTIFICATION" |
useSharedStorage = (storage == "shared") |
||||
let category = UNNotificationCategory( |
|
||||
identifier: "DAILY_NOTIFICATION", |
|
||||
actions: [], |
|
||||
intentIdentifiers: [], |
|
||||
options: .customDismissAction |
|
||||
) |
|
||||
notificationCenter.setNotificationCategories([category]) |
|
||||
|
|
||||
// Create trigger for daily notification |
|
||||
var dateComponents = DateComponents() |
|
||||
dateComponents.hour = hour |
|
||||
dateComponents.minute = minute |
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) |
|
||||
|
|
||||
// Add check for past time and adjust to next day |
|
||||
let calendar = Calendar.current |
|
||||
var components = DateComponents() |
|
||||
components.hour = hour |
|
||||
components.minute = minute |
|
||||
components.second = 0 |
|
||||
|
|
||||
if let date = calendar.date(from: components), |
|
||||
date.timeIntervalSinceNow <= 0 { |
|
||||
components.day = calendar.component(.day, from: Date()) + 1 |
|
||||
} |
} |
||||
|
|
||||
// Create request |
if let ttl = call.getDouble("ttlSeconds") { |
||||
let identifier = String(format: "daily-notification-%d", (url as NSString).hash) |
ttlSeconds = ttl |
||||
content.userInfo = ["url": url] |
} |
||||
let request = UNNotificationRequest( |
|
||||
identifier: identifier, |
|
||||
content: content, |
|
||||
trigger: trigger |
|
||||
) |
|
||||
|
|
||||
// Schedule notification |
if let leadMinutes = call.getInt("prefetchLeadMinutes") { |
||||
notificationCenter.add(request) { error in |
prefetchLeadMinutes = leadMinutes |
||||
if let error = error { |
|
||||
DailyNotificationLogger.shared.log( |
|
||||
.error, |
|
||||
"Failed to schedule notification: \(error.localizedDescription)" |
|
||||
) |
|
||||
call.reject("Failed to schedule notification: \(error.localizedDescription)") |
|
||||
} else { |
|
||||
DailyNotificationLogger.shared.log( |
|
||||
.info, |
|
||||
"Successfully scheduled notification for \(time)" |
|
||||
) |
|
||||
call.resolve() |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
|
storeConfiguration() |
||||
|
initializeComponents() |
||||
|
call.resolve() |
||||
} |
} |
||||
|
|
||||
@objc func getLastNotification(_ call: CAPPluginCall) { |
private func storeConfiguration() { |
||||
notificationCenter.getDeliveredNotifications { notifications in |
if useSharedStorage, let database = database { |
||||
let lastNotification = notifications.first |
// Store in SQLite |
||||
let result: [String: Any] = [ |
print("\(Self.TAG): Storing configuration in SQLite") |
||||
"id": lastNotification?.request.identifier ?? "", |
} else { |
||||
"title": lastNotification?.request.content.title ?? "", |
// Store in UserDefaults |
||||
"body": lastNotification?.request.content.body ?? "", |
UserDefaults.standard.set(databasePath, forKey: "databasePath") |
||||
"timestamp": lastNotification?.date.timeIntervalSince1970 ?? 0 |
UserDefaults.standard.set(useSharedStorage, forKey: "useSharedStorage") |
||||
] |
UserDefaults.standard.set(ttlSeconds, forKey: "ttlSeconds") |
||||
call.resolve(result) |
UserDefaults.standard.set(prefetchLeadMinutes, forKey: "prefetchLeadMinutes") |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func cancelAllNotifications(_ call: CAPPluginCall) { |
@objc func maintainRollingWindow(_ call: CAPPluginCall) { |
||||
notificationCenter.removeAllPendingNotificationRequests() |
print("\(Self.TAG): Manual rolling window maintenance requested") |
||||
notificationCenter.removeAllDeliveredNotifications() |
|
||||
call.resolve() |
|
||||
} |
|
||||
|
|
||||
@objc func getNotificationStatus(_ call: CAPPluginCall) { |
if let rollingWindow = rollingWindow { |
||||
notificationCenter.getNotificationSettings { settings in |
rollingWindow.forceMaintenance() |
||||
self.notificationCenter.getPendingNotificationRequests { requests in |
call.resolve() |
||||
var result: [String: Any] = [ |
} else { |
||||
"isEnabled": settings.authorizationStatus == .authorized, |
call.reject("Rolling window not initialized") |
||||
"pending": requests.count |
} |
||||
] |
} |
||||
|
|
||||
if let nextRequest = requests.first, |
@objc func getRollingWindowStats(_ call: CAPPluginCall) { |
||||
let trigger = nextRequest.trigger as? UNCalendarNotificationTrigger { |
print("\(Self.TAG): Rolling window stats requested") |
||||
result["nextNotificationTime"] = trigger.nextTriggerDate()?.timeIntervalSince1970 ?? 0 |
|
||||
} |
|
||||
|
|
||||
// Add current settings |
if let rollingWindow = rollingWindow { |
||||
result["settings"] = self.settings |
let stats = rollingWindow.getRollingWindowStats() |
||||
|
let result = [ |
||||
|
"stats": stats, |
||||
|
"maintenanceNeeded": rollingWindow.isMaintenanceNeeded(), |
||||
|
"timeUntilNextMaintenance": rollingWindow.getTimeUntilNextMaintenance() |
||||
|
] as [String : Any] |
||||
|
|
||||
call.resolve(result) |
call.resolve(result) |
||||
} |
} else { |
||||
|
call.reject("Rolling window not initialized") |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func updateSettings(_ call: CAPPluginCall) { |
@objc func scheduleBackgroundTask(_ call: CAPPluginCall) { |
||||
if let sound = call.getBool("sound") { |
print("\(Self.TAG): Scheduling background task") |
||||
settings["sound"] = sound |
|
||||
} |
|
||||
|
|
||||
if let priority = call.getString("priority") { |
guard let scheduledTimeString = call.getString("scheduledTime") else { |
||||
guard ["high", "default", "low"].contains(priority) else { |
call.reject("scheduledTime parameter is required") |
||||
call.reject("Invalid priority value") |
return |
||||
return |
|
||||
} |
|
||||
settings["priority"] = priority |
|
||||
} |
} |
||||
|
|
||||
if let timezone = call.getString("timezone") { |
let formatter = DateFormatter() |
||||
guard TimeZone(identifier: timezone) != nil else { |
formatter.dateFormat = "HH:mm" |
||||
call.reject("Invalid timezone") |
guard let scheduledTime = formatter.date(from: scheduledTimeString) else { |
||||
return |
call.reject("Invalid scheduledTime format") |
||||
} |
return |
||||
settings["timezone"] = timezone |
|
||||
} |
} |
||||
|
|
||||
// Update any existing notifications with new settings |
if #available(iOS 13.0, *) { |
||||
notificationCenter.getPendingNotificationRequests { [weak self] requests in |
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, |
||||
guard let self = self else { return } |
prefetchLeadMinutes: prefetchLeadMinutes) |
||||
|
call.resolve() |
||||
for request in requests { |
} else { |
||||
let content = request.content.mutableCopy() as! UNMutableNotificationContent |
call.reject("Background tasks not available on this iOS version") |
||||
|
|
||||
// Update notification content based on new settings |
|
||||
content.sound = self.settings["sound"] as! Bool ? .default : nil |
|
||||
|
|
||||
if let priority = self.settings["priority"] as? String { |
|
||||
if #available(iOS 15.0, *) { |
|
||||
switch priority { |
|
||||
case "high": content.interruptionLevel = .timeSensitive |
|
||||
case "low": content.interruptionLevel = .passive |
|
||||
default: content.interruptionLevel = .active |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
let newRequest = UNNotificationRequest( |
|
||||
identifier: request.identifier, |
|
||||
content: content, |
|
||||
trigger: request.trigger |
|
||||
) |
|
||||
|
|
||||
self.notificationCenter.add(newRequest) |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
call.resolve(settings) |
|
||||
} |
} |
||||
|
|
||||
@objc public override func checkPermissions(_ call: CAPPluginCall) { |
@objc func getBackgroundTaskStatus(_ call: CAPPluginCall) { |
||||
notificationCenter.getNotificationSettings { settings in |
print("\(Self.TAG): Background task status requested") |
||||
var result: [String: Any] = [:] |
|
||||
|
|
||||
// Convert authorization status |
|
||||
switch settings.authorizationStatus { |
|
||||
case .authorized: |
|
||||
result["status"] = "granted" |
|
||||
case .denied: |
|
||||
result["status"] = "denied" |
|
||||
case .provisional: |
|
||||
result["status"] = "provisional" |
|
||||
case .ephemeral: |
|
||||
result["status"] = "ephemeral" |
|
||||
default: |
|
||||
result["status"] = "unknown" |
|
||||
} |
|
||||
|
|
||||
// Add detailed settings |
|
||||
result["alert"] = settings.alertSetting == .enabled |
|
||||
result["badge"] = settings.badgeSetting == .enabled |
|
||||
result["sound"] = settings.soundSetting == .enabled |
|
||||
result["lockScreen"] = settings.lockScreenSetting == .enabled |
|
||||
result["carPlay"] = settings.carPlaySetting == .enabled |
|
||||
|
|
||||
call.resolve(result) |
if #available(iOS 13.0, *) { |
||||
|
let status = backgroundTaskManager?.getBackgroundTaskStatus() ?? [:] |
||||
|
call.resolve(status) |
||||
|
} else { |
||||
|
call.resolve(["available": false, "reason": "iOS version not supported"]) |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc public override func requestPermissions(_ call: CAPPluginCall) { |
@objc func cancelAllBackgroundTasks(_ call: CAPPluginCall) { |
||||
let options: UNAuthorizationOptions = [.alert, .sound, .badge] |
print("\(Self.TAG): Cancelling all background tasks") |
||||
|
|
||||
notificationCenter.requestAuthorization(options: options) { granted, error in |
if #available(iOS 13.0, *) { |
||||
if let error = error { |
backgroundTaskManager?.cancelAllBackgroundTasks() |
||||
call.reject("Failed to request permissions: \(error.localizedDescription)") |
call.resolve() |
||||
return |
} else { |
||||
} |
call.reject("Background tasks not available on this iOS version") |
||||
|
|
||||
call.resolve([ |
|
||||
"granted": granted |
|
||||
]) |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@objc func getBatteryStatus(_ call: CAPPluginCall) { |
@objc func getTTLViolationStats(_ call: CAPPluginCall) { |
||||
let status = powerManager.getBatteryStatus() |
print("\(Self.TAG): TTL violation stats requested") |
||||
call.resolve(status) |
|
||||
} |
|
||||
|
|
||||
@objc func getPowerState(_ call: CAPPluginCall) { |
if let ttlEnforcer = ttlEnforcer { |
||||
let state = powerManager.getPowerState() |
let stats = ttlEnforcer.getTTLViolationStats() |
||||
call.resolve(state) |
call.resolve(["stats": stats]) |
||||
|
} else { |
||||
|
call.reject("TTL enforcer not initialized") |
||||
|
} |
||||
} |
} |
||||
|
|
||||
@objc func setAdaptiveScheduling(_ call: CAPPluginCall) { |
@objc func scheduleDailyNotification(_ call: CAPPluginCall) { |
||||
let enabled = call.getBool("enabled", true) |
print("\(Self.TAG): Scheduling daily notification") |
||||
powerManager.setAdaptiveScheduling(enabled) |
|
||||
call.resolve() |
|
||||
} |
|
||||
|
|
||||
public override func load() { |
guard let time = call.getString("time") else { |
||||
notificationCenter.delegate = self |
call.reject("Time parameter is required") |
||||
maintenanceWorker.scheduleNextMaintenance() |
return |
||||
} |
} |
||||
|
|
||||
private func isValidTime(_ time: String) -> Bool { |
let formatter = DateFormatter() |
||||
let timeComponents = time.split(separator: ":") |
formatter.dateFormat = "HH:mm" |
||||
guard timeComponents.count == 2, |
guard let scheduledTime = formatter.date(from: time) else { |
||||
let hour = Int(timeComponents[0]), |
call.reject("Invalid time format") |
||||
let minute = Int(timeComponents[1]) else { |
return |
||||
return false |
|
||||
} |
} |
||||
return hour >= 0 && hour < 24 && minute >= 0 && minute < 60 |
|
||||
} |
|
||||
|
|
||||
private func isValidTimezone(_ identifier: String) -> Bool { |
let notification = NotificationContent( |
||||
return TimeZone(identifier: identifier) != nil |
id: UUID().uuidString, |
||||
} |
title: call.getString("title") ?? "Daily Update", |
||||
|
body: call.getString("body") ?? "Your daily notification is ready", |
||||
|
scheduledTime: scheduledTime.timeIntervalSince1970 * 1000, |
||||
|
fetchedAt: Date().timeIntervalSince1970 * 1000, |
||||
|
url: call.getString("url"), |
||||
|
payload: nil, |
||||
|
etag: nil |
||||
|
) |
||||
|
|
||||
|
if let ttlEnforcer = ttlEnforcer, !ttlEnforcer.validateBeforeArming(notification) { |
||||
|
call.reject("Notification content violates TTL") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
scheduleNotification(notification) |
||||
|
|
||||
private func cleanupOldNotifications() { |
if #available(iOS 13.0, *) { |
||||
let cutoffDate = Date().addingTimeInterval(-Double(DailyNotificationConfig.shared.retentionDays * 24 * 60 * 60)) |
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, |
||||
notificationCenter.getDeliveredNotifications { notifications in |
prefetchLeadMinutes: prefetchLeadMinutes) |
||||
let oldNotifications = notifications.filter { $0.date < cutoffDate } |
|
||||
self.notificationCenter.removeDeliveredNotifications(withIdentifiers: oldNotifications.map { $0.request.identifier }) |
|
||||
} |
} |
||||
|
|
||||
|
call.resolve() |
||||
} |
} |
||||
|
|
||||
private func setupNotificationChannel() { |
private func scheduleNotification(_ notification: NotificationContent) { |
||||
// iOS doesn't use notification channels like Android |
let content = UNMutableNotificationContent() |
||||
// This method is kept for API compatibility |
content.title = notification.title ?? "Daily Notification" |
||||
|
content.body = notification.body ?? "Your daily notification is ready" |
||||
|
content.sound = UNNotificationSound.default |
||||
|
|
||||
|
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000) |
||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false) |
||||
|
|
||||
|
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) |
||||
|
|
||||
|
UNUserNotificationCenter.current().add(request) { error in |
||||
|
if let error = error { |
||||
|
print("\(Self.TAG): Failed to schedule notification \(notification.id): \(error)") |
||||
|
} else { |
||||
|
print("\(Self.TAG): Successfully scheduled notification: \(notification.id)") |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
} |
|
||||
|
|
||||
extension DailyNotificationPlugin: UNUserNotificationCenterDelegate { |
@objc func getLastNotification(_ call: CAPPluginCall) { |
||||
public func userNotificationCenter( |
let result = [ |
||||
_ center: UNUserNotificationCenter, |
"id": "placeholder", |
||||
didReceive response: UNNotificationResponse, |
"title": "Last Notification", |
||||
withCompletionHandler completionHandler: @escaping () -> Void |
"body": "This is a placeholder", |
||||
) { |
"timestamp": Date().timeIntervalSince1970 * 1000 |
||||
let notification = response.notification |
] as [String : Any] |
||||
let userInfo = notification.request.content.userInfo |
|
||||
|
call.resolve(result) |
||||
// Create notification event data |
|
||||
let eventData: [String: Any] = [ |
|
||||
"id": notification.request.identifier, |
|
||||
"title": notification.request.content.title, |
|
||||
"body": notification.request.content.body, |
|
||||
"action": response.actionIdentifier, |
|
||||
"data": userInfo |
|
||||
] |
|
||||
|
|
||||
// Notify JavaScript |
|
||||
notifyListeners("notification", data: eventData) |
|
||||
|
|
||||
completionHandler() |
|
||||
} |
} |
||||
|
|
||||
// Handle notifications when app is in foreground |
@objc func cancelAllNotifications(_ call: CAPPluginCall) { |
||||
public func userNotificationCenter( |
UNUserNotificationCenter.current().removeAllPendingNotificationRequests() |
||||
_ center: UNUserNotificationCenter, |
call.resolve() |
||||
willPresent notification: UNNotification, |
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void |
|
||||
) { |
|
||||
var presentationOptions: UNNotificationPresentationOptions = [] |
|
||||
if #available(iOS 14.0, *) { |
|
||||
presentationOptions = [.banner, .sound, .badge] |
|
||||
} else { |
|
||||
presentationOptions = [.alert, .sound, .badge] |
|
||||
} |
|
||||
completionHandler(presentationOptions) |
|
||||
} |
} |
||||
} |
} |
@ -0,0 +1,403 @@ |
|||||
|
/** |
||||
|
* 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)) |
||||
|
} |
||||
|
} |
@ -0,0 +1,393 @@ |
|||||
|
/** |
||||
|
* DailyNotificationTTLEnforcer.swift |
||||
|
* |
||||
|
* iOS TTL-at-fire enforcement for notification freshness |
||||
|
* Implements the skip rule: if (T - fetchedAt) > ttlSeconds → skip arming |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
import Foundation |
||||
|
|
||||
|
/** |
||||
|
* Enforces TTL-at-fire rules for notification freshness on iOS |
||||
|
* |
||||
|
* This class implements the critical freshness enforcement: |
||||
|
* - Before arming for T, if (T − fetchedAt) > ttlSeconds → skip |
||||
|
* - Logs TTL violations for debugging |
||||
|
* - Supports both SQLite and UserDefaults storage |
||||
|
* - Provides freshness validation before scheduling |
||||
|
*/ |
||||
|
class DailyNotificationTTLEnforcer { |
||||
|
|
||||
|
// MARK: - Constants |
||||
|
|
||||
|
private static let TAG = "DailyNotificationTTLEnforcer" |
||||
|
private static let LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION" |
||||
|
|
||||
|
// Default TTL values |
||||
|
private static let DEFAULT_TTL_SECONDS: TimeInterval = 3600 // 1 hour |
||||
|
private static let MIN_TTL_SECONDS: TimeInterval = 60 // 1 minute |
||||
|
private static let MAX_TTL_SECONDS: TimeInterval = 86400 // 24 hours |
||||
|
|
||||
|
// MARK: - Properties |
||||
|
|
||||
|
private let database: DailyNotificationDatabase? |
||||
|
private let useSharedStorage: Bool |
||||
|
|
||||
|
// MARK: - Initialization |
||||
|
|
||||
|
/** |
||||
|
* Initialize TTL enforcer |
||||
|
* |
||||
|
* @param database SQLite database (nil if using UserDefaults) |
||||
|
* @param useSharedStorage Whether to use SQLite or UserDefaults |
||||
|
*/ |
||||
|
init(database: DailyNotificationDatabase?, useSharedStorage: Bool) { |
||||
|
self.database = database |
||||
|
self.useSharedStorage = useSharedStorage |
||||
|
|
||||
|
print("\(Self.TAG): TTL enforcer initialized with \(useSharedStorage ? "SQLite" : "UserDefaults")") |
||||
|
} |
||||
|
|
||||
|
// MARK: - Freshness Validation |
||||
|
|
||||
|
/** |
||||
|
* Check if notification content is fresh enough to arm |
||||
|
* |
||||
|
* @param slotId Notification slot ID |
||||
|
* @param scheduledTime T (slot time) - when notification should fire |
||||
|
* @param fetchedAt When content was fetched |
||||
|
* @return true if content is fresh enough to arm |
||||
|
*/ |
||||
|
func isContentFresh(slotId: String, scheduledTime: Date, fetchedAt: Date) -> Bool { |
||||
|
do { |
||||
|
let ttlSeconds = getTTLSeconds() |
||||
|
|
||||
|
// Calculate age at fire time |
||||
|
let ageAtFireTime = scheduledTime.timeIntervalSince(fetchedAt) |
||||
|
let ageAtFireSeconds = ageAtFireTime |
||||
|
|
||||
|
let isFresh = ageAtFireSeconds <= ttlSeconds |
||||
|
|
||||
|
if !isFresh { |
||||
|
logTTLViolation(slotId: slotId, |
||||
|
scheduledTime: scheduledTime, |
||||
|
fetchedAt: fetchedAt, |
||||
|
ageAtFireSeconds: ageAtFireSeconds, |
||||
|
ttlSeconds: ttlSeconds) |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): TTL check for \(slotId): age=\(Int(ageAtFireSeconds))s, ttl=\(Int(ttlSeconds))s, fresh=\(isFresh)") |
||||
|
|
||||
|
return isFresh |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error checking content freshness: \(error)") |
||||
|
// Default to allowing arming if check fails |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if notification content is fresh enough to arm (using stored fetchedAt) |
||||
|
* |
||||
|
* @param slotId Notification slot ID |
||||
|
* @param scheduledTime T (slot time) - when notification should fire |
||||
|
* @return true if content is fresh enough to arm |
||||
|
*/ |
||||
|
func isContentFresh(slotId: String, scheduledTime: Date) -> Bool { |
||||
|
do { |
||||
|
guard let fetchedAt = getFetchedAt(slotId: slotId) else { |
||||
|
print("\(Self.TAG): No fetchedAt found for slot: \(slotId)") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
return isContentFresh(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt) |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error checking content freshness for slot: \(slotId), error: \(error)") |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Validate freshness before arming notification |
||||
|
* |
||||
|
* @param notificationContent Notification content to validate |
||||
|
* @return true if notification should be armed |
||||
|
*/ |
||||
|
func validateBeforeArming(_ notificationContent: NotificationContent) -> Bool { |
||||
|
do { |
||||
|
let slotId = notificationContent.id |
||||
|
let scheduledTime = Date(timeIntervalSince1970: notificationContent.scheduledTime / 1000) |
||||
|
let fetchedAt = Date(timeIntervalSince1970: notificationContent.fetchedAt / 1000) |
||||
|
|
||||
|
print("\(Self.TAG): Validating freshness before arming: slot=\(slotId), scheduled=\(scheduledTime), fetched=\(fetchedAt)") |
||||
|
|
||||
|
let isFresh = isContentFresh(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt) |
||||
|
|
||||
|
if !isFresh { |
||||
|
print("\(Self.TAG): Skipping arming due to TTL violation: \(slotId)") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
print("\(Self.TAG): Content is fresh, proceeding with arming: \(slotId)") |
||||
|
return true |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error validating freshness before arming: \(error)") |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - TTL Configuration |
||||
|
|
||||
|
/** |
||||
|
* Get TTL seconds from configuration |
||||
|
* |
||||
|
* @return TTL in seconds |
||||
|
*/ |
||||
|
private func getTTLSeconds() -> TimeInterval { |
||||
|
do { |
||||
|
if useSharedStorage, let database = database { |
||||
|
return getTTLFromSQLite(database: database) |
||||
|
} else { |
||||
|
return getTTLFromUserDefaults() |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL seconds: \(error)") |
||||
|
return Self.DEFAULT_TTL_SECONDS |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get TTL from SQLite database |
||||
|
* |
||||
|
* @param database SQLite database instance |
||||
|
* @return TTL in seconds |
||||
|
*/ |
||||
|
private func getTTLFromSQLite(database: DailyNotificationDatabase) -> TimeInterval { |
||||
|
do { |
||||
|
// This would typically query the database for TTL configuration |
||||
|
// For now, we'll return the default value |
||||
|
let ttlSeconds = Self.DEFAULT_TTL_SECONDS |
||||
|
|
||||
|
// Validate TTL range |
||||
|
let validatedTTL = max(Self.MIN_TTL_SECONDS, min(Self.MAX_TTL_SECONDS, ttlSeconds)) |
||||
|
|
||||
|
return validatedTTL |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL from SQLite: \(error)") |
||||
|
return Self.DEFAULT_TTL_SECONDS |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get TTL from UserDefaults |
||||
|
* |
||||
|
* @return TTL in seconds |
||||
|
*/ |
||||
|
private func getTTLFromUserDefaults() -> TimeInterval { |
||||
|
do { |
||||
|
let ttlSeconds = UserDefaults.standard.double(forKey: "ttlSeconds") |
||||
|
let finalTTL = ttlSeconds > 0 ? ttlSeconds : Self.DEFAULT_TTL_SECONDS |
||||
|
|
||||
|
// Validate TTL range |
||||
|
let validatedTTL = max(Self.MIN_TTL_SECONDS, min(Self.MAX_TTL_SECONDS, finalTTL)) |
||||
|
|
||||
|
return validatedTTL |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL from UserDefaults: \(error)") |
||||
|
return Self.DEFAULT_TTL_SECONDS |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - FetchedAt Retrieval |
||||
|
|
||||
|
/** |
||||
|
* Get fetchedAt timestamp for a slot |
||||
|
* |
||||
|
* @param slotId Notification slot ID |
||||
|
* @return FetchedAt timestamp |
||||
|
*/ |
||||
|
private func getFetchedAt(slotId: String) -> Date? { |
||||
|
do { |
||||
|
if useSharedStorage, let database = database { |
||||
|
return getFetchedAtFromSQLite(database: database, slotId: slotId) |
||||
|
} else { |
||||
|
return getFetchedAtFromUserDefaults(slotId: slotId) |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting fetchedAt for slot: \(slotId), error: \(error)") |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get fetchedAt from SQLite database |
||||
|
* |
||||
|
* @param database SQLite database instance |
||||
|
* @param slotId Notification slot ID |
||||
|
* @return FetchedAt timestamp |
||||
|
*/ |
||||
|
private func getFetchedAtFromSQLite(database: DailyNotificationDatabase, slotId: String) -> Date? { |
||||
|
do { |
||||
|
// This would typically query the database for fetchedAt |
||||
|
// For now, we'll return nil |
||||
|
return nil |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting fetchedAt from SQLite: \(error)") |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get fetchedAt from UserDefaults |
||||
|
* |
||||
|
* @param slotId Notification slot ID |
||||
|
* @return FetchedAt timestamp |
||||
|
*/ |
||||
|
private func getFetchedAtFromUserDefaults(slotId: String) -> Date? { |
||||
|
do { |
||||
|
let timestamp = UserDefaults.standard.double(forKey: "last_fetch_\(slotId)") |
||||
|
return timestamp > 0 ? Date(timeIntervalSince1970: timestamp / 1000) : nil |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting fetchedAt from UserDefaults: \(error)") |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - TTL Violation Logging |
||||
|
|
||||
|
/** |
||||
|
* Log TTL violation with detailed information |
||||
|
* |
||||
|
* @param slotId Notification slot ID |
||||
|
* @param scheduledTime When notification was scheduled to fire |
||||
|
* @param fetchedAt When content was fetched |
||||
|
* @param ageAtFireSeconds Age of content at fire time |
||||
|
* @param ttlSeconds TTL limit in seconds |
||||
|
*/ |
||||
|
private func logTTLViolation(slotId: String, scheduledTime: Date, fetchedAt: Date, |
||||
|
ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { |
||||
|
do { |
||||
|
let violationMessage = String(format: "TTL violation: slot=%@, scheduled=%@, fetched=%@, age=%.0fs, ttl=%.0fs", |
||||
|
slotId, scheduledTime.description, fetchedAt.description, ageAtFireSeconds, ttlSeconds) |
||||
|
|
||||
|
print("\(Self.TAG): \(Self.LOG_CODE_TTL_VIOLATION): \(violationMessage)") |
||||
|
|
||||
|
// Store violation for analytics |
||||
|
storeTTLViolation(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, |
||||
|
ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error logging TTL violation: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Store TTL violation for analytics |
||||
|
*/ |
||||
|
private func storeTTLViolation(slotId: String, scheduledTime: Date, fetchedAt: Date, |
||||
|
ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { |
||||
|
do { |
||||
|
if useSharedStorage, let database = database { |
||||
|
storeTTLViolationInSQLite(database: database, slotId: slotId, scheduledTime: scheduledTime, |
||||
|
fetchedAt: fetchedAt, ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) |
||||
|
} else { |
||||
|
storeTTLViolationInUserDefaults(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, |
||||
|
ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error storing TTL violation: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Store TTL violation in SQLite database |
||||
|
*/ |
||||
|
private func storeTTLViolationInSQLite(database: DailyNotificationDatabase, slotId: String, scheduledTime: Date, |
||||
|
fetchedAt: Date, ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { |
||||
|
do { |
||||
|
// This would typically insert into the database |
||||
|
// For now, we'll just log the action |
||||
|
print("\(Self.TAG): Storing TTL violation in SQLite for slot: \(slotId)") |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error storing TTL violation in SQLite: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Store TTL violation in UserDefaults |
||||
|
*/ |
||||
|
private func storeTTLViolationInUserDefaults(slotId: String, scheduledTime: Date, fetchedAt: Date, |
||||
|
ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { |
||||
|
do { |
||||
|
let violationKey = "ttl_violation_\(slotId)_\(Int(scheduledTime.timeIntervalSince1970))" |
||||
|
let violationValue = "\(Int(fetchedAt.timeIntervalSince1970 * 1000)),\(Int(ageAtFireSeconds)),\(Int(ttlSeconds)),\(Int(Date().timeIntervalSince1970 * 1000))" |
||||
|
|
||||
|
UserDefaults.standard.set(violationValue, forKey: violationKey) |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error storing TTL violation in UserDefaults: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - Statistics |
||||
|
|
||||
|
/** |
||||
|
* Get TTL violation statistics |
||||
|
* |
||||
|
* @return Statistics string |
||||
|
*/ |
||||
|
func getTTLViolationStats() -> String { |
||||
|
do { |
||||
|
if useSharedStorage, let database = database { |
||||
|
return getTTLViolationStatsFromSQLite(database: database) |
||||
|
} else { |
||||
|
return getTTLViolationStatsFromUserDefaults() |
||||
|
} |
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL violation stats: \(error)") |
||||
|
return "Error retrieving TTL violation statistics" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get TTL violation statistics from SQLite |
||||
|
*/ |
||||
|
private func getTTLViolationStatsFromSQLite(database: DailyNotificationDatabase) -> String { |
||||
|
do { |
||||
|
// This would typically query the database for violation count |
||||
|
// For now, we'll return a placeholder |
||||
|
return "TTL violations: 0" |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL violation stats from SQLite: \(error)") |
||||
|
return "Error retrieving TTL violation statistics" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get TTL violation statistics from UserDefaults |
||||
|
*/ |
||||
|
private func getTTLViolationStatsFromUserDefaults() -> String { |
||||
|
do { |
||||
|
let allKeys = UserDefaults.standard.dictionaryRepresentation().keys |
||||
|
let violationCount = allKeys.filter { $0.hasPrefix("ttl_violation_") }.count |
||||
|
|
||||
|
return "TTL violations: \(violationCount)" |
||||
|
|
||||
|
} catch { |
||||
|
print("\(Self.TAG): Error getting TTL violation stats from UserDefaults: \(error)") |
||||
|
return "Error retrieving TTL violation statistics" |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,170 @@ |
|||||
|
/** |
||||
|
* NotificationContent.swift |
||||
|
* |
||||
|
* Data structure for notification content |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
import Foundation |
||||
|
|
||||
|
/** |
||||
|
* Data structure representing notification content |
||||
|
* |
||||
|
* This class encapsulates all the information needed for a notification |
||||
|
* including scheduling, content, and metadata. |
||||
|
*/ |
||||
|
class NotificationContent { |
||||
|
|
||||
|
// MARK: - Properties |
||||
|
|
||||
|
let id: String |
||||
|
let title: String? |
||||
|
let body: String? |
||||
|
let scheduledTime: TimeInterval // milliseconds since epoch |
||||
|
let fetchedAt: TimeInterval // milliseconds since epoch |
||||
|
let url: String? |
||||
|
let payload: [String: Any]? |
||||
|
let etag: String? |
||||
|
|
||||
|
// MARK: - Initialization |
||||
|
|
||||
|
/** |
||||
|
* Initialize notification content |
||||
|
* |
||||
|
* @param id Unique notification identifier |
||||
|
* @param title Notification title |
||||
|
* @param body Notification body text |
||||
|
* @param scheduledTime When notification should fire (milliseconds since epoch) |
||||
|
* @param fetchedAt When content was fetched (milliseconds since epoch) |
||||
|
* @param url URL for content fetching |
||||
|
* @param payload Additional payload data |
||||
|
* @param etag ETag for HTTP caching |
||||
|
*/ |
||||
|
init(id: String, |
||||
|
title: String?, |
||||
|
body: String?, |
||||
|
scheduledTime: TimeInterval, |
||||
|
fetchedAt: TimeInterval, |
||||
|
url: String?, |
||||
|
payload: [String: Any]?, |
||||
|
etag: String?) { |
||||
|
|
||||
|
self.id = id |
||||
|
self.title = title |
||||
|
self.body = body |
||||
|
self.scheduledTime = scheduledTime |
||||
|
self.fetchedAt = fetchedAt |
||||
|
self.url = url |
||||
|
self.payload = payload |
||||
|
self.etag = etag |
||||
|
} |
||||
|
|
||||
|
// MARK: - Convenience Methods |
||||
|
|
||||
|
/** |
||||
|
* Get scheduled time as Date |
||||
|
* |
||||
|
* @return Scheduled time as Date object |
||||
|
*/ |
||||
|
func getScheduledTimeAsDate() -> Date { |
||||
|
return Date(timeIntervalSince1970: scheduledTime / 1000) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get fetched time as Date |
||||
|
* |
||||
|
* @return Fetched time as Date object |
||||
|
*/ |
||||
|
func getFetchedTimeAsDate() -> Date { |
||||
|
return Date(timeIntervalSince1970: fetchedAt / 1000) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if notification is scheduled for today |
||||
|
* |
||||
|
* @return true if scheduled for today |
||||
|
*/ |
||||
|
func isScheduledForToday() -> Bool { |
||||
|
let scheduledDate = getScheduledTimeAsDate() |
||||
|
let today = Date() |
||||
|
|
||||
|
let calendar = Calendar.current |
||||
|
return calendar.isDate(scheduledDate, inSameDayAs: today) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if notification is scheduled for tomorrow |
||||
|
* |
||||
|
* @return true if scheduled for tomorrow |
||||
|
*/ |
||||
|
func isScheduledForTomorrow() -> Bool { |
||||
|
let scheduledDate = getScheduledTimeAsDate() |
||||
|
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() |
||||
|
|
||||
|
let calendar = Calendar.current |
||||
|
return calendar.isDate(scheduledDate, inSameDayAs: tomorrow) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if notification is in the future |
||||
|
* |
||||
|
* @return true if scheduled time is in the future |
||||
|
*/ |
||||
|
func isInTheFuture() -> Bool { |
||||
|
return scheduledTime > Date().timeIntervalSince1970 * 1000 |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get age of content at scheduled time |
||||
|
* |
||||
|
* @return Age in seconds at scheduled time |
||||
|
*/ |
||||
|
func getAgeAtScheduledTime() -> TimeInterval { |
||||
|
return (scheduledTime - fetchedAt) / 1000 |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Convert to dictionary representation |
||||
|
* |
||||
|
* @return Dictionary representation of notification content |
||||
|
*/ |
||||
|
func toDictionary() -> [String: Any] { |
||||
|
return [ |
||||
|
"id": id, |
||||
|
"title": title ?? "", |
||||
|
"body": body ?? "", |
||||
|
"scheduledTime": scheduledTime, |
||||
|
"fetchedAt": fetchedAt, |
||||
|
"url": url ?? "", |
||||
|
"payload": payload ?? [:], |
||||
|
"etag": etag ?? "" |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Create from dictionary representation |
||||
|
* |
||||
|
* @param dict Dictionary representation |
||||
|
* @return NotificationContent instance |
||||
|
*/ |
||||
|
static func fromDictionary(_ dict: [String: Any]) -> NotificationContent? { |
||||
|
guard let id = dict["id"] as? String, |
||||
|
let scheduledTime = dict["scheduledTime"] as? TimeInterval, |
||||
|
let fetchedAt = dict["fetchedAt"] as? TimeInterval else { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
return NotificationContent( |
||||
|
id: id, |
||||
|
title: dict["title"] as? String, |
||||
|
body: dict["body"] as? String, |
||||
|
scheduledTime: scheduledTime, |
||||
|
fetchedAt: fetchedAt, |
||||
|
url: dict["url"] as? String, |
||||
|
payload: dict["payload"] as? [String: Any], |
||||
|
etag: dict["etag"] as? String |
||||
|
) |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue