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