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 |
|||
* 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 CHANNEL_ID = "daily_notification_channel" |
|||
private static let CHANNEL_NAME = "Daily Notifications" |
|||
private static let CHANNEL_DESCRIPTION = "Daily notification updates" |
|||
|
|||
/// 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 |
|||
} |
|||
|
|||
// 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" |
|||
) |
|||
} |
|||
private static let TAG = "DailyNotificationPlugin" |
|||
|
|||
// 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 |
|||
} |
|||
private var database: DailyNotificationDatabase? |
|||
private var ttlEnforcer: DailyNotificationTTLEnforcer? |
|||
private var rollingWindow: DailyNotificationRollingWindow? |
|||
private var backgroundTaskManager: DailyNotificationBackgroundTaskManager? |
|||
|
|||
// 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 |
|||
|
|||
// 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 |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Add to notification content setup |
|||
content.categoryIdentifier = "DAILY_NOTIFICATION" |
|||
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 |
|||
let identifier = String(format: "daily-notification-%d", (url as NSString).hash) |
|||
content.userInfo = ["url": url] |
|||
let request = UNNotificationRequest( |
|||
identifier: identifier, |
|||
content: content, |
|||
trigger: trigger |
|||
) |
|||
private var useSharedStorage: Bool = false |
|||
private var databasePath: String? |
|||
private var ttlSeconds: TimeInterval = 3600 |
|||
private var prefetchLeadMinutes: Int = 15 |
|||
|
|||
// 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() |
|||
} |
|||
public override func load() { |
|||
super.load() |
|||
print("\(Self.TAG): DailyNotificationPlugin loading") |
|||
initializeComponents() |
|||
|
|||
if #available(iOS 13.0, *) { |
|||
backgroundTaskManager?.registerBackgroundTask() |
|||
} |
|||
|
|||
print("\(Self.TAG): DailyNotificationPlugin loaded successfully") |
|||
} |
|||
|
|||
@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 initializeComponents() { |
|||
if useSharedStorage, let databasePath = databasePath { |
|||
database = DailyNotificationDatabase(path: databasePath) |
|||
} |
|||
|
|||
ttlEnforcer = DailyNotificationTTLEnforcer(database: database, useSharedStorage: useSharedStorage) |
|||
|
|||
rollingWindow = DailyNotificationRollingWindow(ttlEnforcer: ttlEnforcer!, |
|||
database: database, |
|||
useSharedStorage: useSharedStorage) |
|||
|
|||
if #available(iOS 13.0, *) { |
|||
backgroundTaskManager = DailyNotificationBackgroundTaskManager(database: database, |
|||
ttlEnforcer: ttlEnforcer!, |
|||
rollingWindow: rollingWindow!) |
|||
} |
|||
|
|||
@objc func cancelAllNotifications(_ call: CAPPluginCall) { |
|||
notificationCenter.removeAllPendingNotificationRequests() |
|||
notificationCenter.removeAllDeliveredNotifications() |
|||
call.resolve() |
|||
print("\(Self.TAG): All components initialized successfully") |
|||
} |
|||
|
|||
@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 |
|||
] |
|||
@objc func configure(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): Configuring plugin") |
|||
|
|||
if let nextRequest = requests.first, |
|||
let trigger = nextRequest.trigger as? UNCalendarNotificationTrigger { |
|||
result["nextNotificationTime"] = trigger.nextTriggerDate()?.timeIntervalSince1970 ?? 0 |
|||
if let dbPath = call.getString("dbPath") { |
|||
databasePath = dbPath |
|||
} |
|||
|
|||
// Add current settings |
|||
result["settings"] = self.settings |
|||
|
|||
call.resolve(result) |
|||
if let storage = call.getString("storage") { |
|||
useSharedStorage = (storage == "shared") |
|||
} |
|||
|
|||
if let ttl = call.getDouble("ttlSeconds") { |
|||
ttlSeconds = ttl |
|||
} |
|||
|
|||
if let leadMinutes = call.getInt("prefetchLeadMinutes") { |
|||
prefetchLeadMinutes = leadMinutes |
|||
} |
|||
|
|||
@objc func updateSettings(_ call: CAPPluginCall) { |
|||
if let sound = call.getBool("sound") { |
|||
settings["sound"] = sound |
|||
storeConfiguration() |
|||
initializeComponents() |
|||
call.resolve() |
|||
} |
|||
|
|||
if let priority = call.getString("priority") { |
|||
guard ["high", "default", "low"].contains(priority) else { |
|||
call.reject("Invalid priority value") |
|||
return |
|||
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") |
|||
} |
|||
settings["priority"] = priority |
|||
} |
|||
|
|||
if let timezone = call.getString("timezone") { |
|||
guard TimeZone(identifier: timezone) != nil else { |
|||
call.reject("Invalid timezone") |
|||
return |
|||
@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") |
|||
} |
|||
settings["timezone"] = timezone |
|||
} |
|||
|
|||
// Update any existing notifications with new settings |
|||
notificationCenter.getPendingNotificationRequests { [weak self] requests in |
|||
guard let self = self else { return } |
|||
@objc func getRollingWindowStats(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): Rolling window stats requested") |
|||
|
|||
for request in requests { |
|||
let content = request.content.mutableCopy() as! UNMutableNotificationContent |
|||
if let rollingWindow = rollingWindow { |
|||
let stats = rollingWindow.getRollingWindowStats() |
|||
let result = [ |
|||
"stats": stats, |
|||
"maintenanceNeeded": rollingWindow.isMaintenanceNeeded(), |
|||
"timeUntilNextMaintenance": rollingWindow.getTimeUntilNextMaintenance() |
|||
] as [String : Any] |
|||
|
|||
// 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 |
|||
call.resolve(result) |
|||
} else { |
|||
call.reject("Rolling window not initialized") |
|||
} |
|||
} |
|||
|
|||
@objc func scheduleBackgroundTask(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): Scheduling background task") |
|||
|
|||
guard let scheduledTimeString = call.getString("scheduledTime") else { |
|||
call.reject("scheduledTime parameter is required") |
|||
return |
|||
} |
|||
|
|||
let newRequest = UNNotificationRequest( |
|||
identifier: request.identifier, |
|||
content: content, |
|||
trigger: request.trigger |
|||
) |
|||
let formatter = DateFormatter() |
|||
formatter.dateFormat = "HH:mm" |
|||
guard let scheduledTime = formatter.date(from: scheduledTimeString) else { |
|||
call.reject("Invalid scheduledTime format") |
|||
return |
|||
} |
|||
|
|||
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 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 checkPermissions(_ call: CAPPluginCall) { |
|||
notificationCenter.getNotificationSettings { settings in |
|||
var result: [String: Any] = [:] |
|||
@objc func cancelAllBackgroundTasks(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): Cancelling all background tasks") |
|||
|
|||
// 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" |
|||
if #available(iOS 13.0, *) { |
|||
backgroundTaskManager?.cancelAllBackgroundTasks() |
|||
call.resolve() |
|||
} else { |
|||
call.reject("Background tasks not available on this iOS version") |
|||
} |
|||
} |
|||
|
|||
// 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 |
|||
@objc func getTTLViolationStats(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): TTL violation stats requested") |
|||
|
|||
call.resolve(result) |
|||
if let ttlEnforcer = ttlEnforcer { |
|||
let stats = ttlEnforcer.getTTLViolationStats() |
|||
call.resolve(["stats": stats]) |
|||
} else { |
|||
call.reject("TTL enforcer not initialized") |
|||
} |
|||
} |
|||
|
|||
@objc public override func requestPermissions(_ call: CAPPluginCall) { |
|||
let options: UNAuthorizationOptions = [.alert, .sound, .badge] |
|||
@objc func scheduleDailyNotification(_ call: CAPPluginCall) { |
|||
print("\(Self.TAG): Scheduling daily notification") |
|||
|
|||
notificationCenter.requestAuthorization(options: options) { granted, error in |
|||
if let error = error { |
|||
call.reject("Failed to request permissions: \(error.localizedDescription)") |
|||
guard let time = call.getString("time") else { |
|||
call.reject("Time parameter is required") |
|||
return |
|||
} |
|||
|
|||
call.resolve([ |
|||
"granted": granted |
|||
]) |
|||
} |
|||
let formatter = DateFormatter() |
|||
formatter.dateFormat = "HH:mm" |
|||
guard let scheduledTime = formatter.date(from: time) else { |
|||
call.reject("Invalid time format") |
|||
return |
|||
} |
|||
|
|||
@objc func getBatteryStatus(_ call: CAPPluginCall) { |
|||
let status = powerManager.getBatteryStatus() |
|||
call.resolve(status) |
|||
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 |
|||
} |
|||
|
|||
@objc func getPowerState(_ call: CAPPluginCall) { |
|||
let state = powerManager.getPowerState() |
|||
call.resolve(state) |
|||
scheduleNotification(notification) |
|||
|
|||
if #available(iOS 13.0, *) { |
|||
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime, |
|||
prefetchLeadMinutes: prefetchLeadMinutes) |
|||
} |
|||
|
|||
@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 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 |
|||
|
|||
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 |
|||
} |
|||
return hour >= 0 && hour < 24 && minute >= 0 && minute < 60 |
|||
} |
|||
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000) |
|||
let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false) |
|||
|
|||
private func isValidTimezone(_ identifier: String) -> Bool { |
|||
return TimeZone(identifier: identifier) != nil |
|||
} |
|||
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) |
|||
|
|||
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 }) |
|||
} |
|||
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)") |
|||
} |
|||
|
|||
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 |
|||
|
|||
// 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) |
|||
@objc func getLastNotification(_ call: CAPPluginCall) { |
|||
let result = [ |
|||
"id": "placeholder", |
|||
"title": "Last Notification", |
|||
"body": "This is a placeholder", |
|||
"timestamp": Date().timeIntervalSince1970 * 1000 |
|||
] as [String : Any] |
|||
|
|||
completionHandler() |
|||
call.resolve(result) |
|||
} |
|||
|
|||
// 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 cancelAllNotifications(_ call: CAPPluginCall) { |
|||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests() |
|||
call.resolve() |
|||
} |
|||
} |
@ -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