// // DailyNotificationPlugin.swift // DailyNotificationPlugin // // Created by Matthew Raymer on 2025-09-22 // Copyright © 2025 TimeSafari. All rights reserved. // import Foundation import Capacitor import UserNotifications import BackgroundTasks import CoreData /** * 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: 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 state actor for thread-safe access if #available(iOS 13.0, *) { stateActor = DailyNotificationStateActor( database: database, storage: storage! ) } NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") } // MARK: - Configuration Methods /** * Configure the plugin with database and storage options * * Matches Android configure() functionality: * - dbPath: Custom database path (optional) * - storage: "shared" or "tiered" (default: "tiered") * - ttlSeconds: Time-to-live for cached content (optional) * - prefetchLeadMinutes: Minutes before notification to prefetch (optional) * - maxNotificationsPerDay: Maximum notifications per day (optional) * - retentionDays: Days to retain notification history (optional) * - activeDidIntegration: Phase 1 activeDid configuration (optional) * * @param call Plugin call containing configuration parameters */ @objc func configure(_ call: CAPPluginCall) { guard let options = call.getObject("options") else { call.reject("Configuration options required") return } print("DNP-PLUGIN: Configuring plugin with new options") do { // Get configuration options let dbPath = options["dbPath"] as? String let storageMode = options["storage"] as? String ?? "tiered" let ttlSeconds = options["ttlSeconds"] as? Int let prefetchLeadMinutes = options["prefetchLeadMinutes"] as? Int let maxNotificationsPerDay = options["maxNotificationsPerDay"] as? Int let retentionDays = options["retentionDays"] as? Int // Phase 1: Process activeDidIntegration configuration (deferred to Phase 3) if let activeDidConfig = options["activeDidIntegration"] as? [String: Any] { print("DNP-PLUGIN: activeDidIntegration config received (Phase 3 feature)") // TODO: Implement activeDidIntegration configuration in Phase 3 } // Update storage mode let useSharedStorage = storageMode == "shared" // Set database path let finalDbPath: String if let dbPath = dbPath, !dbPath.isEmpty { finalDbPath = dbPath print("DNP-PLUGIN: Database path set to: \(finalDbPath)") } else { // Use default database path let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] finalDbPath = documentsPath.appendingPathComponent("daily_notifications.db").path print("DNP-PLUGIN: Using default database path: \(finalDbPath)") } // Reinitialize storage with new database path if needed if let currentStorage = storage { // Check if path changed if currentStorage.getDatabasePath() != finalDbPath { storage = DailyNotificationStorage(databasePath: finalDbPath) print("DNP-PLUGIN: Storage reinitialized with new database path") } } else { storage = DailyNotificationStorage(databasePath: finalDbPath) } // Store configuration in storage storeConfiguration( ttlSeconds: ttlSeconds, prefetchLeadMinutes: prefetchLeadMinutes, maxNotificationsPerDay: maxNotificationsPerDay, retentionDays: retentionDays, storageMode: storageMode, dbPath: finalDbPath ) print("DNP-PLUGIN: Plugin configuration completed successfully") call.resolve() } catch { print("DNP-PLUGIN: Error configuring plugin: \(error)") call.reject("Configuration failed: \(error.localizedDescription)") } } /** * Store configuration values * * @param ttlSeconds TTL in seconds * @param prefetchLeadMinutes Prefetch lead time in minutes * @param maxNotificationsPerDay Maximum notifications per day * @param retentionDays Retention period in days * @param storageMode Storage mode ("shared" or "tiered") * @param dbPath Database path */ private func storeConfiguration( ttlSeconds: Int?, prefetchLeadMinutes: Int?, maxNotificationsPerDay: Int?, retentionDays: Int?, storageMode: String, dbPath: String ) { var config: [String: Any] = [ "storageMode": storageMode, "dbPath": dbPath ] if let ttlSeconds = ttlSeconds { config["ttlSeconds"] = ttlSeconds } if let prefetchLeadMinutes = prefetchLeadMinutes { config["prefetchLeadMinutes"] = prefetchLeadMinutes } if let maxNotificationsPerDay = maxNotificationsPerDay { config["maxNotificationsPerDay"] = maxNotificationsPerDay } if let retentionDays = retentionDays { config["retentionDays"] = retentionDays } storage?.saveSettings(config) print("DNP-PLUGIN: Configuration stored successfully") } // MARK: - Dual Scheduling Methods @objc func scheduleContentFetch(_ call: CAPPluginCall) { guard let config = call.getObject("config") else { call.reject("Content fetch config required") return } print("DNP-PLUGIN: Scheduling content fetch") do { try scheduleBackgroundFetch(config: config) call.resolve() } catch { print("DNP-PLUGIN: Failed to schedule content fetch: \(error)") call.reject("Content fetch scheduling failed: \(error.localizedDescription)") } } @objc func scheduleUserNotification(_ call: CAPPluginCall) { guard let config = call.getObject("config") else { call.reject("User notification config required") return } print("DNP-PLUGIN: Scheduling user notification") do { try scheduleUserNotification(config: config) call.resolve() } catch { print("DNP-PLUGIN: Failed to schedule user notification: \(error)") call.reject("User notification scheduling failed: \(error.localizedDescription)") } } @objc func scheduleDualNotification(_ call: CAPPluginCall) { guard let config = call.getObject("config"), let contentFetchConfig = config["contentFetch"] as? [String: Any], let userNotificationConfig = config["userNotification"] as? [String: Any] else { call.reject("Dual notification config required") return } print("DNP-PLUGIN: Scheduling dual notification") do { try scheduleBackgroundFetch(config: contentFetchConfig) try scheduleUserNotification(config: userNotificationConfig) call.resolve() } catch { print("DNP-PLUGIN: Failed to schedule dual notification: \(error)") call.reject("Dual notification scheduling failed: \(error.localizedDescription)") } } @objc func getDualScheduleStatus(_ call: CAPPluginCall) { Task { do { let status = try await getHealthStatus() DispatchQueue.main.async { call.resolve(status) } } catch { DispatchQueue.main.async { print("DNP-PLUGIN: Failed to get dual schedule status: \(error)") call.reject("Status retrieval failed: \(error.localizedDescription)") } } } } /** * Get health status for dual scheduling system * * @return Health status dictionary */ private func getHealthStatus() async throws -> [String: Any] { guard let scheduler = scheduler else { throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"]) } let pendingCount = await scheduler.getPendingNotificationCount() let isEnabled = await scheduler.checkPermissionStatus() == .authorized // Get last notification via state actor var lastNotification: NotificationContent? if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { lastNotification = await stateActor.getLastNotification() } else { lastNotification = self.storage?.getLastNotification() } } else { lastNotification = self.storage?.getLastNotification() } return [ "contentFetch": [ "isEnabled": true, "isScheduled": pendingCount > 0, "lastFetchTime": lastNotification?.fetchedAt ?? 0, "nextFetchTime": 0, "pendingFetches": pendingCount ], "userNotification": [ "isEnabled": isEnabled, "isScheduled": pendingCount > 0, "lastNotificationTime": lastNotification?.scheduledTime ?? 0, "nextNotificationTime": 0, "pendingNotifications": pendingCount ], "relationship": [ "isLinked": true, "contentAvailable": lastNotification != nil, "lastLinkTime": lastNotification?.fetchedAt ?? 0 ], "overall": [ "isActive": isEnabled && pendingCount > 0, "lastActivity": lastNotification?.scheduledTime ?? 0, "errorCount": 0, "successRate": 1.0 ] ] } // 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 * * @param task BGAppRefreshTask */ private func handleBackgroundFetch(task: BGAppRefreshTask) { print("DNP-FETCH: Background fetch task started") // Set expiration handler task.expirationHandler = { print("DNP-FETCH: Background fetch task expired") task.setTaskCompleted(success: false) } // Phase 1: Dummy content fetch (no network) // TODO: Phase 3 - Replace with JWT-signed fetcher let dummyContent = NotificationContent( id: "dummy_\(Date().timeIntervalSince1970)", title: "Daily Update", body: "Your daily notification is ready", scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000), // 5 min from now fetchedAt: Int64(Date().timeIntervalSince1970 * 1000), url: nil, payload: nil, etag: nil ) // Save content to storage via state actor (thread-safe) Task { if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveNotificationContent(dummyContent) // Mark successful run let currentTime = Int64(Date().timeIntervalSince1970 * 1000) await stateActor.saveLastSuccessfulRun(timestamp: currentTime) } else { // Fallback to direct storage access self.storage?.saveNotificationContent(dummyContent) let currentTime = Int64(Date().timeIntervalSince1970 * 1000) self.storage?.saveLastSuccessfulRun(timestamp: currentTime) } } else { // Fallback for iOS < 13 self.storage?.saveNotificationContent(dummyContent) let currentTime = Int64(Date().timeIntervalSince1970 * 1000) self.storage?.saveLastSuccessfulRun(timestamp: currentTime) } } // Schedule next fetch // TODO: Calculate next fetch time based on notification schedule print("DNP-FETCH: Background fetch task completed successfully") task.setTaskCompleted(success: true) } /** * Handle background notification task * * @param task BGProcessingTask */ private func handleBackgroundNotify(task: BGProcessingTask) { print("DNP-NOTIFY: Background notify task started") // Set expiration handler task.expirationHandler = { print("DNP-NOTIFY: Background notify task expired") task.setTaskCompleted(success: false) } // Phase 1: Not used for single daily schedule // This will be used in Phase 2+ for rolling window maintenance print("DNP-NOTIFY: Background notify task completed") task.setTaskCompleted(success: 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") print("DNP-REMINDER: Scheduling daily reminder: \(id)") // 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 ) // Schedule the notification notificationCenter.add(request) { error in DispatchQueue.main.async { if let error = error { print("DNP-REMINDER: Failed to schedule reminder: \(error)") call.reject("Failed to schedule daily reminder: \(error.localizedDescription)") } else { print("DNP-REMINDER: Daily reminder scheduled successfully: \(id)") call.resolve() } } } } @objc func cancelDailyReminder(_ call: CAPPluginCall) { guard let reminderId = call.getString("reminderId") else { call.reject("Missing reminderId parameter") return } print("DNP-REMINDER: Cancelling daily reminder: \(reminderId)") // Cancel the notification notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"]) // Remove from UserDefaults 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 } print("DNP-REMINDER: Updating daily reminder: \(reminderId)") // 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 ) // Store notification content via state actor (thread-safe) Task { if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveNotificationContent(content) } else { // Fallback to direct storage access self.storage?.saveNotificationContent(content) } } else { // Fallback for iOS < 13 self.storage?.saveNotificationContent(content) } // Schedule notification let scheduled = await scheduler.scheduleNotification(content) if scheduled { // Schedule background fetch 5 minutes before notification time self.scheduleBackgroundFetch(scheduledTime: scheduledTime) DispatchQueue.main.async { print("DNP-PLUGIN: Daily notification scheduled successfully") call.resolve() } } else { DispatchQueue.main.async { 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) } } } } /** * Get the last notification that was delivered * * @param call Plugin call */ @objc func getLastNotification(_ call: CAPPluginCall) { Task { var lastNotification: NotificationContent? if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { lastNotification = await stateActor.getLastNotification() } else { // Fallback to direct storage access lastNotification = self.storage?.getLastNotification() } } else { // Fallback for iOS < 13 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 { await scheduler.cancelAllNotifications() // Clear notifications via state actor (thread-safe) if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.clearAllNotifications() } else { // Fallback to direct storage access self.storage?.clearAllNotifications() } } else { // Fallback for iOS < 13 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 { let isEnabled = await scheduler.checkPermissionStatus() == .authorized let pendingCount = await scheduler.getPendingNotificationCount() // Get last notification via state actor (thread-safe) var lastNotification: NotificationContent? var settings: [String: Any] = [:] if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { lastNotification = await stateActor.getLastNotification() settings = await stateActor.getSettings() } else { // Fallback to direct storage access lastNotification = self.storage?.getLastNotification() settings = self.storage?.getSettings() ?? [:] } } else { // Fallback for iOS < 13 lastNotification = self.storage?.getLastNotification() settings = self.storage?.getSettings() ?? [:] } // Calculate next notification time let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0 var result: [String: Any] = [ "isEnabled": isEnabled, "isScheduled": pendingCount > 0, "lastNotificationTime": lastNotification?.scheduledTime ?? 0, "nextNotificationTime": nextNotificationTime, "pending": pendingCount, "settings": settings ] DispatchQueue.main.async { call.resolve(result) } } } /** * Check permission status * Returns boolean flags for each permission type * * @param call Plugin call */ @objc func checkPermissionStatus(_ call: CAPPluginCall) { NSLog("DNP-PLUGIN: checkPermissionStatus called - thread: %@", Thread.isMainThread ? "main" : "background") // Ensure scheduler is initialized (should be initialized in load(), but check anyway) if scheduler == nil { NSLog("DNP-PLUGIN: Scheduler not initialized, initializing now...") scheduler = DailyNotificationScheduler() } 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" NSLog("DNP-PLUGIN: checkPermissionStatus failed - plugin not initialized") call.reject(errorMessage, errorCode) return } // Use Task without @MainActor, then dispatch to main queue for call.resolve Task { do { NSLog("DNP-PLUGIN: Task started - thread: %@", Thread.isMainThread ? "main" : "background") NSLog("DNP-PLUGIN: Calling scheduler.checkPermissionStatus()...") // Check notification permission status let notificationStatus = await scheduler.checkPermissionStatus() NSLog("DNP-PLUGIN: Got notification status: %d", notificationStatus.rawValue) let notificationsEnabled = notificationStatus == .authorized NSLog("DNP-PLUGIN: Notification status: %d, enabled: %@", notificationStatus.rawValue, notificationsEnabled ? "YES" : "NO") // iOS doesn't have exact alarms like Android, but we can check if notifications are authorized // For iOS, "exact alarm" equivalent is having authorized notifications let exactAlarmEnabled = notificationsEnabled // iOS doesn't have wake locks, but we can check Background App Refresh // Note: Background App Refresh status requires checking system settings // For now, we'll assume it's enabled if notifications are enabled // Phase 2: Add proper Background App Refresh status check let wakeLockEnabled = notificationsEnabled // All permissions granted if notifications are authorized let allPermissionsGranted = notificationsEnabled let result: [String: Any] = [ "notificationsEnabled": notificationsEnabled, "exactAlarmEnabled": exactAlarmEnabled, "wakeLockEnabled": wakeLockEnabled, "allPermissionsGranted": allPermissionsGranted ] NSLog("DNP-PLUGIN: checkPermissionStatus result: %@", result) NSLog("DNP-PLUGIN: About to call resolve - thread: %@", Thread.isMainThread ? "main" : "background") // Dispatch to main queue for call.resolve (required by Capacitor) DispatchQueue.main.async { NSLog("DNP-PLUGIN: On main queue, calling resolve") call.resolve(result) NSLog("DNP-PLUGIN: Call resolved successfully") } } catch { NSLog("DNP-PLUGIN: checkPermissionStatus error: %@", error.localizedDescription) let errorMessage = "Failed to check permission status: \(error.localizedDescription)" // Dispatch to main queue for call.reject (required by Capacitor) DispatchQueue.main.async { call.reject(errorMessage, "permission_check_failed") } } } NSLog("DNP-PLUGIN: Task created and returned") } /** * Request notification permissions * Shows system permission dialog if permissions haven't been determined yet * * @param call Plugin call */ @objc func requestNotificationPermissions(_ call: CAPPluginCall) { NSLog("DNP-PLUGIN: requestNotificationPermissions called - thread: %@", Thread.isMainThread ? "main" : "background") // Ensure scheduler is initialized if scheduler == nil { NSLog("DNP-PLUGIN: Scheduler not initialized, initializing now...") scheduler = DailyNotificationScheduler() } 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" NSLog("DNP-PLUGIN: requestNotificationPermissions failed - plugin not initialized") call.reject(errorMessage, errorCode) return } // Use Task without @MainActor, then dispatch to main queue for call.resolve Task { do { NSLog("DNP-PLUGIN: Task started for requestNotificationPermissions") // First check current status let currentStatus = await scheduler.checkPermissionStatus() NSLog("DNP-PLUGIN: Current permission status: %d", currentStatus.rawValue) // If already authorized, return success immediately if currentStatus == .authorized { NSLog("DNP-PLUGIN: Permissions already granted") let result: [String: Any] = [ "granted": true, "status": "authorized" ] DispatchQueue.main.async { call.resolve(result) } return } // If denied, we can't request again (user must go to Settings) if currentStatus == .denied { NSLog("DNP-PLUGIN: Permissions denied - user must enable in Settings") let error = DailyNotificationErrorCodes.createErrorResponse( code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED, message: "Notification permissions denied. Please enable in Settings." ) let errorMessage = error["message"] as? String ?? "Permissions denied" let errorCode = error["error"] as? String ?? "notifications_denied" DispatchQueue.main.async { call.reject(errorMessage, errorCode) } return } // Request permissions (will show system dialog if .notDetermined) NSLog("DNP-PLUGIN: Requesting permissions...") let granted = await scheduler.requestPermissions() NSLog("DNP-PLUGIN: Permission request result: %@", granted ? "granted" : "denied") // Get updated status let newStatus = await scheduler.checkPermissionStatus() let result: [String: Any] = [ "granted": granted, "status": granted ? "authorized" : "denied", "previousStatus": currentStatus.rawValue, "newStatus": newStatus.rawValue ] NSLog("DNP-PLUGIN: requestNotificationPermissions result: %@", result) DispatchQueue.main.async { call.resolve(result) } } catch { NSLog("DNP-PLUGIN: requestNotificationPermissions error: %@", error.localizedDescription) let errorMessage = "Failed to request permissions: \(error.localizedDescription)" DispatchQueue.main.async { call.reject(errorMessage, "permission_request_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) { NSLog("DNP-PLUGIN: isChannelEnabled called") // Ensure scheduler is initialized if scheduler == nil { scheduler = DailyNotificationScheduler() } 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) let channelId = call.getString("channelId") ?? "default" // iOS doesn't have per-channel control, so check app-wide notification authorization Task { 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) { NSLog("DNP-PLUGIN: openChannelSettings called") // Get channelId from call (optional, for API parity with Android) let channelId = call.getString("channelId") ?? "default" // iOS doesn't have per-channel settings, so open app-wide notification settings if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { if UIApplication.shared.canOpenURL(settingsUrl) { UIApplication.shared.open(settingsUrl) { success in if success { NSLog("DNP-PLUGIN: Opened iOS Settings for channel: %@", channelId) DispatchQueue.main.async { call.resolve() } } else { NSLog("DNP-PLUGIN: Failed to open iOS Settings") DispatchQueue.main.async { call.reject("Failed to open settings") } } } } else { call.reject("Cannot open settings URL") } } else { call.reject("Invalid settings URL") } } /** * 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 { // Save settings via state actor (thread-safe) if #available(iOS 13.0, *) { if let stateActor = await self.stateActor { await stateActor.saveSettings(settings) } else { // Fallback to direct storage access self.storage?.saveSettings(settings) } } else { // Fallback for iOS < 13 self.storage?.saveSettings(settings) } DispatchQueue.main.async { print("DNP-PLUGIN: Settings updated successfully") call.resolve() } } } // MARK: - Phase 1: Helper Methods /** * 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: CAPPluginReturnNone)) methods.append(CAPPluginMethod(name: "scheduleDailyNotification", 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)) // 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 } }