// // 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 import UIKit /** * 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() let backgroundTaskScheduler = BGTaskScheduler.shared let persistenceController = PersistenceController.shared // Background task identifiers private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch" private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify" override public func load() { super.load() setupBackgroundTasks() print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") } // MARK: - Configuration Methods @objc func configure(_ call: CAPPluginCall) { guard let options = call.getObject("options") else { call.reject("Configuration options required") return } print("DNP-PLUGIN: Configure called with options: \(options)") // Store configuration in UserDefaults UserDefaults.standard.set(options, forKey: "DailyNotificationConfig") call.resolve() } // 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() call.resolve(status) } catch { print("DNP-PLUGIN: Failed to get dual schedule status: \(error)") call.reject("Status retrieval failed: \(error.localizedDescription)") } } } @objc func updateDualScheduleConfig(_ 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: Updating dual schedule config") // Cancel existing schedules first cancelAllNotifications(call) // Schedule new configuration do { try scheduleBackgroundFetch(config: contentFetchConfig) try scheduleUserNotification(config: userNotificationConfig) // Store config in UserDefaults if let configData = try? JSONSerialization.data(withJSONObject: config, options: []), let configString = String(data: configData, encoding: .utf8) { UserDefaults.standard.set(configString, forKey: "DailyNotificationDualScheduleConfig") UserDefaults.standard.synchronize() } call.resolve() } catch { print("DNP-PLUGIN: Failed to update dual schedule config: \(error)") call.reject("Dual schedule config update failed: \(error.localizedDescription)") } } @objc func cancelDualSchedule(_ call: CAPPluginCall) { print("DNP-PLUGIN: Cancelling dual schedule") // Cancel all notifications (this cancels both fetch and notify) cancelAllNotifications(call) // Clear stored config UserDefaults.standard.removeObject(forKey: "DailyNotificationDualScheduleConfig") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Dual schedule cancelled") } @objc func pauseDualSchedule(_ call: CAPPluginCall) { print("DNP-PLUGIN: Pausing dual schedule") // Store pause state UserDefaults.standard.set(true, forKey: "DailyNotificationDualSchedulePaused") UserDefaults.standard.synchronize() // Cancel all notifications (they can be resumed later) cancelAllNotifications(call) print("DNP-PLUGIN: Dual schedule paused") call.resolve() } @objc func resumeDualSchedule(_ call: CAPPluginCall) { print("DNP-PLUGIN: Resuming dual schedule") // Check if paused guard UserDefaults.standard.bool(forKey: "DailyNotificationDualSchedulePaused") else { call.reject("Dual schedule is not paused") return } // Clear pause state UserDefaults.standard.set(false, forKey: "DailyNotificationDualSchedulePaused") UserDefaults.standard.synchronize() // Restore from stored config if available if let configString = UserDefaults.standard.string(forKey: "DailyNotificationDualScheduleConfig"), let configData = configString.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any], let contentFetchConfig = config["contentFetch"] as? [String: Any], let userNotificationConfig = config["userNotification"] as? [String: Any] { do { try scheduleBackgroundFetch(config: contentFetchConfig) try scheduleUserNotification(config: userNotificationConfig) print("DNP-PLUGIN: Dual schedule resumed from stored config") call.resolve() } catch { print("DNP-PLUGIN: Failed to resume dual schedule: \(error)") call.reject("Dual schedule resume failed: \(error.localizedDescription)") } } else { print("DNP-PLUGIN: No stored config found, cannot resume") call.reject("No stored dual schedule config found") } } // MARK: - Main Scheduling Method /** * Schedule a daily notification * * This is the main scheduling method, equivalent to Android's scheduleDailyNotification. * Schedules both the notification and a prefetch 5 minutes before. * * @param call Plugin call with options: * - time: String (required) - Time in HH:mm format (e.g., "09:00") * - title: String (optional) - Notification title (default: "Daily Notification") * - body: String (optional) - Notification body (default: "") * - sound: Bool (optional) - Enable sound (default: true) * - priority: String (optional) - Priority: "high", "default", "low" (default: "default") * - url: String (optional) - URL for prefetch (optional, native fetcher used if registered) */ @objc func scheduleDailyNotification(_ call: CAPPluginCall) { // Check notification permissions first notificationCenter.getNotificationSettings { settings in if settings.authorizationStatus != .authorized { // Request permission if not granted self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in DispatchQueue.main.async { if let error = error { print("DNP-PLUGIN: Permission request failed: \(error)") call.reject("Notification permission request failed: \(error.localizedDescription)") return } if !granted { print("DNP-PLUGIN: Notification permission denied") call.reject("Notification permission denied. Please enable notifications in Settings.", "PERMISSION_DENIED") return } // Permission granted, proceed with scheduling self.performScheduleDailyNotification(call: call) } } } else { // Permission already granted, proceed self.performScheduleDailyNotification(call: call) } } } /** * Perform the actual scheduling after permission check */ private func performScheduleDailyNotification(call: CAPPluginCall) { guard let options = call.options else { call.reject("Options are required") return } guard let timeString = options["time"] as? String else { call.reject("Time is required (format: HH:mm)") return } let title = options["title"] as? String ?? "Daily Notification" let body = options["body"] as? String ?? "" let sound = options["sound"] as? Bool ?? true let priority = options["priority"] as? String ?? "default" let url = options["url"] as? String // Optional URL for prefetch print("DNP-PLUGIN: Scheduling daily notification: time=\(timeString), title=\(title)") // Parse time (HH:mm format) let timeComponents = timeString.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 } // Calculate next run time let calendar = Calendar.current let now = Date() var dateComponents = calendar.dateComponents([.year, .month, .day], from: now) dateComponents.hour = hour dateComponents.minute = minute dateComponents.second = 0 guard var nextRunDate = calendar.date(from: dateComponents) else { call.reject("Failed to calculate next run time") return } // If the time has already passed today, schedule for tomorrow if nextRunDate <= now { nextRunDate = calendar.date(byAdding: .day, value: 1, to: nextRunDate) ?? nextRunDate } let nextRunTime = nextRunDate.timeIntervalSince1970 * 1000 // Convert to milliseconds let nextRunTimeInterval = nextRunDate.timeIntervalSinceNow // Create notification content let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = sound ? .default : nil content.categoryIdentifier = "DAILY_NOTIFICATION" // Set priority/interruption level if #available(iOS 15.0, *) { switch priority.lowercased() { case "high", "max": content.interruptionLevel = .critical case "low", "min": content.interruptionLevel = .passive default: content.interruptionLevel = .active } } // Create date components for daily trigger var triggerComponents = DateComponents() triggerComponents.hour = hour triggerComponents.minute = minute let trigger = UNCalendarNotificationTrigger( dateMatching: triggerComponents, repeats: true // Daily repeat ) // Create unique identifier let scheduleId = "daily_\(Int(Date().timeIntervalSince1970 * 1000))" let request = UNNotificationRequest( identifier: scheduleId, content: content, trigger: trigger ) // Schedule the notification notificationCenter.add(request) { error in if let error = error { print("DNP-PLUGIN: Failed to schedule notification: \(error)") call.reject("Failed to schedule notification: \(error.localizedDescription)") return } print("DNP-PLUGIN: Notification scheduled successfully: \(scheduleId)") // Schedule prefetch 5 minutes before notification let fetchTime = nextRunTime - (5 * 60 * 1000) // 5 minutes before in milliseconds let fetchTimeInterval = (fetchTime / 1000) - Date().timeIntervalSince1970 if fetchTimeInterval > 0 { // Schedule background fetch task do { let fetchRequest = BGAppRefreshTaskRequest(identifier: self.fetchTaskIdentifier) fetchRequest.earliestBeginDate = Date(timeIntervalSinceNow: fetchTimeInterval) try self.backgroundTaskScheduler.submit(fetchRequest) print("DNP-PLUGIN: Prefetch scheduled: fetchTime=\(fetchTime), notificationTime=\(nextRunTime)") } catch { print("DNP-PLUGIN: Failed to schedule prefetch: \(error)") // Don't fail the whole operation if prefetch scheduling fails } } else { // Fetch time is in the past, trigger immediate fetch if possible print("DNP-PLUGIN: Fetch time is in the past, skipping prefetch scheduling") } // Store schedule in UserDefaults (similar to Android database storage) self.storeScheduleInUserDefaults( id: scheduleId, time: timeString, title: title, body: body, nextRunTime: nextRunTime ) call.resolve() } } /** * Store schedule in UserDefaults */ private func storeScheduleInUserDefaults( id: String, time: String, title: String, body: String, nextRunTime: TimeInterval ) { var schedules = UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? [] // Remove existing schedule with same ID if present schedules.removeAll { ($0["id"] as? String) == id } let schedule: [String: Any] = [ "id": id, "kind": "notify", "time": time, "title": title, "body": body, "nextRunTime": nextRunTime, "enabled": true, "createdAt": Date().timeIntervalSince1970 * 1000 ] schedules.append(schedule) UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") print("DNP-PLUGIN: Schedule stored: \(id)") } // MARK: - Status & Cancellation Methods /** * Get notification status * * Returns the current status of scheduled notifications, including: * - Whether notifications are enabled/scheduled * - Last notification time * - Next notification time * - Pending notification count * * Equivalent to Android's getNotificationStatus method. */ @objc func getNotificationStatus(_ call: CAPPluginCall) { // Get pending notifications from UNUserNotificationCenter notificationCenter.getPendingNotificationRequests { requests in // Filter for daily notifications (those starting with "daily_") let dailyNotifications = requests.filter { $0.identifier.hasPrefix("daily_") } // Get schedules from UserDefaults let schedules = self.getSchedulesFromUserDefaults() let notifySchedules = schedules.filter { ($0["kind"] as? String) == "notify" && ($0["enabled"] as? Bool) == true } // Calculate next notification time var nextNotificationTime: TimeInterval = 0 if let nextRunTimes = notifySchedules.compactMap({ $0["nextRunTime"] as? TimeInterval }) as? [TimeInterval], !nextRunTimes.isEmpty { nextNotificationTime = nextRunTimes.min() ?? 0 } // Get last notification time from UserDefaults (stored when notification is delivered) // For now, we'll use 0 if not available (history tracking can be added later) let lastNotificationTime = UserDefaults.standard.double(forKey: "DailyNotificationLastDeliveryTime") // Build result matching Android API let result: [String: Any] = [ "isEnabled": !notifySchedules.isEmpty, "isScheduled": !notifySchedules.isEmpty, "lastNotificationTime": lastNotificationTime > 0 ? lastNotificationTime : 0, "nextNotificationTime": nextNotificationTime, "scheduledCount": notifySchedules.count, "pending": dailyNotifications.count, "settings": [ "enabled": !notifySchedules.isEmpty, "count": notifySchedules.count ] as [String: Any] ] DispatchQueue.main.async { call.resolve(result) } } } /** * Cancel all notifications * * Cancels all scheduled daily notifications: * 1. Removes all pending notifications from UNUserNotificationCenter * 2. Cancels all background fetch tasks * 3. Clears schedules from UserDefaults * * Equivalent to Android's cancelAllNotifications method. * The method is idempotent - safe to call multiple times. */ @objc func cancelAllNotifications(_ call: CAPPluginCall) { print("DNP-PLUGIN: Cancelling all notifications") // 1. Get all pending notifications notificationCenter.getPendingNotificationRequests { requests in let dailyNotificationIds = requests .filter { $0.identifier.hasPrefix("daily_") } .map { $0.identifier } // 2. Remove all daily notifications if !dailyNotificationIds.isEmpty { self.notificationCenter.removePendingNotificationRequests(withIdentifiers: dailyNotificationIds) print("DNP-PLUGIN: Removed \(dailyNotificationIds.count) pending notification(s)") } // 3. Cancel all background tasks // Cancel by identifier (BGTaskScheduler requires identifier to cancel) // Note: cancel() doesn't throw, it's safe to call even if task doesn't exist self.backgroundTaskScheduler.cancel(taskRequestWithIdentifier: self.fetchTaskIdentifier) self.backgroundTaskScheduler.cancel(taskRequestWithIdentifier: self.notifyTaskIdentifier) print("DNP-PLUGIN: Cancelled background tasks") // 4. Clear schedules from UserDefaults var schedules = UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? [] let notifySchedules = schedules.filter { ($0["kind"] as? String) == "notify" } schedules.removeAll { ($0["kind"] as? String) == "notify" } UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") print("DNP-PLUGIN: Removed \(notifySchedules.count) schedule(s) from storage") DispatchQueue.main.async { print("DNP-PLUGIN: All notifications cancelled successfully") call.resolve() } } } /** * Get schedules from UserDefaults */ private func getSchedulesFromUserDefaults() -> [[String: Any]] { return UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? [] } // MARK: - Database Access Methods /** * Get schedules * * Returns schedules matching optional filters (kind, enabled). * * Equivalent to Android's getSchedules method. */ @objc func getSchedules(_ call: CAPPluginCall) { let options = call.getObject("options") let kind = options?["kind"] as? String let enabled = options?["enabled"] as? Bool print("DNP-PLUGIN: Getting schedules: kind=\(kind ?? "all"), enabled=\(enabled?.description ?? "all")") var schedules = getSchedulesFromUserDefaults() // Apply filters if let kindFilter = kind { schedules = schedules.filter { ($0["kind"] as? String) == kindFilter } } if let enabledFilter = enabled { schedules = schedules.filter { ($0["enabled"] as? Bool) == enabledFilter } } let result: [String: Any] = [ "schedules": schedules ] print("DNP-PLUGIN: Found \(schedules.count) schedule(s)") call.resolve(result) } /** * Get schedule by ID * * Returns a single schedule by ID. * * Equivalent to Android's getSchedule method. */ @objc func getSchedule(_ call: CAPPluginCall) { guard let id = call.getString("id") else { call.reject("Schedule ID is required") return } print("DNP-PLUGIN: Getting schedule: \(id)") let schedules = getSchedulesFromUserDefaults() let schedule = schedules.first { ($0["id"] as? String) == id } if let schedule = schedule { call.resolve(schedule) } else { call.resolve(["schedule": NSNull()]) } } /** * Get config * * Returns configuration value by key. * * Equivalent to Android's getConfig method. */ @objc func getConfig(_ call: CAPPluginCall) { guard let key = call.getString("key") else { call.reject("Config key is required") return } let options = call.getObject("options") let timesafariDid = options?["timesafariDid"] as? String print("DNP-PLUGIN: Getting config: key=\(key), did=\(timesafariDid ?? "none")") // Build config key (include DID if provided) let configKey = timesafariDid != nil ? "\(key)_\(timesafariDid!)" : key let fullKey = "DailyNotificationConfig_\(configKey)" // Try to get config from UserDefaults if let configString = UserDefaults.standard.string(forKey: fullKey), let configData = configString.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] { call.resolve(config) } else { // Return null if not found call.resolve(["config": NSNull()]) } } /** * Set config * * Stores configuration value by key. * * Equivalent to Android's setConfig method. */ @objc func setConfig(_ call: CAPPluginCall) { guard let key = call.getString("key") else { call.reject("Config key is required") return } guard let value = call.getObject("value") else { call.reject("Config value is required") return } let options = call.getObject("options") let timesafariDid = options?["timesafariDid"] as? String print("DNP-PLUGIN: Setting config: key=\(key), did=\(timesafariDid ?? "none")") // Build config key (include DID if provided) let configKey = timesafariDid != nil ? "\(key)_\(timesafariDid!)" : key let fullKey = "DailyNotificationConfig_\(configKey)" // Store config as JSON string do { let configData = try JSONSerialization.data(withJSONObject: value, options: []) let configString = String(data: configData, encoding: .utf8) ?? "{}" UserDefaults.standard.set(configString, forKey: fullKey) UserDefaults.standard.synchronize() print("DNP-PLUGIN: Config stored successfully") call.resolve() } catch { print("DNP-PLUGIN: Failed to store config: \(error)") call.reject("Failed to store config: \(error.localizedDescription)") } } /** * Create schedule * * Creates a new schedule and stores it in UserDefaults. * * Equivalent to Android's createSchedule method. */ @objc func createSchedule(_ call: CAPPluginCall) { guard let scheduleJson = call.getObject("schedule") else { call.reject("Schedule data is required") return } guard let kind = scheduleJson["kind"] as? String else { call.reject("Schedule kind is required") return } // Generate ID if not provided let id = scheduleJson["id"] as? String ?? "\(kind)_\(Int64(Date().timeIntervalSince1970 * 1000))" print("DNP-PLUGIN: Creating schedule: id=\(id), kind=\(kind)") // Build schedule dictionary var schedule: [String: Any] = [ "id": id, "kind": kind, "enabled": scheduleJson["enabled"] as? Bool ?? true, "createdAt": Int64(Date().timeIntervalSince1970 * 1000) ] // Add optional fields if let cron = scheduleJson["cron"] as? String { schedule["cron"] = cron } if let clockTime = scheduleJson["clockTime"] as? String { schedule["clockTime"] = clockTime } if let jitterMs = scheduleJson["jitterMs"] as? Int { schedule["jitterMs"] = jitterMs } if let backoffPolicy = scheduleJson["backoffPolicy"] as? String { schedule["backoffPolicy"] = backoffPolicy } if let stateJson = scheduleJson["stateJson"] as? String { schedule["stateJson"] = stateJson } // Add to schedules array var schedules = getSchedulesFromUserDefaults() schedules.append(schedule) UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Schedule created successfully") call.resolve(schedule) } /** * Update schedule * * Updates an existing schedule in UserDefaults. * * Equivalent to Android's updateSchedule method. */ @objc func updateSchedule(_ call: CAPPluginCall) { guard let id = call.getString("id") else { call.reject("Schedule ID is required") return } guard let updates = call.getObject("updates") else { call.reject("Updates are required") return } print("DNP-PLUGIN: Updating schedule: id=\(id)") var schedules = getSchedulesFromUserDefaults() guard let index = schedules.firstIndex(where: { ($0["id"] as? String) == id }) else { call.reject("Schedule not found: \(id)") return } // Update schedule fields var schedule = schedules[index] if let enabled = updates["enabled"] as? Bool { schedule["enabled"] = enabled } if let cron = updates["cron"] as? String { schedule["cron"] = cron } if let clockTime = updates["clockTime"] as? String { schedule["clockTime"] = clockTime } if let jitterMs = updates["jitterMs"] as? Int { schedule["jitterMs"] = jitterMs } if let backoffPolicy = updates["backoffPolicy"] as? String { schedule["backoffPolicy"] = backoffPolicy } if let stateJson = updates["stateJson"] as? String { schedule["stateJson"] = stateJson } if let lastRunAt = updates["lastRunAt"] as? Int64 { schedule["lastRunAt"] = lastRunAt } if let nextRunAt = updates["nextRunAt"] as? Int64 { schedule["nextRunAt"] = nextRunAt } schedules[index] = schedule UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Schedule updated successfully") call.resolve(schedule) } /** * Delete schedule * * Deletes a schedule from UserDefaults. * * Equivalent to Android's deleteSchedule method. */ @objc func deleteSchedule(_ call: CAPPluginCall) { guard let id = call.getString("id") else { call.reject("Schedule ID is required") return } print("DNP-PLUGIN: Deleting schedule: id=\(id)") var schedules = getSchedulesFromUserDefaults() let initialCount = schedules.count schedules.removeAll { ($0["id"] as? String) == id } if schedules.count == initialCount { call.reject("Schedule not found: \(id)") return } UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Schedule deleted successfully") call.resolve() } /** * Enable/disable schedule * * Enables or disables a schedule. * * Equivalent to Android's enableSchedule method. */ @objc func enableSchedule(_ call: CAPPluginCall) { guard let id = call.getString("id") else { call.reject("Schedule ID is required") return } let enabled = call.getBool("enabled") ?? true print("DNP-PLUGIN: Setting schedule enabled: id=\(id), enabled=\(enabled)") var schedules = getSchedulesFromUserDefaults() guard let index = schedules.firstIndex(where: { ($0["id"] as? String) == id }) else { call.reject("Schedule not found: \(id)") return } schedules[index]["enabled"] = enabled UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Schedule enabled status updated") call.resolve() } /** * Calculate next run time * * Calculates the next run time from a cron expression or HH:mm time string. * * Equivalent to Android's calculateNextRunTime method. */ @objc func calculateNextRunTime(_ call: CAPPluginCall) { guard let schedule = call.getString("schedule") else { call.reject("Schedule expression is required") return } print("DNP-PLUGIN: Calculating next run time: schedule=\(schedule)") let nextRunAt = calculateNextRunTimeFromSchedule(schedule) let result: [String: Any] = [ "nextRunAt": Int64(nextRunAt) ] print("DNP-PLUGIN: Next run time calculated: \(nextRunAt)") call.resolve(result) } /** * Calculate next run time from schedule string * * Supports both cron format ("minute hour * * *") and HH:mm format ("09:30"). */ private func calculateNextRunTimeFromSchedule(_ schedule: String) -> TimeInterval { let calendar = Calendar.current let now = Date() // Try to parse as HH:mm first if schedule.contains(":") { let parts = schedule.split(separator: ":") if parts.count == 2, let hour = Int(parts[0]), let minute = Int(parts[1]), hour >= 0 && hour <= 23, minute >= 0 && minute <= 59 { var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now) components.hour = hour components.minute = minute components.second = 0 if let targetDate = calendar.date(from: components) { // If time has passed today, schedule for tomorrow if targetDate <= now { if let tomorrow = calendar.date(byAdding: .day, value: 1, to: targetDate) { return tomorrow.timeIntervalSince1970 * 1000 } } return targetDate.timeIntervalSince1970 * 1000 } } } // Try to parse as cron expression: "minute hour * * *" let parts = schedule.trimmingCharacters(in: .whitespaces).split(separator: " ") if parts.count >= 2, let minute = Int(parts[0]), let hour = Int(parts[1]), minute >= 0 && minute <= 59, hour >= 0 && hour <= 23 { var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now) components.hour = hour components.minute = minute components.second = 0 if let targetDate = calendar.date(from: components) { // If time has passed today, schedule for tomorrow if targetDate <= now { if let tomorrow = calendar.date(byAdding: .day, value: 1, to: targetDate) { return tomorrow.timeIntervalSince1970 * 1000 } } return targetDate.timeIntervalSince1970 * 1000 } } // Fallback: 24 hours from now print("DNP-PLUGIN: Invalid schedule format, defaulting to 24h from now") return (now.addingTimeInterval(24 * 60 * 60).timeIntervalSince1970 * 1000) } // MARK: - History Methods /** * Get history * * Returns history entries with optional filters (since, kind, limit). * * Equivalent to Android's getHistory method. */ @objc func getHistory(_ call: CAPPluginCall) { let options = call.getObject("options") let since = options?["since"] as? Int64 let kind = options?["kind"] as? String let limit = (options?["limit"] as? Int) ?? 50 print("DNP-PLUGIN: Getting history: since=\(since?.description ?? "none"), kind=\(kind ?? "all"), limit=\(limit)") Task { do { let context = persistenceController.container.viewContext let request: NSFetchRequest = History.fetchRequest() // Build predicate var predicates: [NSPredicate] = [] if let sinceTimestamp = since { let sinceDate = Date(timeIntervalSince1970: TimeInterval(sinceTimestamp) / 1000.0) predicates.append(NSPredicate(format: "occurredAt >= %@", sinceDate as NSDate)) } if let kindFilter = kind { predicates.append(NSPredicate(format: "kind == %@", kindFilter)) } if !predicates.isEmpty { request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) } // Sort by occurredAt descending (most recent first) request.sortDescriptors = [NSSortDescriptor(keyPath: \History.occurredAt, ascending: false)] request.fetchLimit = limit let results = try context.fetch(request) let historyArray = results.map { history -> [String: Any] in [ "id": history.id ?? "", "refId": history.refId ?? "", "kind": history.kind ?? "", "occurredAt": Int64((history.occurredAt?.timeIntervalSince1970 ?? 0) * 1000), "durationMs": history.durationMs, "outcome": history.outcome ?? "", "diagJson": history.diagJson ?? "" ] } let result: [String: Any] = [ "history": historyArray ] print("DNP-PLUGIN: Found \(historyArray.count) history entry(ies)") DispatchQueue.main.async { call.resolve(result) } } catch { print("DNP-PLUGIN: Failed to get history: \(error)") DispatchQueue.main.async { call.reject("Failed to get history: \(error.localizedDescription)") } } } } /** * Get history stats * * Returns statistics about history entries. * * Equivalent to Android's getHistoryStats method. */ @objc func getHistoryStats(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting history stats") Task { do { let context = persistenceController.container.viewContext let request: NSFetchRequest = History.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \History.occurredAt, ascending: false)] let allHistory = try context.fetch(request) var outcomes: [String: Int] = [:] var kinds: [String: Int] = [:] var mostRecent: TimeInterval? = nil var oldest: TimeInterval? = nil for entry in allHistory { // Count outcomes if let outcome = entry.outcome { outcomes[outcome] = (outcomes[outcome] ?? 0) + 1 } // Count kinds if let kind = entry.kind { kinds[kind] = (kinds[kind] ?? 0) + 1 } // Track timestamps if let occurredAt = entry.occurredAt { let timestamp = occurredAt.timeIntervalSince1970 * 1000 if mostRecent == nil || timestamp > mostRecent! { mostRecent = timestamp } if oldest == nil || timestamp < oldest! { oldest = timestamp } } } let result: [String: Any] = [ "totalCount": allHistory.count, "outcomes": outcomes, "kinds": kinds, "mostRecent": mostRecent ?? NSNull(), "oldest": oldest ?? NSNull() ] print("DNP-PLUGIN: History stats: total=\(allHistory.count), outcomes=\(outcomes.count), kinds=\(kinds.count)") DispatchQueue.main.async { call.resolve(result) } } catch { print("DNP-PLUGIN: Failed to get history stats: \(error)") DispatchQueue.main.async { call.reject("Failed to get history stats: \(error.localizedDescription)") } } } } // MARK: - Config Methods /** * Get all configs * * Returns all configurations matching optional filters. * * Equivalent to Android's getAllConfigs method. */ @objc func getAllConfigs(_ call: CAPPluginCall) { let options = call.getObject("options") let timesafariDid = options?["timesafariDid"] as? String let configType = options?["configType"] as? String print("DNP-PLUGIN: Getting all configs: did=\(timesafariDid ?? "none"), type=\(configType ?? "all")") // Get all UserDefaults keys that start with our prefix let prefix = "DailyNotificationConfig_" var configs: [[String: Any]] = [] // Note: UserDefaults doesn't support listing all keys directly // We'll need to maintain a list of config keys or use a different approach // For now, return empty array with a note that this is a limitation // In production, you might want to maintain a separate list of config keys let result: [String: Any] = [ "configs": configs ] print("DNP-PLUGIN: Found \(configs.count) config(s) (Note: UserDefaults doesn't support key enumeration)") call.resolve(result) } /** * Update config * * Updates an existing configuration value. * * Equivalent to Android's updateConfig method. */ @objc func updateConfig(_ call: CAPPluginCall) { guard let key = call.getString("key") else { call.reject("Config key is required") return } guard let value = call.getString("value") else { call.reject("Config value is required") return } let options = call.getObject("options") let timesafariDid = options?["timesafariDid"] as? String print("DNP-PLUGIN: Updating config: key=\(key), did=\(timesafariDid ?? "none")") // Build config key (include DID if provided) let configKey = timesafariDid != nil ? "\(key)_\(timesafariDid!)" : key let fullKey = "DailyNotificationConfig_\(configKey)" // Check if config exists guard UserDefaults.standard.string(forKey: fullKey) != nil else { call.reject("Config not found") return } // Update config value (store as JSON string) do { // Try to parse value as JSON, if it fails, store as plain string let configValue: [String: Any] if let jsonData = value.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { configValue = json } else { // Store as plain string value configValue = ["value": value] } let configData = try JSONSerialization.data(withJSONObject: configValue, options: []) let configString = String(data: configData, encoding: .utf8) ?? "{}" UserDefaults.standard.set(configString, forKey: fullKey) UserDefaults.standard.synchronize() print("DNP-PLUGIN: Config updated successfully") call.resolve(configValue) } catch { print("DNP-PLUGIN: Failed to update config: \(error)") call.reject("Failed to update config: \(error.localizedDescription)") } } /** * Delete config * * Deletes a configuration by key. * * Equivalent to Android's deleteConfig method. */ @objc func deleteConfig(_ call: CAPPluginCall) { guard let key = call.getString("key") else { call.reject("Config key is required") return } let options = call.getObject("options") let timesafariDid = options?["timesafariDid"] as? String print("DNP-PLUGIN: Deleting config: key=\(key), did=\(timesafariDid ?? "none")") // Build config key (include DID if provided) let configKey = timesafariDid != nil ? "\(key)_\(timesafariDid!)" : key let fullKey = "DailyNotificationConfig_\(configKey)" // Check if config exists guard UserDefaults.standard.string(forKey: fullKey) != nil else { call.reject("Config not found") return } UserDefaults.standard.removeObject(forKey: fullKey) UserDefaults.standard.synchronize() print("DNP-PLUGIN: Config deleted successfully") call.resolve() } // MARK: - Permission Methods /** * Check permission status * * Returns boolean flags for each permission type: * - notificationsEnabled: Notification authorization status * - exactAlarmEnabled: Background App Refresh status (iOS equivalent) * - wakeLockEnabled: Always true on iOS (not applicable) * - allPermissionsGranted: All permissions granted * * Equivalent to Android's checkPermissionStatus method. */ @objc func checkPermissionStatus(_ call: CAPPluginCall) { print("DNP-PLUGIN: Checking permission status") // Check notification authorization notificationCenter.getNotificationSettings { settings in let notificationsEnabled = settings.authorizationStatus == .authorized // Check Background App Refresh (iOS equivalent of exact alarm permission) // Note: We can't directly check this, but we can infer it's enabled // if the app is allowed to run background tasks // For now, we'll assume it's enabled if notifications are authorized // (Background App Refresh is a system setting, not a runtime permission) let exactAlarmEnabled = notificationsEnabled // Simplified - actual check would require checking system settings // Wake lock is not applicable on iOS (always available if app is running) let wakeLockEnabled = true let allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled let result: [String: Any] = [ "notificationsEnabled": notificationsEnabled, "exactAlarmEnabled": exactAlarmEnabled, "wakeLockEnabled": wakeLockEnabled, "allPermissionsGranted": allPermissionsGranted ] print("DNP-PLUGIN: Permission status: notifications=\(notificationsEnabled), exactAlarm=\(exactAlarmEnabled), wakeLock=\(wakeLockEnabled), all=\(allPermissionsGranted)") DispatchQueue.main.async { call.resolve(result) } } } /** * Request notification permissions * * Requests notification authorization from the user. * Returns PermissionStatus matching Android API format. * * Equivalent to Android's requestNotificationPermissions method. */ @objc func requestNotificationPermissions(_ call: CAPPluginCall) { print("DNP-PLUGIN: Requesting notification permissions") // Check current authorization status notificationCenter.getNotificationSettings { settings in if settings.authorizationStatus == .authorized { // Already granted let result: [String: Any] = [ "status": "granted", "granted": true, "notifications": "granted", "alert": settings.alertSetting == .enabled, "badge": settings.badgeSetting == .enabled, "sound": settings.soundSetting == .enabled, "lockScreen": settings.lockScreenSetting == .enabled, "carPlay": settings.carPlaySetting == .enabled ] DispatchQueue.main.async { call.resolve(result) } return } // Request authorization self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in DispatchQueue.main.async { if let error = error { print("DNP-PLUGIN: Permission request failed: \(error)") call.reject("Permission request failed: \(error.localizedDescription)") return } // Get updated settings after request self.notificationCenter.getNotificationSettings { updatedSettings in let result: [String: Any] = [ "status": granted ? "granted" : "denied", "granted": granted, "notifications": granted ? "granted" : "denied", "alert": updatedSettings.alertSetting == .enabled, "badge": updatedSettings.badgeSetting == .enabled, "sound": updatedSettings.soundSetting == .enabled, "lockScreen": updatedSettings.lockScreenSetting == .enabled, "carPlay": updatedSettings.carPlaySetting == .enabled ] DispatchQueue.main.async { print("DNP-PLUGIN: Permission request completed: granted=\(granted)") call.resolve(result) } } } } } } /** * Check permissions (Capacitor standard format) * * Returns PermissionStatus with notifications field as PermissionState. * This is the standard Capacitor permission check format. */ @objc func checkPermissions(_ call: CAPPluginCall) { print("DNP-PLUGIN: Checking permissions (Capacitor format)") notificationCenter.getNotificationSettings { settings in let permissionState: String switch settings.authorizationStatus { case .authorized: permissionState = "granted" case .denied: permissionState = "denied" case .notDetermined: permissionState = "prompt" case .provisional: permissionState = "provisional" @unknown default: permissionState = "unknown" } let result: [String: Any] = [ "status": permissionState, "granted": settings.authorizationStatus == .authorized, "notifications": permissionState, "alert": settings.alertSetting == .enabled, "badge": settings.badgeSetting == .enabled, "sound": settings.soundSetting == .enabled, "lockScreen": settings.lockScreenSetting == .enabled, "carPlay": settings.carPlaySetting == .enabled ] DispatchQueue.main.async { call.resolve(result) } } } /** * Request permissions (alias for requestNotificationPermissions) * * Standard Capacitor permission request method. */ @objc func requestPermissions(_ call: CAPPluginCall) { // Delegate to requestNotificationPermissions requestNotificationPermissions(call) } /** * Get battery status * * Returns battery information including: * - level: Battery level (0-100) * - isCharging: Whether device is charging * - powerState: Power state code * - isOptimizationExempt: Whether app is exempt from battery optimization (iOS: always false) * * Equivalent to Android's getBatteryStatus method. */ @objc func getBatteryStatus(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting battery status") // Enable battery monitoring if not already enabled UIDevice.current.isBatteryMonitoringEnabled = true // Get battery level (0.0 to 1.0, -1.0 if unknown) let batteryLevel = UIDevice.current.batteryLevel let batteryLevelPercent = batteryLevel >= 0 ? Int(batteryLevel * 100) : -1 // Get battery state let batteryState = UIDevice.current.batteryState let isCharging = batteryState == .charging || batteryState == .full // Map battery state to power state code // 0 = unknown, 1 = unplugged, 2 = charging, 3 = full let powerState: Int switch batteryState { case .unknown: powerState = 0 case .unplugged: powerState = 1 case .charging: powerState = 2 case .full: powerState = 3 @unknown default: powerState = 0 } // iOS doesn't have battery optimization like Android // Background App Refresh is the closest equivalent, but we can't check it directly // Return false to indicate we're subject to system power management let isOptimizationExempt = false let result: [String: Any] = [ "level": batteryLevelPercent, "isCharging": isCharging, "powerState": powerState, "isOptimizationExempt": isOptimizationExempt ] print("DNP-PLUGIN: Battery status: level=\(batteryLevelPercent)%, charging=\(isCharging), state=\(powerState)") call.resolve(result) } // MARK: - Configuration Methods /** * Update starred plan IDs * * Stores plan IDs in UserDefaults for native fetcher to use. * Matches Android SharedPreferences storage pattern. * * Equivalent to Android's updateStarredPlans method. */ @objc func updateStarredPlans(_ call: CAPPluginCall) { guard let options = call.options else { call.reject("Options are required") return } // Extract planIds array from options guard let planIdsValue = options["planIds"] else { call.reject("planIds array is required") return } var planIds: [String] = [] // Handle different array formats from Capacitor if let planIdsArray = planIdsValue as? [String] { planIds = planIdsArray } else if let planIdsArray = planIdsValue as? [Any] { planIds = planIdsArray.compactMap { $0 as? String } } else if let singlePlanId = planIdsValue as? String { planIds = [singlePlanId] } else { call.reject("planIds must be an array of strings") return } print("DNP-PLUGIN: Updating starred plans: count=\(planIds.count)") // Store in UserDefaults (matching Android SharedPreferences) // Use suite name to match Android prefs name pattern let prefsName = "daily_notification_timesafari" let keyStarredPlanIds = "starredPlanIds" // Convert planIds to JSON array string (matching Android format) do { let jsonData = try JSONSerialization.data(withJSONObject: planIds, options: []) let jsonString = String(data: jsonData, encoding: .utf8) ?? "[]" // Store in UserDefaults with suite name let userDefaults = UserDefaults(suiteName: prefsName) ?? UserDefaults.standard userDefaults.set(jsonString, forKey: keyStarredPlanIds) userDefaults.synchronize() let result: [String: Any] = [ "success": true, "planIdsCount": planIds.count, "updatedAt": Int64(Date().timeIntervalSince1970 * 1000) ] print("DNP-PLUGIN: Starred plans updated: count=\(planIds.count)") call.resolve(result) } catch { print("DNP-PLUGIN: Failed to serialize planIds: \(error)") call.reject("Failed to update starred plans: \(error.localizedDescription)") } } /** * Configure native fetcher * * Configures the native content fetcher with API credentials. * Stores configuration in UserDefaults for persistence. * * Note: iOS doesn't have the same native fetcher interface as Android. * Configuration is stored and can be used by background fetch tasks. * * Equivalent to Android's configureNativeFetcher method. */ @objc func configureNativeFetcher(_ call: CAPPluginCall) { guard let options = call.options else { call.reject("Options are required") return } // Extract required parameters guard let apiBaseUrl = options["apiBaseUrl"] as? String else { call.reject("apiBaseUrl is required") return } guard let activeDid = options["activeDid"] as? String else { call.reject("activeDid is required") return } // Support both jwtToken and jwtSecret for backward compatibility guard let jwtToken = (options["jwtToken"] as? String) ?? (options["jwtSecret"] as? String) else { call.reject("jwtToken or jwtSecret is required") return } print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...") // Store configuration in UserDefaults (matching Android database storage) let configId = "native_fetcher_config" let configKey = "DailyNotificationNativeFetcherConfig" let config: [String: Any] = [ "apiBaseUrl": apiBaseUrl, "activeDid": activeDid, "jwtToken": jwtToken, "configuredAt": Date().timeIntervalSince1970 * 1000 ] // Store as JSON string for consistency with Android do { let jsonData = try JSONSerialization.data(withJSONObject: config, options: []) let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" UserDefaults.standard.set(jsonString, forKey: configKey) UserDefaults.standard.synchronize() print("DNP-PLUGIN: Native fetcher configuration stored successfully") call.resolve() } catch { print("DNP-PLUGIN: Failed to store native fetcher config: \(error)") call.reject("Failed to store configuration: \(error.localizedDescription)") } } /** * Set active DID from host * * Sets the active DID (identity) for TimeSafari integration. * This is a simpler method than configureNativeFetcher for just updating the DID. * * Equivalent to Android's setActiveDidFromHost method. */ @objc func setActiveDidFromHost(_ call: CAPPluginCall) { guard let activeDid = call.getString("activeDid") else { call.reject("activeDid is required") return } print("DNP-PLUGIN: Setting activeDid from host: \(activeDid.prefix(30))...") // Store activeDid in UserDefaults let keyActiveDid = "DailyNotificationActiveDid" UserDefaults.standard.set(activeDid, forKey: keyActiveDid) UserDefaults.standard.synchronize() // If there's existing native fetcher config, update it let configKey = "DailyNotificationNativeFetcherConfig" if let configJson = UserDefaults.standard.string(forKey: configKey), let configData = configJson.data(using: .utf8), var config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] { config["activeDid"] = activeDid config["updatedAt"] = Date().timeIntervalSince1970 * 1000 if let updatedJsonData = try? JSONSerialization.data(withJSONObject: config, options: []), let updatedJsonString = String(data: updatedJsonData, encoding: .utf8) { UserDefaults.standard.set(updatedJsonString, forKey: configKey) UserDefaults.standard.synchronize() } } print("DNP-PLUGIN: ActiveDid set successfully") call.resolve() } // MARK: - Content Management Methods /** * Get content cache * * Returns the latest cached content from Core Data. * * Equivalent to Android's getContentCache method. */ @objc func getContentCache(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting content cache") Task { do { let context = persistenceController.container.viewContext let request: NSFetchRequest = ContentCache.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \ContentCache.fetchedAt, ascending: false)] request.fetchLimit = 1 let results = try context.fetch(request) if let latest = results.first { let payload = try JSONSerialization.jsonObject(with: latest.payload!) as? [String: Any] ?? [:] let result: [String: Any] = [ "id": latest.id ?? "", "fetchedAt": Int64((latest.fetchedAt?.timeIntervalSince1970 ?? 0) * 1000), "ttlSeconds": latest.ttlSeconds, "payload": payload, "meta": latest.meta ?? "" ] DispatchQueue.main.async { call.resolve(result) } } else { // Return empty result if no cache DispatchQueue.main.async { call.resolve([:]) } } } catch { print("DNP-PLUGIN: Failed to get content cache: \(error)") DispatchQueue.main.async { call.reject("Content cache retrieval failed: \(error.localizedDescription)") } } } } /** * Clear content cache * * Clears all content cache entries from Core Data. * * Equivalent to Android's clearContentCache method. */ @objc func clearContentCache(_ call: CAPPluginCall) { print("DNP-PLUGIN: Clearing content cache") Task { do { let context = persistenceController.container.viewContext let request: NSFetchRequest = ContentCache.fetchRequest() let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) try context.save() print("DNP-PLUGIN: Content cache cleared successfully") DispatchQueue.main.async { call.resolve() } } catch { print("DNP-PLUGIN: Failed to clear content cache: \(error)") DispatchQueue.main.async { call.reject("Failed to clear content cache: \(error.localizedDescription)") } } } } /** * Get content cache by ID * * Returns content cache by ID, or latest if ID not provided. * * Equivalent to Android's getContentCacheById method. */ @objc func getContentCacheById(_ call: CAPPluginCall) { let options = call.getObject("options") let id = options?["id"] as? String print("DNP-PLUGIN: Getting content cache: id=\(id ?? "latest")") Task { do { let context = persistenceController.container.viewContext let request: NSFetchRequest = ContentCache.fetchRequest() if let cacheId = id { request.predicate = NSPredicate(format: "id == %@", cacheId) } else { request.sortDescriptors = [NSSortDescriptor(keyPath: \ContentCache.fetchedAt, ascending: false)] request.fetchLimit = 1 } let results = try context.fetch(request) if let cache = results.first { let payload = try JSONSerialization.jsonObject(with: cache.payload!) as? [String: Any] ?? [:] let result: [String: Any] = [ "id": cache.id ?? "", "fetchedAt": Int64((cache.fetchedAt?.timeIntervalSince1970 ?? 0) * 1000), "ttlSeconds": cache.ttlSeconds, "payload": payload, "meta": cache.meta ?? "" ] DispatchQueue.main.async { call.resolve(result) } } else { DispatchQueue.main.async { call.resolve(["contentCache": NSNull()]) } } } catch { print("DNP-PLUGIN: Failed to get content cache: \(error)") DispatchQueue.main.async { call.reject("Failed to get content cache: \(error.localizedDescription)") } } } } /** * Get latest content cache * * Returns the latest content cache entry. * * Equivalent to Android's getLatestContentCache method. */ @objc func getLatestContentCache(_ call: CAPPluginCall) { // Delegate to getContentCacheById with no ID (returns latest) let options: [String: Any] = [:] call.options = options getContentCacheById(call) } /** * Save content cache * * Saves content to cache in Core Data. * * Equivalent to Android's saveContentCache method. */ @objc func saveContentCache(_ call: CAPPluginCall) { guard let contentJson = call.getObject("content") else { call.reject("Content data is required") return } guard let payloadString = contentJson["payload"] as? String else { call.reject("Payload is required") return } guard let ttlSeconds = contentJson["ttlSeconds"] as? Int else { call.reject("TTL seconds is required") return } let id = contentJson["id"] as? String ?? "cache_\(Int64(Date().timeIntervalSince1970 * 1000))" let meta = contentJson["meta"] as? String print("DNP-PLUGIN: Saving content cache: id=\(id)") Task { do { let context = persistenceController.container.viewContext // Convert payload string to Data guard let payloadData = payloadString.data(using: .utf8) else { throw NSError(domain: "DailyNotificationPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid payload data"]) } let cache = ContentCache(context: context) cache.id = id cache.fetchedAt = Date() cache.ttlSeconds = Int32(ttlSeconds) cache.payload = payloadData cache.meta = meta try context.save() let result: [String: Any] = [ "id": id, "fetchedAt": Int64(Date().timeIntervalSince1970 * 1000), "ttlSeconds": ttlSeconds, "payload": try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] ?? [:], "meta": meta ?? "" ] print("DNP-PLUGIN: Content cache saved successfully") DispatchQueue.main.async { call.resolve(result) } } catch { print("DNP-PLUGIN: Failed to save content cache: \(error)") DispatchQueue.main.async { call.reject("Failed to save content cache: \(error.localizedDescription)") } } } } /** * Get power state * * Returns power state information. * * Equivalent to Android's getPowerState method. */ @objc func getPowerState(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting power state") // iOS doesn't have battery optimization like Android // Background App Refresh is the closest equivalent, but we can't check it directly let isOptimizationExempt = false // Get battery state for power state code UIDevice.current.isBatteryMonitoringEnabled = true let batteryState = UIDevice.current.batteryState // Map battery state to power state code (same as getBatteryStatus) let powerState: Int switch batteryState { case .unknown: powerState = 0 case .unplugged: powerState = 1 case .charging: powerState = 2 case .full: powerState = 3 @unknown default: powerState = 0 } let result: [String: Any] = [ "powerState": powerState, "isOptimizationExempt": isOptimizationExempt ] print("DNP-PLUGIN: Power state: \(powerState), optimizationExempt=\(isOptimizationExempt)") call.resolve(result) } /** * Request battery optimization exemption * * On iOS, this is a no-op as iOS doesn't have battery optimization settings * like Android. Background App Refresh is controlled by the user in Settings. * * Equivalent to Android's requestBatteryOptimizationExemption method. */ @objc func requestBatteryOptimizationExemption(_ call: CAPPluginCall) { print("DNP-PLUGIN: Requesting battery optimization exemption (iOS: no-op)") // iOS doesn't have battery optimization exemption like Android // Background App Refresh is a system setting that users control // We can't programmatically request exemption on iOS // This method exists for API compatibility but does nothing call.resolve() } /** * Set adaptive scheduling * * Enables or disables adaptive scheduling features. * * Equivalent to Android's setAdaptiveScheduling method. */ @objc func setAdaptiveScheduling(_ call: CAPPluginCall) { guard let options = call.options else { call.reject("Options are required") return } guard let enabled = options["enabled"] as? Bool else { call.reject("enabled boolean is required") return } print("DNP-PLUGIN: Setting adaptive scheduling: enabled=\(enabled)") // Store adaptive scheduling setting in UserDefaults UserDefaults.standard.set(enabled, forKey: "DailyNotificationAdaptiveScheduling") UserDefaults.standard.synchronize() print("DNP-PLUGIN: Adaptive scheduling set successfully") call.resolve() } /** * Check if channel is enabled * * iOS doesn't have notification channels like Android. * This checks if notifications are enabled for the app. * * Equivalent to Android's isChannelEnabled method. */ @objc func isChannelEnabled(_ call: CAPPluginCall) { let channelId = call.getString("channelId") ?? "daily_notification_channel" print("DNP-PLUGIN: Checking channel enabled: \(channelId)") notificationCenter.getNotificationSettings { settings in let enabled = settings.authorizationStatus == .authorized let result: [String: Any] = [ "enabled": enabled, "channelId": channelId ] DispatchQueue.main.async { call.resolve(result) } } } /** * Open channel settings * * Opens iOS notification settings for the app. * iOS doesn't have per-channel settings like Android. * * Equivalent to Android's openChannelSettings method. */ @objc func openChannelSettings(_ call: CAPPluginCall) { let channelId = call.getString("channelId") ?? "daily_notification_channel" print("DNP-PLUGIN: Opening channel settings: \(channelId)") // iOS doesn't have per-channel settings // Open app notification settings instead if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { if UIApplication.shared.canOpenURL(settingsUrl) { UIApplication.shared.open(settingsUrl) { success in let result: [String: Any] = [ "opened": success, "channelId": channelId ] call.resolve(result) } } else { let result: [String: Any] = [ "opened": false, "channelId": channelId, "error": "Cannot open settings URL" ] call.resolve(result) } } else { let result: [String: Any] = [ "opened": false, "channelId": channelId, "error": "Invalid settings URL" ] call.resolve(result) } } /** * Check status * * Comprehensive status check for notifications. * Returns permission status, scheduling status, and other relevant information. * * Equivalent to Android's checkStatus method. */ @objc func checkStatus(_ call: CAPPluginCall) { print("DNP-PLUGIN: Checking comprehensive status") // Get notification authorization status notificationCenter.getNotificationSettings { settings in let notificationsEnabled = settings.authorizationStatus == .authorized // Get notification status self.notificationCenter.getPendingNotificationRequests { requests in let dailyNotifications = requests.filter { $0.identifier.hasPrefix("daily_") } // Get schedules from UserDefaults let schedules = self.getSchedulesFromUserDefaults() let notifySchedules = schedules.filter { ($0["kind"] as? String) == "notify" && ($0["enabled"] as? Bool) == true } // Calculate next notification time var nextNotificationTime: TimeInterval = 0 if let nextRunTimes = notifySchedules.compactMap({ $0["nextRunTime"] as? TimeInterval }) as? [TimeInterval], !nextRunTimes.isEmpty { nextNotificationTime = nextRunTimes.min() ?? 0 } let result: [String: Any] = [ "notificationsEnabled": notificationsEnabled, "isScheduled": !notifySchedules.isEmpty, "scheduledCount": notifySchedules.count, "pendingCount": dailyNotifications.count, "nextNotificationTime": nextNotificationTime, "channelId": "daily_notification_channel", "channelEnabled": notificationsEnabled ] DispatchQueue.main.async { call.resolve(result) } } } } // MARK: - Alarm Status Methods /** * Check if alarm is scheduled * * Checks if a notification is scheduled for the given trigger time. * * Equivalent to Android's isAlarmScheduled method. */ @objc func isAlarmScheduled(_ call: CAPPluginCall) { guard let options = call.options else { call.reject("Options are required") return } guard let triggerAtMillis = options["triggerAtMillis"] as? Int64 else { call.reject("triggerAtMillis is required") return } let triggerAt = Date(timeIntervalSince1970: TimeInterval(triggerAtMillis) / 1000.0) print("DNP-PLUGIN: Checking alarm status: triggerAt=\(triggerAt)") // Check if notification exists for this time notificationCenter.getPendingNotificationRequests { requests in let dailyNotifications = requests.filter { $0.identifier.hasPrefix("daily_") } // Check if any notification matches the trigger time var isScheduled = false for notification in dailyNotifications { if let trigger = notification.trigger as? UNCalendarNotificationTrigger, let nextTriggerDate = trigger.nextTriggerDate() { // Compare dates (within 1 minute tolerance) if abs(nextTriggerDate.timeIntervalSince(triggerAt)) < 60 { isScheduled = true break } } } let result: [String: Any] = [ "scheduled": isScheduled, "triggerAtMillis": triggerAtMillis ] print("DNP-PLUGIN: Alarm status: scheduled=\(isScheduled), triggerAt=\(triggerAtMillis)") DispatchQueue.main.async { call.resolve(result) } } } /** * Get next alarm time * * Returns the next scheduled notification time. * * Equivalent to Android's getNextAlarmTime method. */ @objc func getNextAlarmTime(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting next alarm time") notificationCenter.getPendingNotificationRequests { requests in let dailyNotifications = requests.filter { $0.identifier.hasPrefix("daily_") } var nextAlarmTime: TimeInterval? = nil // Find the earliest scheduled notification for notification in dailyNotifications { if let trigger = notification.trigger as? UNCalendarNotificationTrigger, let nextTriggerDate = trigger.nextTriggerDate() { let triggerTime = nextTriggerDate.timeIntervalSince1970 * 1000 if nextAlarmTime == nil || triggerTime < nextAlarmTime! { nextAlarmTime = triggerTime } } } let result: [String: Any] if let nextTime = nextAlarmTime { result = [ "scheduled": true, "triggerAtMillis": Int64(nextTime) ] print("DNP-PLUGIN: Next alarm time: \(nextTime)") } else { result = [ "scheduled": false ] print("DNP-PLUGIN: No alarm scheduled") } DispatchQueue.main.async { call.resolve(result) } } } /** * Test alarm * * Schedules a test notification to fire in a few seconds. * Useful for verifying notification delivery works correctly. * * Equivalent to Android's testAlarm method. */ @objc func testAlarm(_ call: CAPPluginCall) { let options = call.options let secondsFromNow = (options?["secondsFromNow"] as? Int) ?? 5 print("DNP-PLUGIN: TEST: Scheduling test alarm in \(secondsFromNow) seconds") // Create test notification let content = UNMutableNotificationContent() content.title = "Test Notification" content.body = "This is a test notification scheduled \(secondsFromNow) seconds from now" content.sound = .default // Schedule for secondsFromNow seconds in the future let triggerDate = Date().addingTimeInterval(TimeInterval(secondsFromNow)) let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate) let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) let identifier = "test_alarm_\(Date().timeIntervalSince1970)" let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) notificationCenter.add(request) { error in DispatchQueue.main.async { if let error = error { print("DNP-PLUGIN: Failed to schedule test alarm: \(error)") call.reject("Failed to schedule test alarm: \(error.localizedDescription)") } else { let triggerAtMillis = Int64(Date().addingTimeInterval(TimeInterval(secondsFromNow)).timeIntervalSince1970 * 1000) let result: [String: Any] = [ "scheduled": true, "secondsFromNow": secondsFromNow, "triggerAtMillis": triggerAtMillis ] print("DNP-PLUGIN: Test alarm scheduled successfully") call.resolve(result) } } } } /** * Get exact alarm status * * Returns detailed information about exact alarm scheduling capability. * On iOS, exact alarms are always supported via UNUserNotificationCenter. * * Equivalent to Android's getExactAlarmStatus method. */ @objc func getExactAlarmStatus(_ call: CAPPluginCall) { print("DNP-PLUGIN: Getting exact alarm status") // iOS always supports exact alarms via UNUserNotificationCenter // Background App Refresh is the closest equivalent to Android's exact alarm permission // but we can't check it directly - we assume it's enabled if notifications are authorized notificationCenter.getNotificationSettings { settings in let notificationsEnabled = settings.authorizationStatus == .authorized // iOS supports exact alarms (UNUserNotificationCenter provides precise scheduling) let supported = true let enabled = notificationsEnabled // Assume enabled if notifications are authorized let canSchedule = enabled let fallbackWindow = "0 minutes" // No fallback needed on iOS - exact scheduling is always available let result: [String: Any] = [ "supported": supported, "enabled": enabled, "canSchedule": canSchedule, "fallbackWindow": fallbackWindow ] print("DNP-PLUGIN: Exact alarm status: supported=\(supported), enabled=\(enabled), canSchedule=\(canSchedule)") DispatchQueue.main.async { call.resolve(result) } } } /** * Open exact alarm settings * * Opens iOS notification settings for the app. * iOS doesn't have separate exact alarm settings like Android. * * Equivalent to Android's openExactAlarmSettings method. */ @objc func openExactAlarmSettings(_ call: CAPPluginCall) { print("DNP-PLUGIN: Opening exact alarm settings (iOS: opens app notification settings)") // iOS doesn't have separate exact alarm settings // Open app notification settings instead if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { if UIApplication.shared.canOpenURL(settingsUrl) { UIApplication.shared.open(settingsUrl) { success in if success { print("DNP-PLUGIN: Settings opened successfully") call.resolve() } else { print("DNP-PLUGIN: Failed to open settings") call.reject("Failed to open settings") } } } else { call.reject("Cannot open settings URL") } } else { call.reject("Invalid settings URL") } } // 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) } } 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..