Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Matthew Raymer a7dd559c4a feat(ios): implement scheduleDailyNotification method
Implemented main scheduling method for iOS plugin, matching Android functionality:

Core Features:
- Permission checking and requesting (iOS notification authorization)
- Time parsing (HH:mm format) with validation
- Next run time calculation (handles same-day and next-day scheduling)
- UNUserNotificationCenter scheduling with daily repeat
- Priority/interruption level support (iOS 15.0+)
- Prefetch scheduling 5 minutes before notification (BGTaskScheduler)
- Schedule storage in UserDefaults

Implementation Details:
- Checks notification authorization status before scheduling
- Requests permission if not granted (equivalent to Android exact alarm permission)
- Parses time string and calculates next occurrence
- Creates UNCalendarNotificationTrigger for daily repeat
- Schedules BGAppRefreshTask for prefetch 5 minutes before
- Stores schedule metadata in UserDefaults for persistence

Matches Android API:
- Same parameter structure (time, title, body, sound, priority, url)
- Same behavior (daily repeat, prefetch scheduling)
- iOS-specific adaptations (UNUserNotificationCenter vs AlarmManager)

This is the first critical method implementation (10/52 methods now complete).
2025-11-11 01:50:26 -08:00

694 lines
26 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()
let backgroundTaskScheduler = BGTaskScheduler.shared
let persistenceController = PersistenceController.shared
// Background task identifiers
private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify"
override public func load() {
super.load()
setupBackgroundTasks()
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
}
// MARK: - Configuration Methods
@objc func configure(_ call: CAPPluginCall) {
guard let options = call.getObject("options") else {
call.reject("Configuration options required")
return
}
print("DNP-PLUGIN: Configure called with options: \(options)")
// Store configuration in UserDefaults
UserDefaults.standard.set(options, forKey: "DailyNotificationConfig")
call.resolve()
}
// MARK: - Dual Scheduling Methods
@objc func scheduleContentFetch(_ call: CAPPluginCall) {
guard let config = call.getObject("config") else {
call.reject("Content fetch config required")
return
}
print("DNP-PLUGIN: Scheduling content fetch")
do {
try scheduleBackgroundFetch(config: config)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule content fetch: \(error)")
call.reject("Content fetch scheduling failed: \(error.localizedDescription)")
}
}
@objc func scheduleUserNotification(_ call: CAPPluginCall) {
guard let config = call.getObject("config") else {
call.reject("User notification config required")
return
}
print("DNP-PLUGIN: Scheduling user notification")
do {
try scheduleUserNotification(config: config)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule user notification: \(error)")
call.reject("User notification scheduling failed: \(error.localizedDescription)")
}
}
@objc func scheduleDualNotification(_ call: CAPPluginCall) {
guard let config = call.getObject("config"),
let contentFetchConfig = config["contentFetch"] as? [String: Any],
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
call.reject("Dual notification config required")
return
}
print("DNP-PLUGIN: Scheduling dual notification")
do {
try scheduleBackgroundFetch(config: contentFetchConfig)
try scheduleUserNotification(config: userNotificationConfig)
call.resolve()
} catch {
print("DNP-PLUGIN: Failed to schedule dual notification: \(error)")
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
}
}
@objc func getDualScheduleStatus(_ call: CAPPluginCall) {
Task {
do {
let status = try await getHealthStatus()
call.resolve(status)
} catch {
print("DNP-PLUGIN: Failed to get dual schedule status: \(error)")
call.reject("Status retrieval failed: \(error.localizedDescription)")
}
}
}
// MARK: - Main Scheduling Method
/**
* Schedule a daily notification
*
* This is the main scheduling method, equivalent to Android's scheduleDailyNotification.
* Schedules both the notification and a prefetch 5 minutes before.
*
* @param call Plugin call with options:
* - time: String (required) - Time in HH:mm format (e.g., "09:00")
* - title: String (optional) - Notification title (default: "Daily Notification")
* - body: String (optional) - Notification body (default: "")
* - sound: Bool (optional) - Enable sound (default: true)
* - priority: String (optional) - Priority: "high", "default", "low" (default: "default")
* - url: String (optional) - URL for prefetch (optional, native fetcher used if registered)
*/
@objc func scheduleDailyNotification(_ call: CAPPluginCall) {
// Check notification permissions first
notificationCenter.getNotificationSettings { settings in
if settings.authorizationStatus != .authorized {
// Request permission if not granted
self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
DispatchQueue.main.async {
if let error = error {
print("DNP-PLUGIN: Permission request failed: \(error)")
call.reject("Notification permission request failed: \(error.localizedDescription)")
return
}
if !granted {
print("DNP-PLUGIN: Notification permission denied")
call.reject("Notification permission denied. Please enable notifications in Settings.", "PERMISSION_DENIED")
return
}
// Permission granted, proceed with scheduling
self.performScheduleDailyNotification(call: call)
}
}
} else {
// Permission already granted, proceed
self.performScheduleDailyNotification(call: call)
}
}
}
/**
* Perform the actual scheduling after permission check
*/
private func performScheduleDailyNotification(call: CAPPluginCall) {
guard let options = call.options else {
call.reject("Options are required")
return
}
guard let timeString = options["time"] as? String else {
call.reject("Time is required (format: HH:mm)")
return
}
let title = options["title"] as? String ?? "Daily Notification"
let body = options["body"] as? String ?? ""
let sound = options["sound"] as? Bool ?? true
let priority = options["priority"] as? String ?? "default"
let url = options["url"] as? String // Optional URL for prefetch
print("DNP-PLUGIN: Scheduling daily notification: time=\(timeString), title=\(title)")
// Parse time (HH:mm format)
let timeComponents = timeString.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]),
hour >= 0 && hour <= 23,
minute >= 0 && minute <= 59 else {
call.reject("Invalid time format. Use HH:mm (e.g., 09:00)")
return
}
// Calculate next run time
let calendar = Calendar.current
let now = Date()
var dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
dateComponents.hour = hour
dateComponents.minute = minute
dateComponents.second = 0
guard var nextRunDate = calendar.date(from: dateComponents) else {
call.reject("Failed to calculate next run time")
return
}
// If the time has already passed today, schedule for tomorrow
if nextRunDate <= now {
nextRunDate = calendar.date(byAdding: .day, value: 1, to: nextRunDate) ?? nextRunDate
}
let nextRunTime = nextRunDate.timeIntervalSince1970 * 1000 // Convert to milliseconds
let nextRunTimeInterval = nextRunDate.timeIntervalSinceNow
// Create notification content
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = sound ? .default : nil
content.categoryIdentifier = "DAILY_NOTIFICATION"
// Set priority/interruption level
if #available(iOS 15.0, *) {
switch priority.lowercased() {
case "high", "max":
content.interruptionLevel = .critical
case "low", "min":
content.interruptionLevel = .passive
default:
content.interruptionLevel = .active
}
}
// Create date components for daily trigger
var triggerComponents = DateComponents()
triggerComponents.hour = hour
triggerComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: triggerComponents,
repeats: true // Daily repeat
)
// Create unique identifier
let scheduleId = "daily_\(Int(Date().timeIntervalSince1970 * 1000))"
let request = UNNotificationRequest(
identifier: scheduleId,
content: content,
trigger: trigger
)
// Schedule the notification
notificationCenter.add(request) { error in
if let error = error {
print("DNP-PLUGIN: Failed to schedule notification: \(error)")
call.reject("Failed to schedule notification: \(error.localizedDescription)")
return
}
print("DNP-PLUGIN: Notification scheduled successfully: \(scheduleId)")
// Schedule prefetch 5 minutes before notification
let fetchTime = nextRunTime - (5 * 60 * 1000) // 5 minutes before in milliseconds
let fetchTimeInterval = (fetchTime / 1000) - Date().timeIntervalSince1970
if fetchTimeInterval > 0 {
// Schedule background fetch task
do {
let fetchRequest = BGAppRefreshTaskRequest(identifier: self.fetchTaskIdentifier)
fetchRequest.earliestBeginDate = Date(timeIntervalSinceNow: fetchTimeInterval)
try self.backgroundTaskScheduler.submit(fetchRequest)
print("DNP-PLUGIN: Prefetch scheduled: fetchTime=\(fetchTime), notificationTime=\(nextRunTime)")
} catch {
print("DNP-PLUGIN: Failed to schedule prefetch: \(error)")
// Don't fail the whole operation if prefetch scheduling fails
}
} else {
// Fetch time is in the past, trigger immediate fetch if possible
print("DNP-PLUGIN: Fetch time is in the past, skipping prefetch scheduling")
}
// Store schedule in UserDefaults (similar to Android database storage)
self.storeScheduleInUserDefaults(
id: scheduleId,
time: timeString,
title: title,
body: body,
nextRunTime: nextRunTime
)
call.resolve()
}
}
/**
* Store schedule in UserDefaults
*/
private func storeScheduleInUserDefaults(
id: String,
time: String,
title: String,
body: String,
nextRunTime: TimeInterval
) {
let schedule: [String: Any] = [
"id": id,
"kind": "notify",
"time": time,
"title": title,
"body": body,
"nextRunTime": nextRunTime,
"enabled": true,
"createdAt": Date().timeIntervalSince1970 * 1000
]
var schedules = UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? []
schedules.append(schedule)
UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules")
print("DNP-PLUGIN: Schedule stored: \(id)")
}
// MARK: - Private Implementation Methods
private func setupBackgroundTasks() {
// Register background fetch task
backgroundTaskScheduler.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in
self.handleBackgroundFetch(task: task as! BGAppRefreshTask)
}
// Register background processing task
backgroundTaskScheduler.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in
self.handleBackgroundNotify(task: task as! BGProcessingTask)
}
}
private func scheduleBackgroundFetch(config: [String: Any]) throws {
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
// Calculate next run time (simplified - would use proper cron parsing in production)
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime)
try backgroundTaskScheduler.submit(request)
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)")
}
private func scheduleUserNotification(config: [String: Any]) throws {
let content = UNMutableNotificationContent()
content.title = config["title"] as? String ?? "Daily Notification"
content.body = config["body"] as? String ?? "Your daily update is ready"
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
// Create trigger (simplified - would use proper cron parsing in production)
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
let request = UNNotificationRequest(
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
content: content,
trigger: trigger
)
notificationCenter.add(request) { error in
if let error = error {
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
} else {
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
}
}
}
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
// Simplified implementation - would use proper cron parsing in production
// For now, return next day at 9 AM
return 86400 // 24 hours
}
// MARK: - Static Daily Reminder Methods
@objc func scheduleDailyReminder(_ call: CAPPluginCall) {
guard let id = call.getString("id"),
let title = call.getString("title"),
let body = call.getString("body"),
let time = call.getString("time") else {
call.reject("Missing required parameters: id, title, body, time")
return
}
let sound = call.getBool("sound", true)
let vibration = call.getBool("vibration", true)
let priority = call.getString("priority", "normal")
let repeatDaily = call.getBool("repeatDaily", true)
let timezone = call.getString("timezone")
print("DNP-REMINDER: Scheduling daily reminder: \(id)")
// Parse time (HH:mm format)
let timeComponents = time.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]),
hour >= 0 && hour <= 23,
minute >= 0 && minute <= 59 else {
call.reject("Invalid time format. Use HH:mm (e.g., 09:00)")
return
}
// Create notification content
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = sound ? .default : nil
content.categoryIdentifier = "DAILY_REMINDER"
// Set priority
if #available(iOS 15.0, *) {
switch priority {
case "high":
content.interruptionLevel = .critical
case "low":
content.interruptionLevel = .passive
default:
content.interruptionLevel = .active
}
}
// Create date components for daily trigger
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: repeatDaily
)
let request = UNNotificationRequest(
identifier: "reminder_\(id)",
content: content,
trigger: trigger
)
// Store reminder in UserDefaults
storeReminderInUserDefaults(
id: id,
title: title,
body: body,
time: time,
sound: sound,
vibration: vibration,
priority: priority,
repeatDaily: repeatDaily,
timezone: timezone
)
// Schedule the notification
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error = error {
print("DNP-REMINDER: Failed to schedule reminder: \(error)")
call.reject("Failed to schedule daily reminder: \(error.localizedDescription)")
} else {
print("DNP-REMINDER: Daily reminder scheduled successfully: \(id)")
call.resolve()
}
}
}
}
@objc func cancelDailyReminder(_ call: CAPPluginCall) {
guard let reminderId = call.getString("reminderId") else {
call.reject("Missing reminderId parameter")
return
}
print("DNP-REMINDER: Cancelling daily reminder: \(reminderId)")
// Cancel the notification
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
// Remove from UserDefaults
removeReminderFromUserDefaults(id: reminderId)
call.resolve()
}
@objc func getScheduledReminders(_ call: CAPPluginCall) {
print("DNP-REMINDER: Getting scheduled reminders")
// Get pending notifications
notificationCenter.getPendingNotificationRequests { requests in
let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") }
// Get stored reminder data from UserDefaults
let reminders = self.getRemindersFromUserDefaults()
var result: [[String: Any]] = []
for reminder in reminders {
let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" }
var reminderInfo = reminder
reminderInfo["isScheduled"] = isScheduled
result.append(reminderInfo)
}
DispatchQueue.main.async {
call.resolve(["reminders": result])
}
}
}
@objc func updateDailyReminder(_ call: CAPPluginCall) {
guard let reminderId = call.getString("reminderId") else {
call.reject("Missing reminderId parameter")
return
}
print("DNP-REMINDER: Updating daily reminder: \(reminderId)")
// Cancel existing reminder
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
// Update in UserDefaults
let title = call.getString("title")
let body = call.getString("body")
let time = call.getString("time")
let sound = call.getBool("sound")
let vibration = call.getBool("vibration")
let priority = call.getString("priority")
let repeatDaily = call.getBool("repeatDaily")
let timezone = call.getString("timezone")
updateReminderInUserDefaults(
id: reminderId,
title: title,
body: body,
time: time,
sound: sound,
vibration: vibration,
priority: priority,
repeatDaily: repeatDaily,
timezone: timezone
)
// Reschedule with new settings if all required fields are provided
if let title = title, let body = body, let time = time {
// Parse time
let timeComponents = time.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]) else {
call.reject("Invalid time format")
return
}
// Create new notification content
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = (sound ?? true) ? .default : nil
content.categoryIdentifier = "DAILY_REMINDER"
// Set priority
let finalPriority = priority ?? "normal"
if #available(iOS 15.0, *) {
switch finalPriority {
case "high":
content.interruptionLevel = .critical
case "low":
content.interruptionLevel = .passive
default:
content.interruptionLevel = .active
}
}
// Create date components for daily trigger
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: repeatDaily ?? true
)
let request = UNNotificationRequest(
identifier: "reminder_\(reminderId)",
content: content,
trigger: trigger
)
// Schedule the updated notification
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error = error {
call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)")
} else {
call.resolve()
}
}
}
} else {
call.resolve()
}
}
// MARK: - Helper Methods for Reminder Storage
private func storeReminderInUserDefaults(
id: String,
title: String,
body: String,
time: String,
sound: Bool,
vibration: Bool,
priority: String,
repeatDaily: Bool,
timezone: String?
) {
let reminderData: [String: Any] = [
"id": id,
"title": title,
"body": body,
"time": time,
"sound": sound,
"vibration": vibration,
"priority": priority,
"repeatDaily": repeatDaily,
"timezone": timezone ?? "",
"createdAt": Date().timeIntervalSince1970,
"lastTriggered": 0
]
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
reminders.append(reminderData)
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
print("DNP-REMINDER: Reminder stored: \(id)")
}
private func removeReminderFromUserDefaults(id: String) {
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
reminders.removeAll { ($0["id"] as? String) == id }
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
print("DNP-REMINDER: Reminder removed: \(id)")
}
private func getRemindersFromUserDefaults() -> [[String: Any]] {
return UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
}
private func updateReminderInUserDefaults(
id: String,
title: String?,
body: String?,
time: String?,
sound: Bool?,
vibration: Bool?,
priority: String?,
repeatDaily: Bool?,
timezone: String?
) {
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
for i in 0..<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)")
}
}