// // DailyNotificationPlugin.swift // DailyNotificationPlugin // // Created by Matthew Raymer on 2025-09-22 // Copyright © 2025 TimeSafari. All rights reserved. // import Foundation import UIKit import Capacitor import UserNotifications import BackgroundTasks import CoreData import ObjectiveC /** * iOS implementation of Daily Notification Plugin * Implements BGTaskScheduler + UNUserNotificationCenter for dual scheduling * * @author Matthew Raymer * @version 1.1.0 * @created 2025-09-22 09:22:32 UTC */ @objc(DailyNotificationPlugin) public class DailyNotificationPlugin: CAPPlugin { let notificationCenter = UNUserNotificationCenter.current() private let backgroundTaskScheduler = BGTaskScheduler.shared // Note: PersistenceController available for Phase 2+ CoreData integration if needed // Background task identifiers private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch" private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify" // Phase 1: Storage and Scheduler components var storage: DailyNotificationStorage? var scheduler: DailyNotificationScheduler? // Phase 1: Reactivation manager for recovery var reactivationManager: DailyNotificationReactivationManager? // Phase 1: Concurrency actor for thread-safe state access @available(iOS 13.0, *) var stateActor: DailyNotificationStateActor? override public func load() { NSLog("DNP-DEBUG: DailyNotificationPlugin.load() called - Capacitor discovered the plugin!") super.load() setupBackgroundTasks() // Initialize Phase 1 components let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let defaultPath = documentsPath.appendingPathComponent("daily_notifications.db").path let database = DailyNotificationDatabase(path: defaultPath) storage = DailyNotificationStorage(databasePath: database.getPath()) scheduler = DailyNotificationScheduler() // Initialize reactivation manager for recovery reactivationManager = DailyNotificationReactivationManager( database: database, storage: storage!, scheduler: scheduler! ) // Initialize state actor for thread-safe access if #available(iOS 13.0, *) { stateActor = DailyNotificationStateActor( database: database, storage: storage! ) } // Perform recovery on app launch (async, non-blocking) reactivationManager?.performRecovery() // Register for notification delivery events (Notification Center pattern) NotificationCenter.default.addObserver( self, selector: #selector(handleNotificationDelivery(_:)), name: NSNotification.Name("DailyNotificationDelivered"), object: nil ) NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification") print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification") // Register for app becoming active to check for missed rollovers NotificationCenter.default.addObserver( self, selector: #selector(handleAppBecameActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil ) NSLog("DNP-ROLLOVER: Observer registered for app becoming active") print("DNP-ROLLOVER: Observer registered for app becoming active") NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") // Debug: Log all available @objc methods for Capacitor discovery let methods = getObjCMethods() NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))") print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))") } /** * Debug helper: Get all @objc methods for this class */ private func getObjCMethods() -> [String] { var methods: [String] = [] var methodCount: UInt32 = 0 let methodList = class_copyMethodList(type(of: self), &methodCount) for i in 0.. [String: Any] { guard let scheduler = scheduler else { throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"]) } // Delegate to ScheduleHelper for health status (combines multiple sources) return try await DailyNotificationScheduleHelper.getHealthStatus( scheduler: scheduler, storage: self.storage, stateActor: await self.stateActor ) } // MARK: - Private Implementation Methods private func setupBackgroundTasks() { // Register background fetch task backgroundTaskScheduler.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in self.handleBackgroundFetch(task: task as! BGAppRefreshTask) } // Register background processing task backgroundTaskScheduler.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in self.handleBackgroundNotify(task: task as! BGProcessingTask) } // Phase 1: Check for missed BGTask on app launch checkForMissedBGTask() } /** * Handle background fetch task * * Phase 1: Dummy fetcher - returns static content * Phase 3: Will be replaced with JWT-signed fetcher * * Enhanced with: * - Recovery logic (verify scheduled notifications) * - Next task scheduling * - Graceful expiration handling * * @param task BGAppRefreshTask */ private func handleBackgroundFetch(task: BGAppRefreshTask) { print("DNP-FETCH: Background fetch task started") // Enhanced expiration handler with graceful cleanup var taskCompleted = false task.expirationHandler = { guard !taskCompleted else { return } print("DNP-FETCH: Background fetch task expired - performing graceful cleanup") // Cancel any ongoing operations // Note: In production, you might want to cancel URLSession tasks here task.setTaskCompleted(success: false) taskCompleted = true } // Phase 3: Check for JWT-signed fetcher configuration // If native fetcher is configured, use it; otherwise fall back to dummy content let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config") // Save content to storage via state actor (thread-safe) Task { let content: NotificationContent if let configJson = nativeFetcherConfig, let configData = configJson.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any], let apiBaseUrl = config["apiBaseUrl"] as? String, let activeDid = config["activeDid"] as? String, let jwtToken = config["jwtToken"] as? String { // Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)") // Attempt to fetch content from TimeSafari API // Note: This is a minimal implementation - can be extended with full API client do { let fetchedContent = try await fetchContentFromAPI( apiBaseUrl: apiBaseUrl, activeDid: activeDid, jwtToken: jwtToken ) content = fetchedContent print("DNP-FETCH: Successfully fetched content from API") } catch { // Fallback to dummy content on fetch failure print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content") content = NotificationContent( id: "fallback_\(Date().timeIntervalSince1970)", title: "Daily Update", body: "Your daily notification is ready", scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000), fetchedAt: Int64(Date().timeIntervalSince1970 * 1000), url: nil, payload: ["fetchError": error.localizedDescription], etag: nil ) } } else { // Fallback: Dummy content fetch (no network) print("DNP-FETCH: Using dummy content (native fetcher not configured)") content = NotificationContent( id: "dummy_\(Date().timeIntervalSince1970)", title: "Daily Update", body: "Your daily notification is ready", scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000), fetchedAt: Int64(Date().timeIntervalSince1970 * 1000), url: nil, payload: nil, etag: nil ) } do { // Use the content (either from JWT fetcher or dummy) if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveNotificationContent(content) // Mark successful run let currentTime = Int64(Date().timeIntervalSince1970 * 1000) await stateActor.saveLastSuccessfulRun(timestamp: currentTime) } else { // Fallback to direct storage access self.storage?.saveNotificationContent(content) let currentTime = Int64(Date().timeIntervalSince1970 * 1000) self.storage?.saveLastSuccessfulRun(timestamp: currentTime) } } else { // Fallback for iOS < 13 self.storage?.saveNotificationContent(content) let currentTime = Int64(Date().timeIntervalSince1970 * 1000) self.storage?.saveLastSuccessfulRun(timestamp: currentTime) } // Phase 3.3: Recovery logic - verify scheduled notifications // Check if notifications are still scheduled after fetch if let reactivationManager = self.reactivationManager { // Perform lightweight verification (non-blocking) Task { do { let verificationResult = try await reactivationManager.verifyFutureNotifications() if verificationResult.notificationsMissing > 0 { print("DNP-FETCH: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch") // Note: Full recovery happens on app launch, not in background task } } catch { // Non-fatal: Log but don't fail task print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)") } } } // Phase 3.3: Schedule next background task // Calculate next fetch time based on notification schedule if let scheduler = self.scheduler { let nextScheduledTime = await scheduler.getNextNotificationTime() if let nextTime = nextScheduledTime { self.scheduleBackgroundFetch(scheduledTime: nextTime) print("DNP-FETCH: Next background fetch scheduled") } else { print("DNP-FETCH: No future notifications found, skipping next task schedule") } } else { print("DNP-FETCH: Scheduler not available, skipping next task schedule") } guard !taskCompleted else { return } task.setTaskCompleted(success: true) taskCompleted = true print("DNP-FETCH: Background fetch task completed successfully") } catch { print("DNP-FETCH: Background fetch task failed: \(error.localizedDescription)") guard !taskCompleted else { return } task.setTaskCompleted(success: false) taskCompleted = true } } } /** * Handle background notification task * * Enhanced with: * - Recovery logic (verify scheduled notifications) * - Next task scheduling * - Graceful expiration handling * * @param task BGProcessingTask */ private func handleBackgroundNotify(task: BGProcessingTask) { print("DNP-NOTIFY: Background notify task started") // Enhanced expiration handler with graceful cleanup var taskCompleted = false task.expirationHandler = { guard !taskCompleted else { return } print("DNP-NOTIFY: Background notify task expired - performing graceful cleanup") task.setTaskCompleted(success: false) taskCompleted = true } Task { do { // Phase 3.3: Recovery logic - verify scheduled notifications // Check if notifications are still scheduled if let reactivationManager = self.reactivationManager { // Perform lightweight verification (non-blocking) let verificationResult = try await reactivationManager.verifyFutureNotifications() if verificationResult.notificationsMissing > 0 { print("DNP-NOTIFY: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch") // Note: Full recovery happens on app launch, not in background task } } // Phase 1: Not used for single daily schedule // This will be used in Phase 2+ for rolling window maintenance // For now, just verify state // Phase 3.3: Schedule next background task if needed // For notify task, schedule next occurrence if applicable if let scheduler = self.scheduler { let nextScheduledTime = await scheduler.getNextNotificationTime() if let nextTime = nextScheduledTime { // Calculate next notify task time (if applicable) // Note: Notify tasks are typically scheduled less frequently than fetch tasks print("DNP-NOTIFY: Next notification scheduled at \(nextTime)") } } guard !taskCompleted else { return } task.setTaskCompleted(success: true) taskCompleted = true print("DNP-NOTIFY: Background notify task completed successfully") } catch { print("DNP-NOTIFY: Background notify task failed: \(error.localizedDescription)") guard !taskCompleted else { return } task.setTaskCompleted(success: false) taskCompleted = true } } } /** * Check for missed BGTask and reschedule if needed * * Phase 1: BGTask Miss Detection * - Checks if BGTask was scheduled but not run within 15 min window * - Reschedules if missed */ private func checkForMissedBGTask() { Task { var earliestBeginTimestamp: Int64? var lastSuccessfulRun: Int64? // Get BGTask tracking info via state actor (thread-safe) if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { earliestBeginTimestamp = await stateActor.getBGTaskEarliestBegin() lastSuccessfulRun = await stateActor.getLastSuccessfulRun() } else { // Fallback to direct storage access earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin() lastSuccessfulRun = self.storage?.getLastSuccessfulRun() } } else { // Fallback for iOS < 13 earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin() lastSuccessfulRun = self.storage?.getLastSuccessfulRun() } guard let earliestBeginTime = earliestBeginTimestamp else { // No BGTask scheduled return } let currentTime = Int64(Date().timeIntervalSince1970 * 1000) let missWindow = Int64(15 * 60 * 1000) // 15 minutes in milliseconds // Check if task was missed (current time > earliestBeginTime + 15 min) if currentTime > earliestBeginTime + missWindow { // Check if there was a successful run if let lastRun = lastSuccessfulRun { // If last successful run was after earliestBeginTime, task was not missed if lastRun >= earliestBeginTime { print("DNP-FETCH: BGTask completed successfully, no reschedule needed") return } } // Task was missed - reschedule print("DNP-FETCH: BGTask missed window; rescheduling") // Reschedule for 1 minute from now let rescheduleTime = currentTime + (1 * 60 * 1000) // 1 minute from now let rescheduleDate = Date(timeIntervalSince1970: Double(rescheduleTime) / 1000.0) let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier) request.earliestBeginDate = rescheduleDate do { try backgroundTaskScheduler.submit(request) // Save rescheduled time via state actor (thread-safe) if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveBGTaskEarliestBegin(timestamp: rescheduleTime) } else { // Fallback to direct storage access self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime) } } else { // Fallback for iOS < 13 self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime) } print("DNP-FETCH: BGTask rescheduled for \(rescheduleDate)") } catch { print("DNP-FETCH: Failed to reschedule BGTask: \(error)") } } } } private func scheduleBackgroundFetch(config: [String: Any]) throws { let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier) // Calculate next run time (simplified - would use proper cron parsing in production) let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *") request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime) try backgroundTaskScheduler.submit(request) print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)") } private func scheduleUserNotification(config: [String: Any]) throws { let content = UNMutableNotificationContent() content.title = config["title"] as? String ?? "Daily Notification" content.body = config["body"] as? String ?? "Your daily update is ready" content.sound = (config["sound"] as? Bool ?? true) ? .default : nil // Create trigger (simplified - would use proper cron parsing in production) let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *") let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false) let request = UNNotificationRequest( identifier: "daily-notification-\(Date().timeIntervalSince1970)", content: content, trigger: trigger ) notificationCenter.add(request) { error in if let error = error { print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)") } else { print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully") } } } private func calculateNextRunTime(from schedule: String) -> TimeInterval { // Simplified implementation - would use proper cron parsing in production // For now, return next day at 9 AM return 86400 // 24 hours } // MARK: - Static Daily Reminder Methods @objc func scheduleDailyReminder(_ call: CAPPluginCall) { guard let id = call.getString("id"), let title = call.getString("title"), let body = call.getString("body"), let time = call.getString("time") else { call.reject("Missing required parameters: id, title, body, time") return } let sound = call.getBool("sound", true) let vibration = call.getBool("vibration", true) let priority = call.getString("priority", "normal") let repeatDaily = call.getBool("repeatDaily", true) let timezone = call.getString("timezone") // Validate and parse time (HH:mm format) let timeComponents = time.components(separatedBy: ":") guard timeComponents.count == 2, let hour = Int(timeComponents[0]), let minute = Int(timeComponents[1]), hour >= 0 && hour <= 23, minute >= 0 && minute <= 59 else { call.reject("Invalid time format. Use HH:mm (e.g., 09:00)") return } // Create notification content let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = sound ? .default : nil content.categoryIdentifier = "DAILY_REMINDER" // Set priority if #available(iOS 15.0, *) { switch priority { case "high": content.interruptionLevel = .critical case "low": content.interruptionLevel = .passive default: content.interruptionLevel = .active } } // Create date components for daily trigger var dateComponents = DateComponents() dateComponents.hour = hour dateComponents.minute = minute let trigger = UNCalendarNotificationTrigger( dateMatching: dateComponents, repeats: repeatDaily ) let request = UNNotificationRequest( identifier: "reminder_\(id)", content: content, trigger: trigger ) // Store reminder in UserDefaults storeReminderInUserDefaults( id: id, title: title, body: body, time: time, sound: sound, vibration: vibration, priority: priority, repeatDaily: repeatDaily, timezone: timezone ) // Delegate to UNUserNotificationCenter to schedule notification notificationCenter.add(request) { error in DispatchQueue.main.async { if let error = error { call.reject("Failed to schedule daily reminder: \(error.localizedDescription)") } else { call.resolve() } } } } @objc func cancelDailyReminder(_ call: CAPPluginCall) { guard let reminderId = call.getString("reminderId") else { call.reject("Missing reminderId parameter") return } // Cancel the notification notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"]) // Delegate to storage for reminder removal removeReminderFromUserDefaults(id: reminderId) call.resolve() } @objc func getScheduledReminders(_ call: CAPPluginCall) { print("DNP-REMINDER: Getting scheduled reminders") // Get pending notifications notificationCenter.getPendingNotificationRequests { requests in let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") } // Get stored reminder data from UserDefaults let reminders = self.getRemindersFromUserDefaults() var result: [[String: Any]] = [] for reminder in reminders { let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" } var reminderInfo = reminder reminderInfo["isScheduled"] = isScheduled result.append(reminderInfo) } DispatchQueue.main.async { call.resolve(["reminders": result]) } } } @objc func updateDailyReminder(_ call: CAPPluginCall) { guard let reminderId = call.getString("reminderId") else { call.reject("Missing reminderId parameter") return } // Cancel existing reminder notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"]) // Update in UserDefaults let title = call.getString("title") let body = call.getString("body") let time = call.getString("time") let sound = call.getBool("sound") let vibration = call.getBool("vibration") let priority = call.getString("priority") let repeatDaily = call.getBool("repeatDaily") let timezone = call.getString("timezone") updateReminderInUserDefaults( id: reminderId, title: title, body: body, time: time, sound: sound, vibration: vibration, priority: priority, repeatDaily: repeatDaily, timezone: timezone ) // Reschedule with new settings if all required fields are provided if let title = title, let body = body, let time = time { // Parse time let timeComponents = time.components(separatedBy: ":") guard timeComponents.count == 2, let hour = Int(timeComponents[0]), let minute = Int(timeComponents[1]) else { call.reject("Invalid time format") return } // Create new notification content let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = (sound ?? true) ? .default : nil content.categoryIdentifier = "DAILY_REMINDER" // Set priority let finalPriority = priority ?? "normal" if #available(iOS 15.0, *) { switch finalPriority { case "high": content.interruptionLevel = .critical case "low": content.interruptionLevel = .passive default: content.interruptionLevel = .active } } // Create date components for daily trigger var dateComponents = DateComponents() dateComponents.hour = hour dateComponents.minute = minute let trigger = UNCalendarNotificationTrigger( dateMatching: dateComponents, repeats: repeatDaily ?? true ) let request = UNNotificationRequest( identifier: "reminder_\(reminderId)", content: content, trigger: trigger ) // Schedule the updated notification notificationCenter.add(request) { error in DispatchQueue.main.async { if let error = error { call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)") } else { call.resolve() } } } } else { call.resolve() } } // MARK: - Helper Methods for Reminder Storage private func storeReminderInUserDefaults( id: String, title: String, body: String, time: String, sound: Bool, vibration: Bool, priority: String, repeatDaily: Bool, timezone: String? ) { let reminderData: [String: Any] = [ "id": id, "title": title, "body": body, "time": time, "sound": sound, "vibration": vibration, "priority": priority, "repeatDaily": repeatDaily, "timezone": timezone ?? "", "createdAt": Date().timeIntervalSince1970, "lastTriggered": 0 ] var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? [] reminders.append(reminderData) UserDefaults.standard.set(reminders, forKey: "daily_reminders") print("DNP-REMINDER: Reminder stored: \(id)") } private func removeReminderFromUserDefaults(id: String) { var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? [] reminders.removeAll { ($0["id"] as? String) == id } UserDefaults.standard.set(reminders, forKey: "daily_reminders") print("DNP-REMINDER: Reminder removed: \(id)") } private func getRemindersFromUserDefaults() -> [[String: Any]] { return UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? [] } private func updateReminderInUserDefaults( id: String, title: String?, body: String?, time: String?, sound: Bool?, vibration: Bool?, priority: String?, repeatDaily: Bool?, timezone: String? ) { var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? [] for i in 0..= 0 && hour <= 23, minute >= 0 && minute <= 59 else { let error = DailyNotificationErrorCodes.invalidTimeFormat() let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } // Extract other parameters (with defaults matching Android) let title = call.getString("title") ?? "Daily Update" let body = call.getString("body") ?? "Your daily notification is ready" let sound = call.getBool("sound", true) let url = call.getString("url") let priority = call.getString("priority") ?? "default" // Calculate scheduled time (next occurrence at specified hour:minute) let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute) let fetchedAt = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds // Create notification content let content = NotificationContent( id: "daily_\(Date().timeIntervalSince1970)", title: title, body: body, scheduledTime: scheduledTime, fetchedAt: fetchedAt, url: url, payload: nil, etag: nil ) // Delegate to ScheduleHelper for orchestration Task { let scheduled = await DailyNotificationScheduleHelper.scheduleDailyNotification( content: content, scheduledTime: scheduledTime, scheduler: scheduler, storage: self.storage, stateActor: await self.stateActor, scheduleBackgroundFetch: { [weak self] (scheduledTime: Int64) -> Void in self?.scheduleBackgroundFetch(scheduledTime: scheduledTime) } ) DispatchQueue.main.async { if scheduled { call.resolve() } else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.SCHEDULING_FAILED, message: "Failed to schedule notification" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) } } } } /** * Test method: Schedule an alarm to fire in a few seconds * Useful for verifying alarm delivery works correctly * * @param call Plugin call with optional secondsFromNow (default: 5) * @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number) */ @objc func testAlarm(_ call: CAPPluginCall) { NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!") print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!") print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)") guard let scheduler = scheduler else { NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting") print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting") let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } // Get secondsFromNow parameter (default: 5) let secondsFromNow = call.getInt("secondsFromNow") ?? 5 // Ensure minimum of 1 second (iOS requirement) let validSeconds = max(1, secondsFromNow) Task { do { // Check permissions first let permissionStatus = await notificationCenter.notificationSettings() if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED, message: "Notification permissions not granted" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } // Create test notification content let notificationContent = UNMutableNotificationContent() notificationContent.title = "Test Notification" notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now" notificationContent.sound = .default notificationContent.categoryIdentifier = "DAILY_NOTIFICATION" notificationContent.userInfo = [ "notification_id": "test_\(Date().timeIntervalSince1970)", "is_test": true ] // Create time interval trigger (fires in X seconds) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false) // Create notification request with unique ID let notificationId = "test_alarm_\(Date().timeIntervalSince1970)" let request = UNNotificationRequest( identifier: notificationId, content: notificationContent, trigger: trigger ) // Schedule notification try await notificationCenter.add(request) // Calculate trigger time in milliseconds let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000) let result: [String: Any] = [ "scheduled": true, "secondsFromNow": validSeconds, "triggerAtMillis": triggerAtMillis ] print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))") NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)") DispatchQueue.main.async { NSLog("DNP-DEBUG: testAlarm() - Resolving call with result") call.resolve(result) } } catch { NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)") print("DNP-PLUGIN: Error scheduling test alarm: \(error)") let errorResponse = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.SCHEDULING_FAILED, message: "Failed to schedule test alarm: \(error.localizedDescription)" ) let errorMessage = errorResponse["message"] as? String ?? "Unknown error" let errorCode = errorResponse["error"] as? String ?? "unknown_error" NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))") DispatchQueue.main.async { call.reject(errorMessage, errorCode) } } } } /** * Debug method: List all available plugin methods * Useful for verifying Capacitor method discovery * * @param call Plugin call */ @objc func listAvailableMethods(_ call: CAPPluginCall) { let methods = getObjCMethods() let result: [String: Any] = [ "methods": methods, "count": methods.count, "testAlarmFound": methods.contains("testAlarm:") ] NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods") NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))") call.resolve(result) } /** * Get the last notification that was delivered * * @param call Plugin call */ @objc func getLastNotification(_ call: CAPPluginCall) { Task { let lastNotification: NotificationContent? // Delegate to stateActor if available (thread-safe), otherwise use storage directly if #available(iOS 13.0, *), let stateActor = await self.stateActor { lastNotification = await stateActor.getLastNotification() } else { lastNotification = self.storage?.getLastNotification() } DispatchQueue.main.async { if let notification = lastNotification { let result: [String: Any] = [ "id": notification.id, "title": notification.title ?? "", "body": notification.body ?? "", "timestamp": notification.scheduledTime, "url": notification.url ?? "" ] call.resolve(result) } else { call.resolve([:]) } } } } /** * Cancel all scheduled notifications * * @param call Plugin call */ @objc func cancelAllNotifications(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { // Delegate cancellation to scheduler await scheduler.cancelAllNotifications() // Clear storage via stateActor if available (thread-safe), otherwise use storage directly if #available(iOS 13.0, *), let stateActor = await self.stateActor { await stateActor.clearAllNotifications() } else { self.storage?.clearAllNotifications() } DispatchQueue.main.async { print("DNP-PLUGIN: All notifications cancelled successfully") call.resolve() } } } /** * Get the current status of notifications * * @param call Plugin call */ @objc func getNotificationStatus(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { // Delegate to scheduler for permission status and pending count let isEnabled = await scheduler.checkPermissionStatus() == .authorized let pendingCount = await scheduler.getPendingNotificationCount() // Delegate to stateActor if available (thread-safe), otherwise use storage directly let lastNotification: NotificationContent? let settings: [String: Any] if #available(iOS 13.0, *), let stateActor = await self.stateActor { lastNotification = await stateActor.getLastNotification() settings = await stateActor.getSettings() } else { lastNotification = self.storage?.getLastNotification() settings = self.storage?.getSettings() ?? [:] } // Delegate to scheduler for next notification time let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0 // Delegate to storage for rollover status let lastRolloverTime = storage?.getLastRolloverTime() ?? 0 var result: [String: Any] = [ "isEnabled": isEnabled, "isScheduled": pendingCount > 0, "lastNotificationTime": lastNotification?.scheduledTime ?? 0, "nextNotificationTime": nextNotificationTime, "pending": pendingCount, "rolloverEnabled": true, // Indicate rollover is active "lastRolloverTime": lastRolloverTime, // When last rollover occurred "settings": settings ] DispatchQueue.main.async { call.resolve(result) } } } /** * Handle notification delivery event (from Notification Center) * * This is called when AppDelegate posts notification delivery event * Matches Android's scheduleNextNotification() behavior * * @param notification NSNotification with userInfo containing notification_id and scheduled_time */ @objc private func handleNotificationDelivery(_ notification: Notification) { NSLog("DNP-ROLLOVER: handleNotificationDelivery called") print("DNP-ROLLOVER: handleNotificationDelivery called") // Extract notification data from userInfo guard let userInfo = notification.userInfo, let notificationId = userInfo["notification_id"] as? String, let scheduledTime = userInfo["scheduled_time"] as? Int64 else { NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil") print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])") return } let scheduledTimeStr = formatTime(scheduledTime) NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr) print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)") // Track notify execution let currentTime = Int64(Date().timeIntervalSince1970 * 1000) storage?.saveLastNotifyExecution(timestamp: currentTime) // Delegate rollover processing (glue logic - will be moved to service in future) Task { await processRollover(notificationId: notificationId, scheduledTime: scheduledTime) } } /** * Handle app becoming active (foreground) * * This is called when the app becomes active to check for missed rollovers * that occurred while the app was backgrounded. * * @param notification NSNotification for app becoming active */ @objc private func handleAppBecameActive(_ notification: Notification) { NSLog("DNP-ROLLOVER: handleAppBecameActive called") print("DNP-ROLLOVER: handleAppBecameActive called") // Perform lightweight rollover check when app becomes active // This handles cases where notifications fired while app was backgrounded reactivationManager?.performActiveRolloverCheck() } /** * Process rollover for delivered notification * * @param notificationId ID of notification that was delivered * @param scheduledTime Scheduled time of delivered notification */ private func processRollover(notificationId: String, scheduledTime: Int64) async { let scheduledTimeStr = formatTime(scheduledTime) NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr) print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)") guard let scheduler = scheduler, let storage = storage else { NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@", scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil") print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")") return } // Get the notification content that was delivered guard let content = storage.getNotificationContent(id: notificationId) else { NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId) print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)") // Log available notification IDs for debugging let allNotifications = storage.getAllNotifications() let availableIds = allNotifications.map { $0.id }.joined(separator: ", ") NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds) print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]") return } NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId) print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification") // Delegate to scheduler to schedule next notification (glue logic - will be moved to service) // Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented) let scheduled = await scheduler.scheduleNextNotification( content, storage: storage, fetcher: nil // Unused - fetchScheduler handles prefetch scheduling ) if scheduled { NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId) print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled") } else { NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId) print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false") } // Rollover processing is non-fatal - recovery will handle on next launch if needed } /** * Format time for logging * * @param timestamp Timestamp in milliseconds * @return Formatted time string */ private func formatTime(_ timestamp: Int64) -> String { let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0) let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return formatter.string(from: date) } /** * Check permission status * Returns boolean flags for each permission type * * @param call Plugin call */ @objc func checkPermissionStatus(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { do { // Delegate to scheduler for permission status check let notificationStatus = await scheduler.checkPermissionStatus() let notificationsEnabled = notificationStatus == .authorized // Format result (iOS-specific: exactAlarm and wakeLock map to notification permission) let result: [String: Any] = [ "notificationsEnabled": notificationsEnabled, "exactAlarmEnabled": notificationsEnabled, // iOS equivalent "wakeLockEnabled": notificationsEnabled, // iOS equivalent (Background App Refresh) "allPermissionsGranted": notificationsEnabled ] DispatchQueue.main.async { call.resolve(result) } } catch { DispatchQueue.main.async { call.reject("Failed to check permission status: \(error.localizedDescription)", "permission_check_failed") } } } } /** * Request notification permissions * Shows system permission dialog if permissions haven't been determined yet * * @param call Plugin call */ @objc func requestNotificationPermissions(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { do { // Delegate to scheduler for permission request let granted = await scheduler.requestPermissions() let result: [String: Any] = [ "granted": granted ] DispatchQueue.main.async { call.resolve(result) } } catch { DispatchQueue.main.async { call.reject("Failed to request permissions: \(error.localizedDescription)", "permission_request_failed") } } } } // MARK: - iOS-Specific Methods /** * Get notification permission status (iOS-specific) * * Returns detailed permission status matching API.md specification * * @param call Plugin call */ @objc func getNotificationPermissionStatus(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { do { // Delegate to scheduler for permission status check let status = await scheduler.checkPermissionStatus() // Format result with all status flags let result: [String: Any] = [ "authorized": status == .authorized, "denied": status == .denied, "notDetermined": status == .notDetermined, "provisional": status == .provisional ] DispatchQueue.main.async { call.resolve(result) } } catch { DispatchQueue.main.async { call.reject("Failed to get permission status: \(error.localizedDescription)", "permission_status_failed") } } } } /** * Request notification permission (iOS-specific) * * @param call Plugin call */ @objc func requestNotificationPermission(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { do { // Delegate to scheduler for permission request let granted = await scheduler.requestPermissions() let result: [String: Any] = [ "granted": granted ] DispatchQueue.main.async { call.resolve(result) } } catch { DispatchQueue.main.async { call.reject("Failed to request permission: \(error.localizedDescription)", "permission_request_failed") } } } } /** * Get pending notifications (iOS-specific) * * @param call Plugin call */ @objc func getPendingNotifications(_ call: CAPPluginCall) { Task { do { // Delegate to UNUserNotificationCenter for pending requests let requests = try await notificationCenter.pendingNotificationRequests() var notifications: [[String: Any]] = [] for request in requests { let content = request.content var triggerDate: Int64 = 0 if let calendarTrigger = request.trigger as? UNCalendarNotificationTrigger { if let nextDate = calendarTrigger.nextTriggerDate() { triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000) } } else if let timeIntervalTrigger = request.trigger as? UNTimeIntervalNotificationTrigger { if let nextDate = timeIntervalTrigger.nextTriggerDate() { triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000) } } let notification: [String: Any] = [ "identifier": request.identifier, "title": content.title, "body": content.body, "triggerDate": triggerDate, "triggerType": request.trigger is UNCalendarNotificationTrigger ? "calendar" : (request.trigger is UNTimeIntervalNotificationTrigger ? "timeInterval" : "location"), "repeats": request.trigger?.repeats ?? false ] notifications.append(notification) } let result: [String: Any] = [ "count": notifications.count, "notifications": notifications ] DispatchQueue.main.async { call.resolve(result) } } catch { DispatchQueue.main.async { call.reject("Failed to get pending notifications: \(error.localizedDescription)", "pending_notifications_failed") } } } } /** * Get background task status (iOS-specific) * * @param call Plugin call */ @objc func getBackgroundTaskStatus(_ call: CAPPluginCall) { // Note: BGTaskScheduler doesn't provide a way to query registered task identifiers // We assume tasks are registered if setupBackgroundTasks() was called // Background App Refresh status cannot be checked programmatically // User must check in Settings app // Delegate storage access to storage service let lastFetchExecution: Any = storage?.getLastSuccessfulRun() ?? NSNull() let lastNotifyExecution: Any = storage?.getLastNotifyExecution() ?? NSNull() let result: [String: Any] = [ "fetchTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called "notifyTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called "lastFetchExecution": lastFetchExecution, "lastNotifyExecution": lastNotifyExecution, "backgroundRefreshEnabled": NSNull() // Cannot check programmatically ] call.resolve(result) } /** * Open notification settings (iOS-specific) * * @param call Plugin call */ @objc func openNotificationSettings(_ call: CAPPluginCall) { // Delegate to UIApplication to open settings guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { call.reject("Invalid settings URL", "open_settings_failed") return } guard UIApplication.shared.canOpenURL(settingsUrl) else { call.reject("Cannot open settings URL", "open_settings_failed") return } UIApplication.shared.open(settingsUrl) { success in DispatchQueue.main.async { if success { call.resolve() } else { call.reject("Failed to open notification settings", "open_settings_failed") } } } } /** * Open Background App Refresh settings (iOS-specific) * * Note: iOS doesn't provide a direct URL to Background App Refresh settings. * This opens the app's settings page where user can find Background App Refresh. * * @param call Plugin call */ @objc func openBackgroundAppRefreshSettings(_ call: CAPPluginCall) { // iOS doesn't have a direct URL to Background App Refresh settings // Open app settings instead, where user can find Background App Refresh guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { call.reject("Invalid settings URL", "open_settings_failed") return } guard UIApplication.shared.canOpenURL(settingsUrl) else { call.reject("Cannot open settings URL", "open_settings_failed") return } UIApplication.shared.open(settingsUrl) { success in DispatchQueue.main.async { if success { call.resolve() } else { call.reject("Failed to open settings", "open_settings_failed") } } } } // MARK: - Channel Methods (iOS Parity with Android) /** * Check if notification channel is enabled * * iOS Note: iOS doesn't have per-channel control like Android. This method * checks if notifications are authorized app-wide, which is the iOS equivalent. * * @param call Plugin call with optional channelId parameter */ @objc func isChannelEnabled(_ call: CAPPluginCall) { guard let scheduler = scheduler else { let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED, message: "Plugin not initialized" ) let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } // Get channelId from call (optional, for API parity with Android) // iOS doesn't have per-channel control, so check app-wide notification authorization let channelId = call.getString("channelId") ?? "default" Task { // Delegate to scheduler for permission status check let status = await scheduler.checkPermissionStatus() let enabled = (status == .authorized || status == .provisional) let result: [String: Any] = [ "enabled": enabled, "channelId": channelId ] DispatchQueue.main.async { call.resolve(result) } } } /** * Open notification channel settings * * iOS Note: iOS doesn't have per-channel settings. This method opens * the app's notification settings in iOS Settings, which is the iOS equivalent. * * @param call Plugin call with optional channelId parameter */ @objc func openChannelSettings(_ call: CAPPluginCall) { // Get channelId from call (optional, for API parity with Android) // iOS doesn't have per-channel settings, so open app-wide notification settings guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { call.reject("Invalid settings URL", "open_settings_failed") return } guard UIApplication.shared.canOpenURL(settingsUrl) else { call.reject("Cannot open settings URL", "open_settings_failed") return } UIApplication.shared.open(settingsUrl) { success in DispatchQueue.main.async { if success { call.resolve() } else { call.reject("Failed to open settings", "open_settings_failed") } } } } /** * Update notification settings * * @param call Plugin call containing new settings */ @objc func updateSettings(_ call: CAPPluginCall) { guard let settings = call.getObject("settings") else { let error = DailyNotificationErrorCodes.missingParameter("settings") let errorMessage = error["message"] as? String ?? "Unknown error" let errorCode = error["error"] as? String ?? "unknown_error" call.reject(errorMessage, errorCode) return } Task { // Delegate to stateActor if available (thread-safe), otherwise use storage directly if #available(iOS 13.0, *), let stateActor = await self.stateActor { await stateActor.saveSettings(settings) } else { self.storage?.saveSettings(settings) } DispatchQueue.main.async { call.resolve() } } } // MARK: - Phase 3: JWT Fetcher HTTP Implementation /** * Fetch notification content from TimeSafari API using JWT authentication * * Phase 3: Complete HTTP implementation for JWT-signed fetcher * * This method: * - Makes authenticated HTTP request to TimeSafari API * - Uses JWT token in Authorization header * - Parses response and converts to NotificationContent * - Handles errors gracefully with fallback * * @param apiBaseUrl Base URL for TimeSafari API server * @param activeDid Active DID for authentication * @param jwtToken JWT token for Authorization header * @return NotificationContent from API or throws error */ private func fetchContentFromAPI( apiBaseUrl: String, activeDid: String, jwtToken: String ) async throws -> NotificationContent { // Construct API endpoint URL // Note: This is a minimal implementation - can be extended with full endpoint support let endpoint = "/api/v2/report/offers" guard let baseURL = URL(string: apiBaseUrl), let url = URL(string: endpoint, relativeTo: baseURL) else { throw NSError( domain: "DailyNotification", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"] ) } // Create HTTP request with JWT authentication var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 30.0 // 30 second timeout print("DNP-FETCH-HTTP: Making request to \(url.absoluteString)") // Execute HTTP request let (data, response) = try await URLSession.shared.data(for: request) // Validate HTTP response guard let httpResponse = response as? HTTPURLResponse else { throw NSError( domain: "DailyNotification", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"] ) } print("DNP-FETCH-HTTP: Response status code: \(httpResponse.statusCode)") // Check for successful response guard httpResponse.statusCode == 200 else { throw NSError( domain: "DailyNotification", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP error: \(httpResponse.statusCode)"] ) } // Parse JSON response guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NSError( domain: "DailyNotification", code: -3, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response"] ) } // Convert API response to NotificationContent // Note: This is a minimal conversion - can be extended to handle full TimeSafari API response structure let currentTime = Int64(Date().timeIntervalSince1970 * 1000) let content = NotificationContent( id: "api_\(currentTime)", title: json["title"] as? String ?? "Daily Update", body: json["body"] as? String ?? "Your daily notification is ready", scheduledTime: currentTime + (5 * 60 * 1000), // 5 min from now fetchedAt: currentTime, url: apiBaseUrl, payload: json, etag: httpResponse.value(forHTTPHeaderField: "ETag") ) print("DNP-FETCH-HTTP: Successfully converted API response to NotificationContent") return content } // MARK: - Phase 1: Helper Methods /** * Get next scheduled notification time * * Helper method to get the next scheduled notification time for * scheduling background tasks. Uses async/await internally. * * @return Next scheduled notification time in milliseconds (Int64), or nil if none */ private func getNextScheduledNotificationTime() -> Int64? { guard let scheduler = scheduler else { return nil } // Use async helper to get next notification time // Note: This is called from background task handlers which are already async var nextTime: Int64? = nil let semaphore = DispatchSemaphore(value: 0) Task { nextTime = await scheduler.getNextNotificationTime() semaphore.signal() } // Wait with timeout (2 seconds - background tasks have limited time) _ = semaphore.wait(timeout: .now() + 2.0) return nextTime } /** * Calculate next scheduled time for given hour and minute * * Uses scheduler's calculateNextOccurrence for consistency * * @param hour Hour (0-23) * @param minute Minute (0-59) * @return Timestamp in milliseconds */ private func calculateNextScheduledTime(hour: Int, minute: Int) -> Int64 { guard let scheduler = scheduler else { // Fallback calculation if scheduler not available let calendar = Calendar.current let now = Date() var components = calendar.dateComponents([.year, .month, .day], from: now) components.hour = hour components.minute = minute components.second = 0 var scheduledDate = calendar.date(from: components) ?? now // If scheduled time has passed today, schedule for tomorrow if scheduledDate <= now { scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate } return Int64(scheduledDate.timeIntervalSince1970 * 1000) } return scheduler.calculateNextOccurrence(hour: hour, minute: minute) } /** * Schedule background fetch 5 minutes before notification time * * @param scheduledTime Notification scheduled time in milliseconds */ private func scheduleBackgroundFetch(scheduledTime: Int64) { // Calculate fetch time (5 minutes before notification) let fetchTime = scheduledTime - (5 * 60 * 1000) // 5 minutes in milliseconds let fetchDate = Date(timeIntervalSince1970: Double(fetchTime) / 1000.0) // Schedule BGTaskScheduler task let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier) request.earliestBeginDate = fetchDate do { try backgroundTaskScheduler.submit(request) // Store earliest begin date for miss detection via state actor (thread-safe) Task { if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveBGTaskEarliestBegin(timestamp: fetchTime) } else { // Fallback to direct storage access self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime) } } else { // Fallback for iOS < 13 self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime) } } print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(fetchDate)") } catch { // BGTaskScheduler errors are common on simulator (Code=1: notPermitted) // This is expected behavior - simulators don't reliably support background tasks // On real devices, this should work if Background App Refresh is enabled let errorDescription = error.localizedDescription if errorDescription.contains("BGTaskSchedulerErrorDomain") || errorDescription.contains("Code=1") || (error as NSError).domain == "BGTaskSchedulerErrorDomain" { print("DNP-FETCH-SCHEDULE: Background fetch scheduling failed (expected on simulator): \(errorDescription)") print("DNP-FETCH-SCHEDULE: Note: BGTaskScheduler requires real device or Background App Refresh enabled") } else { print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)") } // Don't fail notification scheduling if background fetch fails // Notification will still be delivered, just without prefetch } } } // MARK: - CAPBridgedPlugin Conformance // This extension makes the plugin conform to CAPBridgedPlugin protocol // which is required for Capacitor to discover and register the plugin @objc extension DailyNotificationPlugin: CAPBridgedPlugin { @objc public var identifier: String { return "com.timesafari.dailynotification" } @objc public var jsName: String { return "DailyNotification" } @objc public var pluginMethods: [CAPPluginMethod] { var methods: [CAPPluginMethod] = [] // Core methods methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "updateSettings", returnType: CAPPluginReturnPromise)) // Permission methods methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise)) // iOS-specific methods methods.append(CAPPluginMethod(name: "getNotificationPermissionStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "requestNotificationPermission", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getPendingNotifications", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getBackgroundTaskStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "openNotificationSettings", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "openBackgroundAppRefreshSettings", returnType: CAPPluginReturnPromise)) // Channel methods (iOS parity with Android) methods.append(CAPPluginMethod(name: "isChannelEnabled", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "openChannelSettings", returnType: CAPPluginReturnPromise)) // Reminder methods methods.append(CAPPluginMethod(name: "scheduleDailyReminder", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "cancelDailyReminder", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getScheduledReminders", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "updateDailyReminder", returnType: CAPPluginReturnPromise)) // Dual scheduling methods methods.append(CAPPluginMethod(name: "scheduleContentFetch", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise)) return methods } }