Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Server 88aa34b33f fix(ios): fix scheduleDailyNotification parameter handling and BGTaskScheduler error handling
Fixed scheduleDailyNotification to read parameters directly from CAPPluginCall
(matching Android pattern) instead of looking for wrapped "options" object.
Improved BGTaskScheduler error handling to clearly indicate simulator limitations.

Changes:
- Read parameters directly from call (call.getString("time"), etc.) instead of
  call.getObject("options") - Capacitor passes options object directly as call data
- Improved BGTaskScheduler error handling with clear simulator limitation message
- Added priority parameter extraction (was missing)
- Error handling doesn't fail notification scheduling if background fetch fails

BGTaskScheduler Simulator Limitation:
- BGTaskSchedulerErrorDomain Code=1 (notPermitted) is expected on simulator
- Background fetch scheduling fails on simulator but works on real devices
- Notification scheduling still works correctly; prefetch won't run on simulator
- Error messages now clearly indicate this is expected behavior

Result: scheduleDailyNotification now works correctly. Notification scheduling
verified working on simulator. Background fetch error is expected and documented.

Files modified:
- ios/Plugin/DailyNotificationPlugin.swift: Parameter reading fix, error handling
- doc/directives/0003-iOS-Android-Parity-Directive.md: Implementation details documented
2025-11-13 23:51:23 -08:00

1518 lines
61 KiB
Swift

