From 5eebae95562a75fa7bc2a9bad186d280865e4c21 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Mon, 8 Sep 2025 10:30:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(ios):=20implement=20Phase=202.1=20iOS=20ba?= =?UTF-8?q?ckground=20tasks=20with=20T=E2=80=93lead=20prefetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(-) --- examples/phase2-1-ios-background-tasks.ts | 285 ++++++++++ ...ilyNotificationBackgroundTaskManager.swift | 431 +++++++++++++++ ios/Plugin/DailyNotificationDatabase.swift | 211 ++++++++ ios/Plugin/DailyNotificationPlugin.swift | 494 +++++++----------- .../DailyNotificationRollingWindow.swift | 403 ++++++++++++++ ios/Plugin/DailyNotificationTTLEnforcer.swift | 393 ++++++++++++++ ios/Plugin/NotificationContent.swift | 170 ++++++ 7 files changed, 2088 insertions(+), 299 deletions(-) create mode 100644 examples/phase2-1-ios-background-tasks.ts create mode 100644 ios/Plugin/DailyNotificationBackgroundTaskManager.swift create mode 100644 ios/Plugin/DailyNotificationDatabase.swift create mode 100644 ios/Plugin/DailyNotificationRollingWindow.swift create mode 100644 ios/Plugin/DailyNotificationTTLEnforcer.swift create mode 100644 ios/Plugin/NotificationContent.swift diff --git a/examples/phase2-1-ios-background-tasks.ts b/examples/phase2-1-ios-background-tasks.ts new file mode 100644 index 0000000..41be197 --- /dev/null +++ b/examples/phase2-1-ios-background-tasks.ts @@ -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 +}; diff --git a/ios/Plugin/DailyNotificationBackgroundTaskManager.swift b/ios/Plugin/DailyNotificationBackgroundTaskManager.swift new file mode 100644 index 0000000..b83d2d5 --- /dev/null +++ b/ios/Plugin/DailyNotificationBackgroundTaskManager.swift @@ -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 + ] + } +} diff --git a/ios/Plugin/DailyNotificationDatabase.swift b/ios/Plugin/DailyNotificationDatabase.swift new file mode 100644 index 0000000..caaefa6 --- /dev/null +++ b/ios/Plugin/DailyNotificationDatabase.swift @@ -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 + } +} diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index cd51ee5..faf7561 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -1,364 +1,260 @@ /** * 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 Capacitor +import BackgroundTasks 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) public class DailyNotificationPlugin: CAPPlugin { - private let notificationCenter = UNUserNotificationCenter.current() - private let powerManager = DailyNotificationPowerManager.shared - private let maintenanceWorker = DailyNotificationMaintenanceWorker.shared - private var settings: [String: Any] = [ - "sound": true, - "priority": "default", - "retryCount": 3, - "retryInterval": 1000 - ] + private static let TAG = "DailyNotificationPlugin" - private static let CHANNEL_ID = "daily_notification_channel" - private static let CHANNEL_NAME = "Daily Notifications" - private static let CHANNEL_DESCRIPTION = "Daily notification updates" + private var database: DailyNotificationDatabase? + private var ttlEnforcer: DailyNotificationTTLEnforcer? + private var rollingWindow: DailyNotificationRollingWindow? + private var backgroundTaskManager: DailyNotificationBackgroundTaskManager? - /// Schedules a new daily notification - /// - Parameter call: The plugin call containing notification parameters - /// - Returns: Void - /// - Throws: DailyNotificationError - @objc func scheduleDailyNotification(_ call: CAPPluginCall) { - guard let url = call.getString("url"), - let time = call.getString("time") else { - call.reject("Missing required parameters") - return - } + private var useSharedStorage: Bool = false + private var databasePath: String? + private var ttlSeconds: TimeInterval = 3600 + private var prefetchLeadMinutes: Int = 15 + + public override func load() { + super.load() + print("\(Self.TAG): DailyNotificationPlugin loading") + initializeComponents() - // Check battery optimization status - let batteryStatus = powerManager.getBatteryStatus() - if batteryStatus["level"] as? Int ?? 100 < DailyNotificationConfig.BatteryThresholds.critical { - DailyNotificationLogger.shared.log( - .warning, - "Warning: Battery level is critical" - ) + if #available(iOS 13.0, *) { + backgroundTaskManager?.registerBackgroundTask() } - // Parse time string (HH:mm format) - let timeComponents = time.split(separator: ":") - guard timeComponents.count == 2, - let hour = Int(timeComponents[0]), - let minute = Int(timeComponents[1]), - hour >= 0 && hour < 24, - minute >= 0 && minute < 60 else { - call.reject("Invalid time format") - return + print("\(Self.TAG): DailyNotificationPlugin loaded successfully") + } + + private func initializeComponents() { + if useSharedStorage, let databasePath = databasePath { + database = DailyNotificationDatabase(path: databasePath) } - // Create notification content - let content = UNMutableNotificationContent() - content.title = call.getString("title") ?? DailyNotificationConstants.defaultTitle - content.body = call.getString("body") ?? DailyNotificationConstants.defaultBody - content.sound = call.getBool("sound", true) ? .default : nil + ttlEnforcer = DailyNotificationTTLEnforcer(database: database, useSharedStorage: useSharedStorage) - // Set priority - if let priority = call.getString("priority") { - if #available(iOS 15.0, *) { - switch priority { - case "high": - content.interruptionLevel = .timeSensitive - case "low": - content.interruptionLevel = .passive - default: - content.interruptionLevel = .active - } - } - } + rollingWindow = DailyNotificationRollingWindow(ttlEnforcer: ttlEnforcer!, + database: database, + useSharedStorage: useSharedStorage) - // Add to notification content setup - content.categoryIdentifier = "DAILY_NOTIFICATION" - let category = UNNotificationCategory( - identifier: "DAILY_NOTIFICATION", - actions: [], - intentIdentifiers: [], - options: .customDismissAction - ) - notificationCenter.setNotificationCategories([category]) + if #available(iOS 13.0, *) { + backgroundTaskManager = DailyNotificationBackgroundTaskManager(database: database, + ttlEnforcer: ttlEnforcer!, + rollingWindow: rollingWindow!) + } - // Create trigger for daily notification - var dateComponents = DateComponents() - dateComponents.hour = hour - dateComponents.minute = minute - let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + print("\(Self.TAG): All components initialized successfully") + } + + @objc func configure(_ call: CAPPluginCall) { + print("\(Self.TAG): Configuring plugin") - // 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 dbPath = call.getString("dbPath") { + databasePath = dbPath + } - if let date = calendar.date(from: components), - date.timeIntervalSinceNow <= 0 { - components.day = calendar.component(.day, from: Date()) + 1 + if let storage = call.getString("storage") { + useSharedStorage = (storage == "shared") } - // Create request - let identifier = String(format: "daily-notification-%d", (url as NSString).hash) - content.userInfo = ["url": url] - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: trigger - ) + if let ttl = call.getDouble("ttlSeconds") { + ttlSeconds = ttl + } - // Schedule notification - notificationCenter.add(request) { error in - 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() - } + if let leadMinutes = call.getInt("prefetchLeadMinutes") { + prefetchLeadMinutes = leadMinutes } + + storeConfiguration() + initializeComponents() + call.resolve() } - @objc func getLastNotification(_ call: CAPPluginCall) { - notificationCenter.getDeliveredNotifications { notifications in - let lastNotification = notifications.first - let result: [String: Any] = [ - "id": lastNotification?.request.identifier ?? "", - "title": lastNotification?.request.content.title ?? "", - "body": lastNotification?.request.content.body ?? "", - "timestamp": lastNotification?.date.timeIntervalSince1970 ?? 0 - ] - call.resolve(result) + private func storeConfiguration() { + if useSharedStorage, let database = database { + // Store in SQLite + print("\(Self.TAG): Storing configuration in SQLite") + } else { + // Store in UserDefaults + UserDefaults.standard.set(databasePath, forKey: "databasePath") + UserDefaults.standard.set(useSharedStorage, forKey: "useSharedStorage") + UserDefaults.standard.set(ttlSeconds, forKey: "ttlSeconds") + UserDefaults.standard.set(prefetchLeadMinutes, forKey: "prefetchLeadMinutes") } } - @objc func cancelAllNotifications(_ call: CAPPluginCall) { - notificationCenter.removeAllPendingNotificationRequests() - notificationCenter.removeAllDeliveredNotifications() - call.resolve() + @objc func maintainRollingWindow(_ call: CAPPluginCall) { + print("\(Self.TAG): Manual rolling window maintenance requested") + + if let rollingWindow = rollingWindow { + rollingWindow.forceMaintenance() + call.resolve() + } else { + call.reject("Rolling window not initialized") + } } - @objc func getNotificationStatus(_ call: CAPPluginCall) { - notificationCenter.getNotificationSettings { settings in - self.notificationCenter.getPendingNotificationRequests { requests in - var result: [String: Any] = [ - "isEnabled": settings.authorizationStatus == .authorized, - "pending": requests.count - ] - - if let nextRequest = requests.first, - let trigger = nextRequest.trigger as? UNCalendarNotificationTrigger { - result["nextNotificationTime"] = trigger.nextTriggerDate()?.timeIntervalSince1970 ?? 0 - } - - // Add current settings - result["settings"] = self.settings - - call.resolve(result) - } + @objc func getRollingWindowStats(_ call: CAPPluginCall) { + print("\(Self.TAG): Rolling window stats requested") + + if let rollingWindow = rollingWindow { + let stats = rollingWindow.getRollingWindowStats() + let result = [ + "stats": stats, + "maintenanceNeeded": rollingWindow.isMaintenanceNeeded(), + "timeUntilNextMaintenance": rollingWindow.getTimeUntilNextMaintenance() + ] as [String : Any] + + call.resolve(result) + } else { + call.reject("Rolling window not initialized") } } - @objc func updateSettings(_ call: CAPPluginCall) { - if let sound = call.getBool("sound") { - settings["sound"] = sound - } + @objc func scheduleBackgroundTask(_ call: CAPPluginCall) { + print("\(Self.TAG): Scheduling background task") - if let priority = call.getString("priority") { - guard ["high", "default", "low"].contains(priority) else { - call.reject("Invalid priority value") - return - } - settings["priority"] = priority + guard let scheduledTimeString = call.getString("scheduledTime") else { + call.reject("scheduledTime parameter is required") + return } - if let timezone = call.getString("timezone") { - guard TimeZone(identifier: timezone) != nil else { - call.reject("Invalid timezone") - return - } - settings["timezone"] = timezone + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + guard let scheduledTime = formatter.date(from: scheduledTimeString) else { + call.reject("Invalid scheduledTime format") + return } - // Update any existing notifications with new settings - notificationCenter.getPendingNotificationRequests { [weak self] requests in - guard let self = self else { return } - - for request in requests { - let content = request.content.mutableCopy() as! UNMutableNotificationContent - - // 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) - } + if #available(iOS 13.0, *) { + backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, + prefetchLeadMinutes: prefetchLeadMinutes) + call.resolve() + } else { + call.reject("Background tasks not available on this iOS version") } - - call.resolve(settings) } - @objc public override func checkPermissions(_ call: CAPPluginCall) { - notificationCenter.getNotificationSettings { settings in - 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) + @objc func getBackgroundTaskStatus(_ call: CAPPluginCall) { + print("\(Self.TAG): Background task status requested") + + 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) { - let options: UNAuthorizationOptions = [.alert, .sound, .badge] + @objc func cancelAllBackgroundTasks(_ call: CAPPluginCall) { + print("\(Self.TAG): Cancelling all background tasks") - notificationCenter.requestAuthorization(options: options) { granted, error in - if let error = error { - call.reject("Failed to request permissions: \(error.localizedDescription)") - return - } - - call.resolve([ - "granted": granted - ]) + if #available(iOS 13.0, *) { + backgroundTaskManager?.cancelAllBackgroundTasks() + call.resolve() + } else { + call.reject("Background tasks not available on this iOS version") } } - @objc func getBatteryStatus(_ call: CAPPluginCall) { - let status = powerManager.getBatteryStatus() - call.resolve(status) - } - - @objc func getPowerState(_ call: CAPPluginCall) { - let state = powerManager.getPowerState() - call.resolve(state) - } - - @objc func setAdaptiveScheduling(_ call: CAPPluginCall) { - let enabled = call.getBool("enabled", true) - powerManager.setAdaptiveScheduling(enabled) - call.resolve() - } - - public override func load() { - notificationCenter.delegate = self - maintenanceWorker.scheduleNextMaintenance() - } - - private func isValidTime(_ time: String) -> Bool { - let timeComponents = time.split(separator: ":") - guard timeComponents.count == 2, - let hour = Int(timeComponents[0]), - let minute = Int(timeComponents[1]) else { - return false + @objc func getTTLViolationStats(_ call: CAPPluginCall) { + print("\(Self.TAG): TTL violation stats requested") + + if let ttlEnforcer = ttlEnforcer { + let stats = ttlEnforcer.getTTLViolationStats() + call.resolve(["stats": stats]) + } else { + call.reject("TTL enforcer not initialized") } - return hour >= 0 && hour < 24 && minute >= 0 && minute < 60 - } - - private func isValidTimezone(_ identifier: String) -> Bool { - return TimeZone(identifier: identifier) != nil } - private func cleanupOldNotifications() { - let cutoffDate = Date().addingTimeInterval(-Double(DailyNotificationConfig.shared.retentionDays * 24 * 60 * 60)) - notificationCenter.getDeliveredNotifications { notifications in - let oldNotifications = notifications.filter { $0.date < cutoffDate } - self.notificationCenter.removeDeliveredNotifications(withIdentifiers: oldNotifications.map { $0.request.identifier }) + @objc func scheduleDailyNotification(_ call: CAPPluginCall) { + print("\(Self.TAG): Scheduling daily notification") + + guard let time = call.getString("time") else { + call.reject("Time parameter is required") + return + } + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + guard let scheduledTime = formatter.date(from: time) else { + call.reject("Invalid time format") + return } + + let notification = NotificationContent( + 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) + + if #available(iOS 13.0, *) { + backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, + prefetchLeadMinutes: prefetchLeadMinutes) + } + + call.resolve() } - private func setupNotificationChannel() { - // iOS doesn't use notification channels like Android - // This method is kept for API compatibility - } -} - -extension DailyNotificationPlugin: UNUserNotificationCenterDelegate { - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - let notification = response.notification - let userInfo = notification.request.content.userInfo + private func scheduleNotification(_ notification: NotificationContent) { + let content = UNMutableNotificationContent() + content.title = notification.title ?? "Daily Notification" + content.body = notification.body ?? "Your daily notification is ready" + content.sound = UNNotificationSound.default - // 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 - ] + let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000) + let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false) - // Notify JavaScript - notifyListeners("notification", data: eventData) + let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) - completionHandler() + 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)") + } + } } - // Handle notifications when app is in foreground - public func userNotificationCenter( - _ center: UNUserNotificationCenter, - 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) + @objc func getLastNotification(_ call: CAPPluginCall) { + let result = [ + "id": "placeholder", + "title": "Last Notification", + "body": "This is a placeholder", + "timestamp": Date().timeIntervalSince1970 * 1000 + ] as [String : Any] + + call.resolve(result) + } + + @objc func cancelAllNotifications(_ call: CAPPluginCall) { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + call.resolve() } -} \ No newline at end of file +} \ No newline at end of file diff --git a/ios/Plugin/DailyNotificationRollingWindow.swift b/ios/Plugin/DailyNotificationRollingWindow.swift new file mode 100644 index 0000000..2343f6c --- /dev/null +++ b/ios/Plugin/DailyNotificationRollingWindow.swift @@ -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)) + } +} diff --git a/ios/Plugin/DailyNotificationTTLEnforcer.swift b/ios/Plugin/DailyNotificationTTLEnforcer.swift new file mode 100644 index 0000000..8573239 --- /dev/null +++ b/ios/Plugin/DailyNotificationTTLEnforcer.swift @@ -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" + } + } +} diff --git a/ios/Plugin/NotificationContent.swift b/ios/Plugin/NotificationContent.swift new file mode 100644 index 0000000..129e68b --- /dev/null +++ b/ios/Plugin/NotificationContent.swift @@ -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 + ) + } +}