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