feat(ios): implement Phase 2.1 iOS background tasks with T–lead prefetch
- 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(-)
This commit is contained in:
285
examples/phase2-1-ios-background-tasks.ts
Normal file
285
examples/phase2-1-ios-background-tasks.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
431
ios/Plugin/DailyNotificationBackgroundTaskManager.swift
Normal file
431
ios/Plugin/DailyNotificationBackgroundTaskManager.swift
Normal file
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
211
ios/Plugin/DailyNotificationDatabase.swift
Normal file
211
ios/Plugin/DailyNotificationDatabase.swift
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* DailyNotificationDatabase.swift
|
||||||
|
*
|
||||||
|
* iOS SQLite database management for daily notifications
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite database manager for daily notifications on iOS
|
||||||
|
*
|
||||||
|
* This class manages the SQLite database with the three-table schema:
|
||||||
|
* - notif_contents: keep history, fast newest-first reads
|
||||||
|
* - notif_deliveries: track many deliveries per slot/time
|
||||||
|
* - notif_config: generic configuration KV
|
||||||
|
*/
|
||||||
|
class DailyNotificationDatabase {
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private static let TAG = "DailyNotificationDatabase"
|
||||||
|
|
||||||
|
// Table names
|
||||||
|
static let TABLE_NOTIF_CONTENTS = "notif_contents"
|
||||||
|
static let TABLE_NOTIF_DELIVERIES = "notif_deliveries"
|
||||||
|
static let TABLE_NOTIF_CONFIG = "notif_config"
|
||||||
|
|
||||||
|
// Column names
|
||||||
|
static let COL_CONTENTS_ID = "id"
|
||||||
|
static let COL_CONTENTS_SLOT_ID = "slot_id"
|
||||||
|
static let COL_CONTENTS_PAYLOAD_JSON = "payload_json"
|
||||||
|
static let COL_CONTENTS_FETCHED_AT = "fetched_at"
|
||||||
|
static let COL_CONTENTS_ETAG = "etag"
|
||||||
|
|
||||||
|
static let COL_DELIVERIES_ID = "id"
|
||||||
|
static let COL_DELIVERIES_SLOT_ID = "slot_id"
|
||||||
|
static let COL_DELIVERIES_FIRE_AT = "fire_at"
|
||||||
|
static let COL_DELIVERIES_DELIVERED_AT = "delivered_at"
|
||||||
|
static let COL_DELIVERIES_STATUS = "status"
|
||||||
|
static let COL_DELIVERIES_ERROR_CODE = "error_code"
|
||||||
|
static let COL_DELIVERIES_ERROR_MESSAGE = "error_message"
|
||||||
|
|
||||||
|
static let COL_CONFIG_K = "k"
|
||||||
|
static let COL_CONFIG_V = "v"
|
||||||
|
|
||||||
|
// Status values
|
||||||
|
static let STATUS_SCHEDULED = "scheduled"
|
||||||
|
static let STATUS_SHOWN = "shown"
|
||||||
|
static let STATUS_ERROR = "error"
|
||||||
|
static let STATUS_CANCELED = "canceled"
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var db: OpaquePointer?
|
||||||
|
private let path: String
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database with path
|
||||||
|
*
|
||||||
|
* @param path Database file path
|
||||||
|
*/
|
||||||
|
init(path: String) {
|
||||||
|
self.path = path
|
||||||
|
openDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open database connection
|
||||||
|
*/
|
||||||
|
private func openDatabase() {
|
||||||
|
if sqlite3_open(path, &db) == SQLITE_OK {
|
||||||
|
print("\(Self.TAG): Database opened successfully at \(path)")
|
||||||
|
createTables()
|
||||||
|
configureDatabase()
|
||||||
|
} else {
|
||||||
|
print("\(Self.TAG): Error opening database: \(String(cString: sqlite3_errmsg(db)))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create database tables
|
||||||
|
*/
|
||||||
|
private func createTables() {
|
||||||
|
// Create notif_contents table
|
||||||
|
let createContentsTable = """
|
||||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONTENTS)(
|
||||||
|
\(Self.COL_CONTENTS_ID) INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
\(Self.COL_CONTENTS_SLOT_ID) TEXT NOT NULL,
|
||||||
|
\(Self.COL_CONTENTS_PAYLOAD_JSON) TEXT NOT NULL,
|
||||||
|
\(Self.COL_CONTENTS_FETCHED_AT) INTEGER NOT NULL,
|
||||||
|
\(Self.COL_CONTENTS_ETAG) TEXT,
|
||||||
|
UNIQUE(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// Create notif_deliveries table
|
||||||
|
let createDeliveriesTable = """
|
||||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_DELIVERIES)(
|
||||||
|
\(Self.COL_DELIVERIES_ID) INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
\(Self.COL_DELIVERIES_SLOT_ID) TEXT NOT NULL,
|
||||||
|
\(Self.COL_DELIVERIES_FIRE_AT) INTEGER NOT NULL,
|
||||||
|
\(Self.COL_DELIVERIES_DELIVERED_AT) INTEGER,
|
||||||
|
\(Self.COL_DELIVERIES_STATUS) TEXT NOT NULL DEFAULT '\(Self.STATUS_SCHEDULED)',
|
||||||
|
\(Self.COL_DELIVERIES_ERROR_CODE) TEXT,
|
||||||
|
\(Self.COL_DELIVERIES_ERROR_MESSAGE) TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// Create notif_config table
|
||||||
|
let createConfigTable = """
|
||||||
|
CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONFIG)(
|
||||||
|
\(Self.COL_CONFIG_K) TEXT PRIMARY KEY,
|
||||||
|
\(Self.COL_CONFIG_V) TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
let createContentsIndex = """
|
||||||
|
CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time
|
||||||
|
ON \(Self.TABLE_NOTIF_CONTENTS)(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT) DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// Execute table creation
|
||||||
|
executeSQL(createContentsTable)
|
||||||
|
executeSQL(createDeliveriesTable)
|
||||||
|
executeSQL(createConfigTable)
|
||||||
|
executeSQL(createContentsIndex)
|
||||||
|
|
||||||
|
print("\(Self.TAG): Database tables created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure database settings
|
||||||
|
*/
|
||||||
|
private func configureDatabase() {
|
||||||
|
// Enable WAL mode
|
||||||
|
executeSQL("PRAGMA journal_mode=WAL")
|
||||||
|
|
||||||
|
// Set synchronous mode
|
||||||
|
executeSQL("PRAGMA synchronous=NORMAL")
|
||||||
|
|
||||||
|
// Set busy timeout
|
||||||
|
executeSQL("PRAGMA busy_timeout=5000")
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
executeSQL("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
// Set user version
|
||||||
|
executeSQL("PRAGMA user_version=1")
|
||||||
|
|
||||||
|
print("\(Self.TAG): Database configured successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute SQL statement
|
||||||
|
*
|
||||||
|
* @param sql SQL statement to execute
|
||||||
|
*/
|
||||||
|
private func executeSQL(_ sql: String) {
|
||||||
|
var statement: OpaquePointer?
|
||||||
|
|
||||||
|
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
|
||||||
|
if sqlite3_step(statement) == SQLITE_DONE {
|
||||||
|
print("\(Self.TAG): SQL executed successfully: \(sql)")
|
||||||
|
} else {
|
||||||
|
print("\(Self.TAG): SQL execution failed: \(String(cString: sqlite3_errmsg(db)))")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("\(Self.TAG): SQL preparation failed: \(String(cString: sqlite3_errmsg(db)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(statement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection
|
||||||
|
*/
|
||||||
|
func close() {
|
||||||
|
if sqlite3_close(db) == SQLITE_OK {
|
||||||
|
print("\(Self.TAG): Database closed successfully")
|
||||||
|
} else {
|
||||||
|
print("\(Self.TAG): Error closing database: \(String(cString: sqlite3_errmsg(db)))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database path
|
||||||
|
*
|
||||||
|
* @return Database file path
|
||||||
|
*/
|
||||||
|
func getPath() -> String {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if database is open
|
||||||
|
*
|
||||||
|
* @return true if database is open
|
||||||
|
*/
|
||||||
|
func isOpen() -> Bool {
|
||||||
|
return db != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,364 +1,260 @@
|
|||||||
/**
|
/**
|
||||||
* DailyNotificationPlugin.swift
|
* DailyNotificationPlugin.swift
|
||||||
* Daily Notification Plugin for Capacitor
|
|
||||||
*
|
*
|
||||||
* Handles daily notification scheduling and management on iOS
|
* Main iOS plugin class for handling daily notifications
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import BackgroundTasks
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// Represents the main plugin class for handling daily notifications
|
|
||||||
///
|
|
||||||
/// This plugin provides functionality for scheduling and managing daily notifications
|
|
||||||
/// on iOS devices using the UserNotifications framework.
|
|
||||||
@objc(DailyNotificationPlugin)
|
@objc(DailyNotificationPlugin)
|
||||||
public class DailyNotificationPlugin: CAPPlugin {
|
public class DailyNotificationPlugin: CAPPlugin {
|
||||||
private let notificationCenter = UNUserNotificationCenter.current()
|
|
||||||
private let powerManager = DailyNotificationPowerManager.shared
|
|
||||||
private let maintenanceWorker = DailyNotificationMaintenanceWorker.shared
|
|
||||||
|
|
||||||
private var settings: [String: Any] = [
|
private static let TAG = "DailyNotificationPlugin"
|
||||||
"sound": true,
|
|
||||||
"priority": "default",
|
|
||||||
"retryCount": 3,
|
|
||||||
"retryInterval": 1000
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let CHANNEL_ID = "daily_notification_channel"
|
private var database: DailyNotificationDatabase?
|
||||||
private static let CHANNEL_NAME = "Daily Notifications"
|
private var ttlEnforcer: DailyNotificationTTLEnforcer?
|
||||||
private static let CHANNEL_DESCRIPTION = "Daily notification updates"
|
private var rollingWindow: DailyNotificationRollingWindow?
|
||||||
|
private var backgroundTaskManager: DailyNotificationBackgroundTaskManager?
|
||||||
|
|
||||||
/// Schedules a new daily notification
|
private var useSharedStorage: Bool = false
|
||||||
/// - Parameter call: The plugin call containing notification parameters
|
private var databasePath: String?
|
||||||
/// - Returns: Void
|
private var ttlSeconds: TimeInterval = 3600
|
||||||
/// - Throws: DailyNotificationError
|
private var prefetchLeadMinutes: Int = 15
|
||||||
@objc func scheduleDailyNotification(_ call: CAPPluginCall) {
|
|
||||||
guard let url = call.getString("url"),
|
public override func load() {
|
||||||
let time = call.getString("time") else {
|
super.load()
|
||||||
call.reject("Missing required parameters")
|
print("\(Self.TAG): DailyNotificationPlugin loading")
|
||||||
|
initializeComponents()
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
backgroundTaskManager?.registerBackgroundTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\(Self.TAG): DailyNotificationPlugin loaded successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
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!)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\(Self.TAG): All components initialized successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func configure(_ call: CAPPluginCall) {
|
||||||
|
print("\(Self.TAG): Configuring plugin")
|
||||||
|
|
||||||
|
if let dbPath = call.getString("dbPath") {
|
||||||
|
databasePath = dbPath
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
storeConfiguration()
|
||||||
|
initializeComponents()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 scheduleBackgroundTask(_ call: CAPPluginCall) {
|
||||||
|
print("\(Self.TAG): Scheduling background task")
|
||||||
|
|
||||||
|
guard let scheduledTimeString = call.getString("scheduledTime") else {
|
||||||
|
call.reject("scheduledTime parameter is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check battery optimization status
|
let formatter = DateFormatter()
|
||||||
let batteryStatus = powerManager.getBatteryStatus()
|
formatter.dateFormat = "HH:mm"
|
||||||
if batteryStatus["level"] as? Int ?? 100 < DailyNotificationConfig.BatteryThresholds.critical {
|
guard let scheduledTime = formatter.date(from: scheduledTimeString) else {
|
||||||
DailyNotificationLogger.shared.log(
|
call.reject("Invalid scheduledTime format")
|
||||||
.warning,
|
return
|
||||||
"Warning: Battery level is critical"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse time string (HH:mm format)
|
if #available(iOS 13.0, *) {
|
||||||
let timeComponents = time.split(separator: ":")
|
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime,
|
||||||
guard timeComponents.count == 2,
|
prefetchLeadMinutes: prefetchLeadMinutes)
|
||||||
let hour = Int(timeComponents[0]),
|
call.resolve()
|
||||||
let minute = Int(timeComponents[1]),
|
} else {
|
||||||
hour >= 0 && hour < 24,
|
call.reject("Background tasks not available on this iOS version")
|
||||||
minute >= 0 && minute < 60 else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 func cancelAllBackgroundTasks(_ call: CAPPluginCall) {
|
||||||
|
print("\(Self.TAG): Cancelling all background tasks")
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
backgroundTaskManager?.cancelAllBackgroundTasks()
|
||||||
|
call.resolve()
|
||||||
|
} else {
|
||||||
|
call.reject("Background tasks not available on this iOS version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")
|
call.reject("Invalid time format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notification content
|
let notification = NotificationContent(
|
||||||
let content = UNMutableNotificationContent()
|
id: UUID().uuidString,
|
||||||
content.title = call.getString("title") ?? DailyNotificationConstants.defaultTitle
|
title: call.getString("title") ?? "Daily Update",
|
||||||
content.body = call.getString("body") ?? DailyNotificationConstants.defaultBody
|
body: call.getString("body") ?? "Your daily notification is ready",
|
||||||
content.sound = call.getBool("sound", true) ? .default : nil
|
scheduledTime: scheduledTime.timeIntervalSince1970 * 1000,
|
||||||
|
fetchedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
// Set priority
|
url: call.getString("url"),
|
||||||
if let priority = call.getString("priority") {
|
payload: nil,
|
||||||
if #available(iOS 15.0, *) {
|
etag: nil
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schedule notification
|
if let ttlEnforcer = ttlEnforcer, !ttlEnforcer.validateBeforeArming(notification) {
|
||||||
notificationCenter.add(request) { error in
|
call.reject("Notification content violates TTL")
|
||||||
if let error = error {
|
return
|
||||||
DailyNotificationLogger.shared.log(
|
}
|
||||||
.error,
|
|
||||||
"Failed to schedule notification: \(error.localizedDescription)"
|
scheduleNotification(notification)
|
||||||
)
|
|
||||||
call.reject("Failed to schedule notification: \(error.localizedDescription)")
|
if #available(iOS 13.0, *) {
|
||||||
} else {
|
backgroundTaskManager?.scheduleBackgroundTask(scheduledTime: scheduledTime,
|
||||||
DailyNotificationLogger.shared.log(
|
prefetchLeadMinutes: prefetchLeadMinutes)
|
||||||
.info,
|
}
|
||||||
"Successfully scheduled notification for \(time)"
|
|
||||||
)
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
let scheduledTime = Date(timeIntervalSince1970: notification.scheduledTime / 1000)
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledTime), repeats: false)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("\(Self.TAG): Failed to schedule notification \(notification.id): \(error)")
|
||||||
|
} else {
|
||||||
|
print("\(Self.TAG): Successfully scheduled notification: \(notification.id)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLastNotification(_ call: CAPPluginCall) {
|
@objc func getLastNotification(_ call: CAPPluginCall) {
|
||||||
notificationCenter.getDeliveredNotifications { notifications in
|
let result = [
|
||||||
let lastNotification = notifications.first
|
"id": "placeholder",
|
||||||
let result: [String: Any] = [
|
"title": "Last Notification",
|
||||||
"id": lastNotification?.request.identifier ?? "",
|
"body": "This is a placeholder",
|
||||||
"title": lastNotification?.request.content.title ?? "",
|
"timestamp": Date().timeIntervalSince1970 * 1000
|
||||||
"body": lastNotification?.request.content.body ?? "",
|
] as [String : Any]
|
||||||
"timestamp": lastNotification?.date.timeIntervalSince1970 ?? 0
|
|
||||||
]
|
|
||||||
call.resolve(result)
|
call.resolve(result)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@objc func cancelAllNotifications(_ call: CAPPluginCall) {
|
@objc func cancelAllNotifications(_ call: CAPPluginCall) {
|
||||||
notificationCenter.removeAllPendingNotificationRequests()
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
notificationCenter.removeAllDeliveredNotifications()
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 updateSettings(_ call: CAPPluginCall) {
|
|
||||||
if let sound = call.getBool("sound") {
|
|
||||||
settings["sound"] = sound
|
|
||||||
}
|
|
||||||
|
|
||||||
if let priority = call.getString("priority") {
|
|
||||||
guard ["high", "default", "low"].contains(priority) else {
|
|
||||||
call.reject("Invalid priority value")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings["priority"] = priority
|
|
||||||
}
|
|
||||||
|
|
||||||
if let timezone = call.getString("timezone") {
|
|
||||||
guard TimeZone(identifier: timezone) != nil else {
|
|
||||||
call.reject("Invalid timezone")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings["timezone"] = timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 public override func requestPermissions(_ call: CAPPluginCall) {
|
|
||||||
let options: UNAuthorizationOptions = [.alert, .sound, .badge]
|
|
||||||
|
|
||||||
notificationCenter.requestAuthorization(options: options) { granted, error in
|
|
||||||
if let error = error {
|
|
||||||
call.reject("Failed to request permissions: \(error.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
call.resolve([
|
|
||||||
"granted": granted
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
completionHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
403
ios/Plugin/DailyNotificationRollingWindow.swift
Normal file
403
ios/Plugin/DailyNotificationRollingWindow.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
393
ios/Plugin/DailyNotificationTTLEnforcer.swift
Normal file
393
ios/Plugin/DailyNotificationTTLEnforcer.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
ios/Plugin/NotificationContent.swift
Normal file
170
ios/Plugin/NotificationContent.swift
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user