Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Matthew 3649e76c49 feat(ios): add error handling and integration tests
Implement comprehensive error handling and integration test suite:

Error Handling (Section 8):
- Add iOS-specific error codes to DailyNotificationErrorCodes:
  - NOTIFICATION_PERMISSION_DENIED
  - PENDING_NOTIFICATION_LIMIT_EXCEEDED
  - BG_TASK_NOT_REGISTERED
  - BG_TASK_EXECUTION_FAILED
  - BACKGROUND_REFRESH_DISABLED
- Add helper methods for iOS-specific error responses
- Enhance error handling in ReactivationManager:
  - Database errors handled gracefully (non-fatal)
  - Notification center errors handled gracefully (non-fatal)
  - Scheduling errors handled gracefully (non-fatal)
  - All errors logged, app continues normally
  - Partial results returned when operations fail
- Update plugin methods to use iOS-specific error codes:
  - getNotificationPermissionStatus uses NOTIFICATION_PERMISSION_DENIED

Integration Tests (Section 9.2):
- Add DailyNotificationRecoveryIntegrationTests:
  - Full recovery flow tests (cold start, termination)
  - Error handling tests (database, notification center, scheduling)
  - App stability tests (no crashes, concurrent operations)
  - Partial recovery tests
  - Timeout handling tests
- Test coverage:
  - 10 integration tests covering recovery scenarios
  - Error handling verification
  - App stability verification
  - Concurrent operation safety

Completes sections 8.1, 8.2, and 9.2 of iOS implementation checklist.
2025-12-09 02:46:13 -08:00

1743 lines
70 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: 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()
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) {
// Capacitor passes the options object directly as call data
// Read parameters directly from call (matching Android implementation)
print("DNP-PLUGIN: Configuring plugin with new options")
do {
// Get configuration options directly from call (matching Android)
let dbPath = call.getString("dbPath")
let storageMode = call.getString("storage") ?? "tiered"
let ttlSeconds = call.getInt("ttlSeconds")
let prefetchLeadMinutes = call.getInt("prefetchLeadMinutes")
let maxNotificationsPerDay = call.getInt("maxNotificationsPerDay")
let retentionDays = call.getInt("retentionDays")
// Phase 1: Process activeDidIntegration configuration (deferred to Phase 3)
if let activeDidConfig = call.getObject("activeDidIntegration") {
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: - 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) {
Task {
do {
guard let scheduler = scheduler else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
}
let status = await scheduler.checkPermissionStatus()
// Map to iOS-specific error if denied
if status == .denied {
let error = DailyNotificationErrorCodes.notificationPermissionDenied()
let errorMessage = error["message"] as? String ?? "Notification permission denied"
let errorCode = error["error"] as? String ?? DailyNotificationErrorCodes.NOTIFICATION_PERMISSION_DENIED
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
return
}
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) {
Task {
do {
guard let scheduler = scheduler else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
}
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 {
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) {
let registeredIdentifiers = backgroundTaskScheduler.registeredTaskIdentifiers
let fetchTaskRegistered = registeredIdentifiers.contains(fetchTaskIdentifier)
let notifyTaskRegistered = registeredIdentifiers.contains(notifyTaskIdentifier)
// Note: Background App Refresh status cannot be checked programmatically
// User must check in Settings app
let result: [String: Any] = [
"fetchTaskRegistered": fetchTaskRegistered,
"notifyTaskRegistered": notifyTaskRegistered,
"lastFetchExecution": storage?.getLastSuccessfulRun() ?? NSNull(),
"lastNotifyExecution": NSNull(), // TODO: Track notify execution
"backgroundRefreshEnabled": NSNull() // Cannot check programmatically
]
call.resolve(result)
}
/**
* Open notification settings (iOS-specific)
*
* @param call Plugin call
*/
@objc func openNotificationSettings(_ call: CAPPluginCall) {
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open notification settings", "open_settings_failed")
}
}
}
} else {
call.reject("Cannot open settings URL", "open_settings_failed")
}
} else {
call.reject("Invalid settings URL", "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
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open settings", "open_settings_failed")
}
}
}
} else {
call.reject("Cannot open settings URL", "open_settings_failed")
}
} else {
call.reject("Invalid settings URL", "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) {
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: CAPPluginReturnPromise))
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))
// 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))
return methods
}
}