Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Server ed25b1385a fix(ios): enable Capacitor plugin discovery via CAPBridgedPlugin conformance
Capacitor iOS was not discovering DailyNotificationPlugin because it did not
conform to the CAPBridgedPlugin protocol required for runtime discovery.

Changes:
- Add @objc extension to DailyNotificationPlugin implementing CAPBridgedPlugin
  with identifier, jsName, and pluginMethods properties
- Force-load plugin framework in AppDelegate before Capacitor initializes
- Remove duplicate BGTaskScheduler registration from AppDelegate (plugin handles it)
- Update podspec to use dynamic framework (static_framework = false)
- Add diagnostic logging to verify plugin discovery

Result: Plugin is now discovered by Capacitor and all methods are accessible
from JavaScript. Verified working with checkPermissionStatus() method.

Files modified:
- ios/Plugin/DailyNotificationPlugin.swift: Added CAPBridgedPlugin extension
- test-apps/ios-test-app/ios/App/App/AppDelegate.swift: Force-load + diagnostics
- ios/DailyNotificationPlugin.podspec: Dynamic framework setting
- doc/directives/0003-iOS-Android-Parity-Directive.md: Documented solution
2025-11-13 23:29:03 -08:00

1420 lines
57 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
}
guard let options = call.getObject("options") else {
let error = DailyNotificationErrorCodes.missingParameter("options")
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
guard let time = options["time"] as? String, !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
let title = options["title"] as? String ?? "Daily Update"
let body = options["body"] as? String ?? "Your daily notification is ready"
let sound = options["sound"] as? Bool ?? true
let url = options["url"] as? String
// 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")
}
}
}
}
/**
* 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 {
print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)")
}
}
}
// 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))
// 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
}
}