//
// DailyNotificationPlugin.swift
// DailyNotificationPlugin
//
// Created by Matthew Raymer on 2025-09-22
// Copyright © 2025 TimeSafari. All rights reserved.
//
import Foundation
import Capacitor
import UserNotifications
import BackgroundTasks
import CoreData
/**
* iOS implementation of Daily Notification Plugin
* Implements BGTaskScheduler + UNUserNotificationCenter for dual scheduling
*
* @author Matthew Raymer
* @version 1.1.0
* @created 2025-09-22 09:22:32 UTC
*/
@objc(DailyNotificationPlugin)
public class DailyNotificationPlugin: CAPPlugin {
let notificationCenter = UNUserNotificationCenter.current()
private let backgroundTaskScheduler = BGTaskScheduler.shared
// Note: PersistenceController available for Phase 2+ CoreData integration if needed
// Background task identifiers
private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify"
// Phase 1: Storage and Scheduler components
var storage: DailyNotificationStorage?
var scheduler: DailyNotificationScheduler?
// Phase 1: Concurrency actor for thread-safe state access
@available(iOS 13.0, *)
var stateActor: DailyNotificationStateActor?
override public func load() {
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() called - Capacitor discovered the plugin!")
super.load()
setupBackgroundTasks()
// Initialize Phase 1 components
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let defaultPath = documentsPath.appendingPathComponent("daily_notifications.db").path
let database = DailyNotificationDatabase(path: defaultPath)
storage = DailyNotificationStorage(databasePath: database.getPath())
scheduler = DailyNotificationScheduler()
// Initialize state actor for thread-safe access
if #available(iOS 13.0, *) {
stateActor = DailyNotificationStateActor(
database: database,
storage: storage!
)
}
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
}
// MARK: - Configuration Methods
/**
* Configure the plugin with database and storage options
*
* Matches Android configure() functionality:
* - dbPath: Custom database path (optional)
* - storage: "shared" or "tiered" (default: "tiered")
* - ttlSeconds: Time-to-live for cached content (optional)
* - prefetchLeadMinutes: Minutes before notification to prefetch (optional)
* - maxNotificationsPerDay: Maximum notifications per day (optional)
* - retentionDays: Days to retain notification history (optional)
* - activeDidIntegration: Phase 1 activeDid configuration (optional)
*
* @param call Plugin call containing configuration parameters
*/
@objc func configure(_ call: CAPPluginCall) {
guard let options = call.getObject("options") else {
call.reject("Configuration options required")
return
}
print("DNP-PLUGIN: Configuring plugin with new options")
do {
// Get configuration options
let dbPath = options["dbPath"] as? String
let storageMode = options["storage"] as? String ?? "tiered"
let ttlSeconds = options["ttlSeconds"] as? Int
let prefetchLeadMinutes = options["prefetchLeadMinutes"] as? Int
let maxNotificationsPerDay = options["maxNotificationsPerDay"] as? Int
let retentionDays = options["retentionDays"] as? Int
// Phase 1: Process activeDidIntegration configuration (deferred to Phase 3)
if let activeDidConfig = options["activeDidIntegration"] as? [String: Any] {
print("DNP-PLUGIN: activeDidIntegration config received (Phase 3 feature)")
// TODO: Implement activeDidIntegration configuration in Phase 3
}
// Update storage mode
let useSharedStorage = storageMode == "shared"
// Set database path
let finalDbPath: String
if let dbPath = dbPath, !dbPath.isEmpty {
finalDbPath = dbPath
print("DNP-PLUGIN: Database path set to: \(finalDbPath)")
} else {
// Use default database path
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
finalDbPath = documentsPath.appendingPathComponent("daily_notifications.db").path
print("DNP-PLUGIN: Using default database path: \(finalDbPath)")
}
// Reinitialize storage with new database path if needed
if let currentStorage = storage {
// Check if path changed
if currentStorage.getDatabasePath() != finalDbPath {
storage = DailyNotificationStorage(databasePath: finalDbPath)
print("DNP-PLUGIN: Storage reinitialized with new database path")
}
} else {
storage = DailyNotificationStorage(databasePath: finalDbPath)
}
// Store configuration in storage
storeConfiguration(
ttlSeconds: ttlSeconds,
prefetchLeadMinutes: prefetchLeadMinutes,
maxNotificationsPerDay: maxNotificationsPerDay,
retentionDays: retentionDays,
storageMode: storageMode,
dbPath: finalDbPath
)
print("DNP-PLUGIN: Plugin configuration completed successfully")
call.resolve()
} catch {
print("DNP-PLUGIN: Error configuring plugin: \(error)")
call.reject("Configuration failed: \(error.localizedDescription)")
}
}
/**
* Store configuration values
*
* @param ttlSeconds TTL in seconds
* @param prefetchLeadMinutes Prefetch lead time in minutes
* @param maxNotificationsPerDay Maximum notifications per day
* @param retentionDays Retention period in days
* @param storageMode Storage mode ("shared" or "tiered")
* @param dbPath Database path
*/
private func storeConfiguration(
ttlSeconds: Int?,
prefetchLeadMinutes: Int?,
maxNotificationsPerDay: Int?,
retentionDays: Int?,
storageMode: String,
dbPath: String
) {
var config: [String: Any] = [
"storageMode": storageMode,
"dbPath": dbPath
]
if let ttlSeconds = ttlSeconds {
config["ttlSeconds"] = ttlSeconds
}
if let prefetchLeadMinutes = prefetchLeadMinutes {
config["prefetchLeadMinutes"] = prefetchLeadMinutes
}
if let maxNotificationsPerDay = maxNotificationsPerDay {
config["maxNotificationsPerDay"] = maxNotificationsPerDay
}
if let retentionDays = retentionDays {
config["retentionDays"] = retentionDays
}
storage?.saveSettings(config)
print("DNP-PLUGIN: Configuration stored successfully")
}
// MARK: - Dual Scheduling Methods
@objc func scheduleContentFetch(_ call: CAPPluginCall) {
guard let config = call.getObject("config") else {
call.reject("Content fetch config required")
return
}
print("DNP-PLUGIN: Scheduling content fetch")
do {
try scheduleBackgroundFetch(config: config)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule content fetch: \(error)")
call.reject("Content fetch scheduling failed: \(error.localizedDescription)")
}
}
@objc func scheduleUserNotification(_ call: CAPPluginCall) {
guard let config = call.getObject("config") else {
call.reject("User notification config required")
return
}
print("DNP-PLUGIN: Scheduling user notification")
do {
try scheduleUserNotification(config: config)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule user notification: \(error)")
call.reject("User notification scheduling failed: \(error.localizedDescription)")
}
}
@objc func scheduleDualNotification(_ call: CAPPluginCall) {
guard let config = call.getObject("config"),
let contentFetchConfig = config["contentFetch"] as? [String: Any],
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
call.reject("Dual notification config required")
return
}
print("DNP-PLUGIN: Scheduling dual notification")
do {
try scheduleBackgroundFetch(config: contentFetchConfig)
try scheduleUserNotification(config: userNotificationConfig)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule dual notification: \(error)")
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
}
}
@objc func getDualScheduleStatus(_ call: CAPPluginCall) {
Task {
do {
let status = try await getHealthStatus()
DispatchQueue.main.async {
call.resolve(status)
}
} catch {
DispatchQueue.main.async {
print("DNP-PLUGIN: Failed to get dual schedule status: \(error)")
call.reject("Status retrieval failed: \(error.localizedDescription)")
}
}
}
}
/**
* Get health status for dual scheduling system
*
* @return Health status dictionary
*/
private func getHealthStatus() async throws -> [String: Any] {
guard let scheduler = scheduler else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
}
let pendingCount = await scheduler.getPendingNotificationCount()
let isEnabled = await scheduler.checkPermissionStatus() == .authorized
// Get last notification via state actor
var lastNotification: NotificationContent?
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
lastNotification = await stateActor.getLastNotification()
} else {
lastNotification = self.storage?.getLastNotification()
}
} else {
lastNotification = self.storage?.getLastNotification()
}
return [
"contentFetch": [
"isEnabled": true,
"isScheduled": pendingCount > 0,
"lastFetchTime": lastNotification?.fetchedAt ?? 0,
"nextFetchTime": 0,
"pendingFetches": pendingCount
],
"userNotification": [
"isEnabled": isEnabled,
"isScheduled": pendingCount > 0,
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
"nextNotificationTime": 0,
"pendingNotifications": pendingCount
],
"relationship": [
"isLinked": true,
"contentAvailable": lastNotification != nil,
"lastLinkTime": lastNotification?.fetchedAt ?? 0
],
"overall": [
"isActive": isEnabled && pendingCount > 0,
"lastActivity": lastNotification?.scheduledTime ?? 0,
"errorCount": 0,
"successRate": 1.0
]
]
}
// MARK: - Private Implementation Methods
private func setupBackgroundTasks() {
// Register background fetch task
backgroundTaskScheduler.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in
self.handleBackgroundFetch(task: task as! BGAppRefreshTask)
}
// Register background processing task
backgroundTaskScheduler.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in
self.handleBackgroundNotify(task: task as! BGProcessingTask)
}
// Phase 1: Check for missed BGTask on app launch
checkForMissedBGTask()
}
/**
* Handle background fetch task
*
* Phase 1: Dummy fetcher - returns static content
* Phase 3: Will be replaced with JWT-signed fetcher
*
* @param task BGAppRefreshTask
*/
private func handleBackgroundFetch(task: BGAppRefreshTask) {
print("DNP-FETCH: Background fetch task started")
// Set expiration handler
task.expirationHandler = {
print("DNP-FETCH: Background fetch task expired")
task.setTaskCompleted(success: false)
}
// Phase 1: Dummy content fetch (no network)
// TODO: Phase 3 - Replace with JWT-signed fetcher
let dummyContent = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000), // 5 min from now
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: nil,
payload: nil,
etag: nil
)
// Save content to storage via state actor (thread-safe)
Task {
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveNotificationContent(dummyContent)
// Mark successful run
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
} else {
// Fallback to direct storage access
self.storage?.saveNotificationContent(dummyContent)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
} else {
// Fallback for iOS < 13
self.storage?.saveNotificationContent(dummyContent)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
}
// Schedule next fetch
// TODO: Calculate next fetch time based on notification schedule
print("DNP-FETCH: Background fetch task completed successfully")
task.setTaskCompleted(success: true)
}
/**
* Handle background notification task
*
* @param task BGProcessingTask
*/
private func handleBackgroundNotify(task: BGProcessingTask) {
print("DNP-NOTIFY: Background notify task started")
// Set expiration handler
task.expirationHandler = {
print("DNP-NOTIFY: Background notify task expired")
task.setTaskCompleted(success: false)
}
// Phase 1: Not used for single daily schedule
// This will be used in Phase 2+ for rolling window maintenance
print("DNP-NOTIFY: Background notify task completed")
task.setTaskCompleted(success: true)
}
/**
* Check for missed BGTask and reschedule if needed
*
* Phase 1: BGTask Miss Detection
* - Checks if BGTask was scheduled but not run within 15 min window
* - Reschedules if missed
*/
private func checkForMissedBGTask() {
Task {
var earliestBeginTimestamp: Int64?
var lastSuccessfulRun: Int64?
// Get BGTask tracking info via state actor (thread-safe)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
earliestBeginTimestamp = await stateActor.getBGTaskEarliestBegin()
lastSuccessfulRun = await stateActor.getLastSuccessfulRun()
} else {
// Fallback to direct storage access
earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin()
lastSuccessfulRun = self.storage?.getLastSuccessfulRun()
}
} else {
// Fallback for iOS < 13
earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin()
lastSuccessfulRun = self.storage?.getLastSuccessfulRun()
}
guard let earliestBeginTime = earliestBeginTimestamp else {
// No BGTask scheduled
return
}
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
let missWindow = Int64(15 * 60 * 1000) // 15 minutes in milliseconds
// Check if task was missed (current time > earliestBeginTime + 15 min)
if currentTime > earliestBeginTime + missWindow {
// Check if there was a successful run
if let lastRun = lastSuccessfulRun {
// If last successful run was after earliestBeginTime, task was not missed
if lastRun >= earliestBeginTime {
print("DNP-FETCH: BGTask completed successfully, no reschedule needed")
return
}
}
// Task was missed - reschedule
print("DNP-FETCH: BGTask missed window; rescheduling")
// Reschedule for 1 minute from now
let rescheduleTime = currentTime + (1 * 60 * 1000) // 1 minute from now
let rescheduleDate = Date(timeIntervalSince1970: Double(rescheduleTime) / 1000.0)
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
request.earliestBeginDate = rescheduleDate
do {
try backgroundTaskScheduler.submit(request)
// Save rescheduled time via state actor (thread-safe)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
} else {
// Fallback to direct storage access
self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
}
} else {
// Fallback for iOS < 13
self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
}
print("DNP-FETCH: BGTask rescheduled for \(rescheduleDate)")
} catch {
print("DNP-FETCH: Failed to reschedule BGTask: \(error)")
}
}
}
}
private func scheduleBackgroundFetch(config: [String: Any]) throws {
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
// Calculate next run time (simplified - would use proper cron parsing in production)
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime)
try backgroundTaskScheduler.submit(request)
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)")
}
private func scheduleUserNotification(config: [String: Any]) throws {
let content = UNMutableNotificationContent()
content.title = config["title"] as? String ?? "Daily Notification"
content.body = config["body"] as? String ?? "Your daily update is ready"
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
// Create trigger (simplified - would use proper cron parsing in production)
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
let request = UNNotificationRequest(
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
content: content,
trigger: trigger
)
notificationCenter.add(request) { error in
if let error = error {
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
} else {
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
}
}
}
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
// Simplified implementation - would use proper cron parsing in production
// For now, return next day at 9 AM
return 86400 // 24 hours
}
// MARK: - Static Daily Reminder Methods
@objc func scheduleDailyReminder(_ call: CAPPluginCall) {
guard let id = call.getString("id"),
let title = call.getString("title"),
let body = call.getString("body"),
let time = call.getString("time") else {
call.reject("Missing required parameters: id, title, body, time")
return
}
let sound = call.getBool("sound", true)
let vibration = call.getBool("vibration", true)
let priority = call.getString("priority", "normal")
let repeatDaily = call.getBool("repeatDaily", true)
let timezone = call.getString("timezone")
print("DNP-REMINDER: Scheduling daily reminder: \(id)")
// Parse time (HH:mm format)
let timeComponents = time.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]),
hour >= 0 && hour <= 23,
minute >= 0 && minute <= 59 else {
call.reject("Invalid time format. Use HH:mm (e.g., 09:00)")
return
}
// Create notification content
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = sound ? .default : nil
content.categoryIdentifier = "DAILY_REMINDER"
// Set priority
if #available(iOS 15.0, *) {
switch priority {
case "high":
content.interruptionLevel = .critical
case "low":
content.interruptionLevel = .passive
default:
content.interruptionLevel = .active
}
}
// Create date components for daily trigger
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: repeatDaily
)
let request = UNNotificationRequest(
identifier: "reminder_\(id)",
content: content,
trigger: trigger
)
// Store reminder in UserDefaults
storeReminderInUserDefaults(
id: id,
title: title,
body: body,
time: time,
sound: sound,
vibration: vibration,
priority: priority,
repeatDaily: repeatDaily,
timezone: timezone
)
// Schedule the notification
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error = error {
print("DNP-REMINDER: Failed to schedule reminder: \(error)")
call.reject("Failed to schedule daily reminder: \(error.localizedDescription)")
} else {
print("DNP-REMINDER: Daily reminder scheduled successfully: \(id)")
call.resolve()
}
}
}
}
@objc func cancelDailyReminder(_ call: CAPPluginCall) {
guard let reminderId = call.getString("reminderId") else {
call.reject("Missing reminderId parameter")
return
}
print("DNP-REMINDER: Cancelling daily reminder: \(reminderId)")
// Cancel the notification
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
// Remove from UserDefaults
removeReminderFromUserDefaults(id: reminderId)
call.resolve()
}
@objc func getScheduledReminders(_ call: CAPPluginCall) {
print("DNP-REMINDER: Getting scheduled reminders")
// Get pending notifications
notificationCenter.getPendingNotificationRequests { requests in
let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") }
// Get stored reminder data from UserDefaults
let reminders = self.getRemindersFromUserDefaults()
var result: [[String: Any]] = []
for reminder in reminders {
let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" }
var reminderInfo = reminder
reminderInfo["isScheduled"] = isScheduled
result.append(reminderInfo)
}
DispatchQueue.main.async {
call.resolve(["reminders": result])
}
}
}
@objc func updateDailyReminder(_ call: CAPPluginCall) {
guard let reminderId = call.getString("reminderId") else {
call.reject("Missing reminderId parameter")
return
}
print("DNP-REMINDER: Updating daily reminder: \(reminderId)")
// Cancel existing reminder
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
// Update in UserDefaults
let title = call.getString("title")
let body = call.getString("body")
let time = call.getString("time")
let sound = call.getBool("sound")
let vibration = call.getBool("vibration")
let priority = call.getString("priority")
let repeatDaily = call.getBool("repeatDaily")
let timezone = call.getString("timezone")
updateReminderInUserDefaults(
id: reminderId,
title: title,
body: body,
time: time,
sound: sound,
vibration: vibration,
priority: priority,
repeatDaily: repeatDaily,
timezone: timezone
)
// Reschedule with new settings if all required fields are provided
if let title = title, let body = body, let time = time {
// Parse time
let timeComponents = time.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]) else {
call.reject("Invalid time format")
return
}
// Create new notification content
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = (sound ?? true) ? .default : nil
content.categoryIdentifier = "DAILY_REMINDER"
// Set priority
let finalPriority = priority ?? "normal"
if #available(iOS 15.0, *) {
switch finalPriority {
case "high":
content.interruptionLevel = .critical
case "low":
content.interruptionLevel = .passive
default:
content.interruptionLevel = .active
}
}
// Create date components for daily trigger
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: repeatDaily ?? true
)
let request = UNNotificationRequest(
identifier: "reminder_\(reminderId)",
content: content,
trigger: trigger
)
// Schedule the updated notification
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error = error {
call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)")
} else {
call.resolve()
}
}
}
} else {
call.resolve()
}
}
// MARK: - Helper Methods for Reminder Storage
private func storeReminderInUserDefaults(
id: String,
title: String,
body: String,
time: String,
sound: Bool,
vibration: Bool,
priority: String,
repeatDaily: Bool,
timezone: String?
) {
let reminderData: [String: Any] = [
"id": id,
"title": title,
"body": body,
"time": time,
"sound": sound,
"vibration": vibration,
"priority": priority,
"repeatDaily": repeatDaily,
"timezone": timezone ?? "",
"createdAt": Date().timeIntervalSince1970,
"lastTriggered": 0
]
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
reminders.append(reminderData)
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
print("DNP-REMINDER: Reminder stored: \(id)")
}
private func removeReminderFromUserDefaults(id: String) {
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
reminders.removeAll { ($0["id"] as? String) == id }
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
print("DNP-REMINDER: Reminder removed: \(id)")
}
private func getRemindersFromUserDefaults() -> [[String: Any]] {
return UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
}
private func updateReminderInUserDefaults(
id: String,
title: String?,
body: String?,
time: String?,
sound: Bool?,
vibration: Bool?,
priority: String?,
repeatDaily: Bool?,
timezone: String?
) {
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
for i in 0..<reminders.count {
if reminders[i]["id"] as? String == id {
if let title = title { reminders[i]["title"] = title }
if let body = body { reminders[i]["body"] = body }
if let time = time { reminders[i]["time"] = time }
if let sound = sound { reminders[i]["sound"] = sound }
if let vibration = vibration { reminders[i]["vibration"] = vibration }
if let priority = priority { reminders[i]["priority"] = priority }
if let repeatDaily = repeatDaily { reminders[i]["repeatDaily"] = repeatDaily }
if let timezone = timezone { reminders[i]["timezone"] = timezone }
break
}
}
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
print("DNP-REMINDER: Reminder updated: \(id)")
}
// MARK: - Phase 1: Core Notification Methods
/**
* Schedule a daily notification with the specified options
*
* Phase 1: Single daily schedule (one prefetch 5 min before + one notification)
*
* @param call Plugin call containing notification parameters
*/
@objc func scheduleDailyNotification(_ 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
}
// Capacitor passes the options object directly as call data
// Read parameters directly from call (matching Android implementation)
guard let time = call.getString("time"), !time.isEmpty else {
let error = DailyNotificationErrorCodes.missingParameter("time")
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// 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 {
let error = DailyNotificationErrorCodes.invalidTimeFormat()
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Extract other parameters (with defaults matching Android)
let title = call.getString("title") ?? "Daily Update"
let body = call.getString("body") ?? "Your daily notification is ready"
let sound = call.getBool("sound", true)
let url = call.getString("url")
let priority = call.getString("priority") ?? "default"
// Calculate scheduled time (next occurrence at specified hour:minute)
let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute)
let fetchedAt = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds
// Create notification content
let content = NotificationContent(
id: "daily_\(Date().timeIntervalSince1970)",
title: title,
body: body,
scheduledTime: scheduledTime,
fetchedAt: fetchedAt,
url: url,
payload: nil,
etag: nil
)
// Store notification content via state actor (thread-safe)
Task {
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveNotificationContent(content)
} else {
// Fallback to direct storage access
self.storage?.saveNotificationContent(content)
}
} else {
// Fallback for iOS < 13
self.storage?.saveNotificationContent(content)
}
// Schedule notification
let scheduled = await scheduler.scheduleNotification(content)
if scheduled {
// Schedule background fetch 5 minutes before notification time
self.scheduleBackgroundFetch(scheduledTime: scheduledTime)
DispatchQueue.main.async {
print("DNP-PLUGIN: Daily notification scheduled successfully")
call.resolve()
}
} else {
DispatchQueue.main.async {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
message: "Failed to schedule notification"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
}
}
}
}
/**
* Get the last notification that was delivered
*
* @param call Plugin call
*/
@objc func getLastNotification(_ call: CAPPluginCall) {
Task {
var lastNotification: NotificationContent?
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
lastNotification = await stateActor.getLastNotification()
} else {
// Fallback to direct storage access
lastNotification = self.storage?.getLastNotification()
}
} else {
// Fallback for iOS < 13
lastNotification = self.storage?.getLastNotification()
}
DispatchQueue.main.async {
if let notification = lastNotification {
let result: [String: Any] = [
"id": notification.id,
"title": notification.title ?? "",
"body": notification.body ?? "",
"timestamp": notification.scheduledTime,
"url": notification.url ?? ""
]
call.resolve(result)
} else {
call.resolve([:])
}
}
}
}
/**
* Cancel all scheduled notifications
*
* @param call Plugin call
*/
@objc func cancelAllNotifications(_ call: CAPPluginCall) {
guard let scheduler = scheduler else {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
Task {
await scheduler.cancelAllNotifications()
// Clear notifications via state actor (thread-safe)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.clearAllNotifications()
} else {
// Fallback to direct storage access
self.storage?.clearAllNotifications()
}
} else {
// Fallback for iOS < 13
self.storage?.clearAllNotifications()
}
DispatchQueue.main.async {
print("DNP-PLUGIN: All notifications cancelled successfully")
call.resolve()
}
}
}
/**
* Get the current status of notifications
*
* @param call Plugin call
*/
@objc func getNotificationStatus(_ call: CAPPluginCall) {
guard let scheduler = scheduler else {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
Task {
let isEnabled = await scheduler.checkPermissionStatus() == .authorized
let pendingCount = await scheduler.getPendingNotificationCount()
// Get last notification via state actor (thread-safe)
var lastNotification: NotificationContent?
var settings: [String: Any] = [:]
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
lastNotification = await stateActor.getLastNotification()
settings = await stateActor.getSettings()
} else {
// Fallback to direct storage access
lastNotification = self.storage?.getLastNotification()
settings = self.storage?.getSettings() ?? [:]
}
} else {
// Fallback for iOS < 13
lastNotification = self.storage?.getLastNotification()
settings = self.storage?.getSettings() ?? [:]
}
// Calculate next notification time
let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0
var result: [String: Any] = [
"isEnabled": isEnabled,
"isScheduled": pendingCount > 0,
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
"nextNotificationTime": nextNotificationTime,
"pending": pendingCount,
"settings": settings
]
DispatchQueue.main.async {
call.resolve(result)
}
}
}
/**
* Check permission status
* Returns boolean flags for each permission type
*
* @param call Plugin call
*/
@objc func checkPermissionStatus(_ call: CAPPluginCall) {
NSLog("DNP-PLUGIN: checkPermissionStatus called - thread: %@", Thread.isMainThread ? "main" : "background")
// Ensure scheduler is initialized (should be initialized in load(), but check anyway)
if scheduler == nil {
NSLog("DNP-PLUGIN: Scheduler not initialized, initializing now...")
scheduler = DailyNotificationScheduler()
}
guard let scheduler = scheduler else {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
NSLog("DNP-PLUGIN: checkPermissionStatus failed - plugin not initialized")
call.reject(errorMessage, errorCode)
return
}
// Use Task without @MainActor, then dispatch to main queue for call.resolve
Task {
do {
NSLog("DNP-PLUGIN: Task started - thread: %@", Thread.isMainThread ? "main" : "background")
NSLog("DNP-PLUGIN: Calling scheduler.checkPermissionStatus()...")
// Check notification permission status
let notificationStatus = await scheduler.checkPermissionStatus()
NSLog("DNP-PLUGIN: Got notification status: %d", notificationStatus.rawValue)
let notificationsEnabled = notificationStatus == .authorized
NSLog("DNP-PLUGIN: Notification status: %d, enabled: %@", notificationStatus.rawValue, notificationsEnabled ? "YES" : "NO")
// iOS doesn't have exact alarms like Android, but we can check if notifications are authorized
// For iOS, "exact alarm" equivalent is having authorized notifications
let exactAlarmEnabled = notificationsEnabled
// iOS doesn't have wake locks, but we can check Background App Refresh
// Note: Background App Refresh status requires checking system settings
// For now, we'll assume it's enabled if notifications are enabled
// Phase 2: Add proper Background App Refresh status check
let wakeLockEnabled = notificationsEnabled
// All permissions granted if notifications are authorized
let allPermissionsGranted = notificationsEnabled
let result: [String: Any] = [
"notificationsEnabled": notificationsEnabled,
"exactAlarmEnabled": exactAlarmEnabled,
"wakeLockEnabled": wakeLockEnabled,
"allPermissionsGranted": allPermissionsGranted
]
NSLog("DNP-PLUGIN: checkPermissionStatus result: %@", result)
NSLog("DNP-PLUGIN: About to call resolve - thread: %@", Thread.isMainThread ? "main" : "background")
// Dispatch to main queue for call.resolve (required by Capacitor)
DispatchQueue.main.async {
NSLog("DNP-PLUGIN: On main queue, calling resolve")
call.resolve(result)
NSLog("DNP-PLUGIN: Call resolved successfully")
}
} catch {
NSLog("DNP-PLUGIN: checkPermissionStatus error: %@", error.localizedDescription)
let errorMessage = "Failed to check permission status: \(error.localizedDescription)"
// Dispatch to main queue for call.reject (required by Capacitor)
DispatchQueue.main.async {
call.reject(errorMessage, "permission_check_failed")
}
}
}
NSLog("DNP-PLUGIN: Task created and returned")
}
/**
* Request notification permissions
* Shows system permission dialog if permissions haven't been determined yet
*
* @param call Plugin call
*/
@objc func requestNotificationPermissions(_ call: CAPPluginCall) {
NSLog("DNP-PLUGIN: requestNotificationPermissions called - thread: %@", Thread.isMainThread ? "main" : "background")
// Ensure scheduler is initialized
if scheduler == nil {
NSLog("DNP-PLUGIN: Scheduler not initialized, initializing now...")
scheduler = DailyNotificationScheduler()
}
guard let scheduler = scheduler else {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
NSLog("DNP-PLUGIN: requestNotificationPermissions failed - plugin not initialized")
call.reject(errorMessage, errorCode)
return
}
// Use Task without @MainActor, then dispatch to main queue for call.resolve
Task {
do {
NSLog("DNP-PLUGIN: Task started for requestNotificationPermissions")
// First check current status
let currentStatus = await scheduler.checkPermissionStatus()
NSLog("DNP-PLUGIN: Current permission status: %d", currentStatus.rawValue)
// If already authorized, return success immediately
if currentStatus == .authorized {
NSLog("DNP-PLUGIN: Permissions already granted")
let result: [String: Any] = [
"granted": true,
"status": "authorized"
]
DispatchQueue.main.async {
call.resolve(result)
}
return
}
// If denied, we can't request again (user must go to Settings)
if currentStatus == .denied {
NSLog("DNP-PLUGIN: Permissions denied - user must enable in Settings")
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
message: "Notification permissions denied. Please enable in Settings."
)
let errorMessage = error["message"] as? String ?? "Permissions denied"
let errorCode = error["error"] as? String ?? "notifications_denied"
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
return
}
// Request permissions (will show system dialog if .notDetermined)
NSLog("DNP-PLUGIN: Requesting permissions...")
let granted = await scheduler.requestPermissions()
NSLog("DNP-PLUGIN: Permission request result: %@", granted ? "granted" : "denied")
// Get updated status
let newStatus = await scheduler.checkPermissionStatus()
let result: [String: Any] = [
"granted": granted,
"status": granted ? "authorized" : "denied",
"previousStatus": currentStatus.rawValue,
"newStatus": newStatus.rawValue
]
NSLog("DNP-PLUGIN: requestNotificationPermissions result: %@", result)
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
NSLog("DNP-PLUGIN: requestNotificationPermissions error: %@", error.localizedDescription)
let errorMessage = "Failed to request permissions: \(error.localizedDescription)"
DispatchQueue.main.async {
call.reject(errorMessage, "permission_request_failed")
}
}
}
}
// MARK: - Channel Methods (iOS Parity with Android)
/**
* Check if notification channel is enabled
*
* iOS Note: iOS doesn't have per-channel control like Android. This method
* checks if notifications are authorized app-wide, which is the iOS equivalent.
*
* @param call Plugin call with optional channelId parameter
*/
@objc func isChannelEnabled(_ call: CAPPluginCall) {
NSLog("DNP-PLUGIN: isChannelEnabled called")
// Ensure scheduler is initialized
if scheduler == nil {
scheduler = DailyNotificationScheduler()
}
guard let scheduler = scheduler else {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Get channelId from call (optional, for API parity with Android)
let channelId = call.getString("channelId") ?? "default"
// iOS doesn't have per-channel control, so check app-wide notification authorization
Task {
let status = await scheduler.checkPermissionStatus()
let enabled = (status == .authorized || status == .provisional)
let result: [String: Any] = [
"enabled": enabled,
"channelId": channelId
]
DispatchQueue.main.async {
call.resolve(result)
}
}
}
/**
* Open notification channel settings
*
* iOS Note: iOS doesn't have per-channel settings. This method opens
* the app's notification settings in iOS Settings, which is the iOS equivalent.
*
* @param call Plugin call with optional channelId parameter
*/
@objc func openChannelSettings(_ call: CAPPluginCall) {
NSLog("DNP-PLUGIN: openChannelSettings called")
// Get channelId from call (optional, for API parity with Android)
let channelId = call.getString("channelId") ?? "default"
// iOS doesn't have per-channel settings, so open app-wide notification settings
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl) { success in
if success {
NSLog("DNP-PLUGIN: Opened iOS Settings for channel: %@", channelId)
DispatchQueue.main.async {
call.resolve()
}
} else {
NSLog("DNP-PLUGIN: Failed to open iOS Settings")
DispatchQueue.main.async {
call.reject("Failed to open settings")
}
}
}
} else {
call.reject("Cannot open settings URL")
}
} else {
call.reject("Invalid settings URL")
}
}
/**
* Update notification settings
*
* @param call Plugin call containing new settings
*/
@objc func updateSettings(_ call: CAPPluginCall) {
guard let settings = call.getObject("settings") else {
let error = DailyNotificationErrorCodes.missingParameter("settings")
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
Task {
// Save settings via state actor (thread-safe)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveSettings(settings)
} else {
// Fallback to direct storage access
self.storage?.saveSettings(settings)
}
} else {
// Fallback for iOS < 13
self.storage?.saveSettings(settings)
}
DispatchQueue.main.async {
print("DNP-PLUGIN: Settings updated successfully")
call.resolve()
}
}
}
// MARK: - Phase 1: Helper Methods
/**
* Calculate next scheduled time for given hour and minute
*
* Uses scheduler's calculateNextOccurrence for consistency
*
* @param hour Hour (0-23)
* @param minute Minute (0-59)
* @return Timestamp in milliseconds
*/
private func calculateNextScheduledTime(hour: Int, minute: Int) -> Int64 {
guard let scheduler = scheduler else {
// Fallback calculation if scheduler not available
let calendar = Calendar.current
let now = Date()
var components = calendar.dateComponents([.year, .month, .day], from: now)
components.hour = hour
components.minute = minute
components.second = 0
var scheduledDate = calendar.date(from: components) ?? now
// If scheduled time has passed today, schedule for tomorrow
if scheduledDate <= now {
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
}
return Int64(scheduledDate.timeIntervalSince1970 * 1000)
}
return scheduler.calculateNextOccurrence(hour: hour, minute: minute)
}
/**
* Schedule background fetch 5 minutes before notification time
*
* @param scheduledTime Notification scheduled time in milliseconds
*/
private func scheduleBackgroundFetch(scheduledTime: Int64) {
// Calculate fetch time (5 minutes before notification)
let fetchTime = scheduledTime - (5 * 60 * 1000) // 5 minutes in milliseconds
let fetchDate = Date(timeIntervalSince1970: Double(fetchTime) / 1000.0)
// Schedule BGTaskScheduler task
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
request.earliestBeginDate = fetchDate
do {
try backgroundTaskScheduler.submit(request)
// Store earliest begin date for miss detection via state actor (thread-safe)
Task {
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveBGTaskEarliestBegin(timestamp: fetchTime)
} else {
// Fallback to direct storage access
self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime)
}
} else {
// Fallback for iOS < 13
self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime)
}
}
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(fetchDate)")
} catch {
// BGTaskScheduler errors are common on simulator (Code=1: notPermitted)
// This is expected behavior - simulators don't reliably support background tasks
// On real devices, this should work if Background App Refresh is enabled
let errorDescription = error.localizedDescription
if errorDescription.contains("BGTaskSchedulerErrorDomain") ||
errorDescription.contains("Code=1") ||
(error as NSError).domain == "BGTaskSchedulerErrorDomain" {
print("DNP-FETCH-SCHEDULE: Background fetch scheduling failed (expected on simulator): \(errorDescription)")
print("DNP-FETCH-SCHEDULE: Note: BGTaskScheduler requires real device or Background App Refresh enabled")
} else {
print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)")
}
// Don't fail notification scheduling if background fetch fails
// Notification will still be delivered, just without prefetch
}
}
}
// MARK: - CAPBridgedPlugin Conformance
// This extension makes the plugin conform to CAPBridgedPlugin protocol
// which is required for Capacitor to discover and register the plugin
@objc extension DailyNotificationPlugin: CAPBridgedPlugin {
@objc public var identifier: String {
return "com.timesafari.dailynotification"
}
@objc public var jsName: String {
return "DailyNotification"
}
@objc public var pluginMethods: [CAPPluginMethod] {
var methods: [CAPPluginMethod] = []
// Core methods
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnNone))
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateSettings", returnType: CAPPluginReturnPromise))
// Permission methods
methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise))
// Channel methods (iOS parity with Android)
methods.append(CAPPluginMethod(name: "isChannelEnabled", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openChannelSettings", returnType: CAPPluginReturnPromise))
// Reminder methods
methods.append(CAPPluginMethod(name: "scheduleDailyReminder", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelDailyReminder", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getScheduledReminders", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateDailyReminder", returnType: CAPPluginReturnPromise))
// Dual scheduling methods
methods.append(CAPPluginMethod(name: "scheduleContentFetch", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
return methods
}
}