Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Jose Olarte III d8a0eaf413 refactor(android,ios): rename package com.timesafari to org.timesafari.dailynotification
- Android: move plugin source to org/timesafari/dailynotification, update
  namespace, manifest package, and all package/imports; change intent actions
  to org.timesafari.daily.NOTIFICATION and DISMISS
- iOS: update bundle IDs, BGTask identifiers, subsystem labels, and queue
  names in Plugin and Xcode projects
- Capacitor: update plugin class registration and appIds in configs
- Test apps (android-test-app, daily-notification-test, ios-test-app):
  applicationId/bundleId, manifests, ProGuard, scripts, and docs
- Docs: bulk update references; add CONSUMING_APP_MIGRATION_COM_TO_ORG.md
  for consuming app migration

BREAKING CHANGE: Consuming apps must update plugin class to
org.timesafari.dailynotification.DailyNotificationPlugin, manifest
receivers/actions, and iOS BGTask identifiers per migration doc.
2026-03-12 14:26:07 +08:00

2203 lines
92 KiB
Swift

//
// DailyNotificationPlugin.swift
// DailyNotificationPlugin
//
// Created by Matthew Raymer on 2025-09-22
// Copyright © 2025 TimeSafari. All rights reserved.
//
import Foundation
import UIKit
import Capacitor
import UserNotifications
import BackgroundTasks
import CoreData
import ObjectiveC
/**
* 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 = "org.timesafari.dailynotification.fetch"
private let notifyTaskIdentifier = "org.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()
// Register for notification delivery events (Notification Center pattern)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotificationDelivery(_:)),
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
// Register for app becoming active to check for missed rollovers
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppBecameActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
print("DNP-ROLLOVER: Observer registered for app becoming active")
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
// Debug: Log all available @objc methods for Capacitor discovery
let methods = getObjCMethods()
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
}
/**
* Debug helper: Get all @objc methods for this class
*/
private func getObjCMethods() -> [String] {
var methods: [String] = []
var methodCount: UInt32 = 0
let methodList = class_copyMethodList(type(of: self), &methodCount)
for i in 0..<Int(methodCount) {
if let method = methodList?[i] {
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
// Filter for methods that look like plugin methods (take CAPPluginCall)
if methodName.contains(":") && !methodName.hasPrefix("_") {
methods.append(methodName)
}
}
}
free(methodList)
return methods.sorted()
}
// 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) {
// Validate and extract configuration parameters
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 3: Process activeDidIntegration configuration
if let activeDidConfig = call.getObject("activeDidIntegration") {
// Extract and store activeDidIntegration configuration
// This enables TimeSafari-specific DID-based authentication and API integration
if let platform = activeDidConfig["platform"] as? String {
UserDefaults.standard.set(platform, forKey: "activeDidIntegration_platform")
}
if let storageType = activeDidConfig["storageType"] as? String {
UserDefaults.standard.set(storageType, forKey: "activeDidIntegration_storageType")
}
if let jwtExpirationSeconds = activeDidConfig["jwtExpirationSeconds"] as? Int {
UserDefaults.standard.set(jwtExpirationSeconds, forKey: "activeDidIntegration_jwtExpirationSeconds")
}
if let apiServer = activeDidConfig["apiServer"] as? String {
UserDefaults.standard.set(apiServer, forKey: "activeDidIntegration_apiServer")
}
if let activeDid = activeDidConfig["activeDid"] as? String {
UserDefaults.standard.set(activeDid, forKey: "activeDidIntegration_activeDid")
}
if let autoSync = activeDidConfig["autoSync"] as? Bool {
UserDefaults.standard.set(autoSync, forKey: "activeDidIntegration_autoSync")
}
if let identityChangeGraceSeconds = activeDidConfig["identityChangeGraceSeconds"] as? Int {
UserDefaults.standard.set(identityChangeGraceSeconds, forKey: "activeDidIntegration_identityChangeGraceSeconds")
}
print("DNP-PLUGIN: activeDidIntegration configuration stored")
}
// Determine database path (use provided or default)
let finalDbPath: String
if let dbPath = dbPath, !dbPath.isEmpty {
finalDbPath = dbPath
} else {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
finalDbPath = documentsPath.appendingPathComponent("daily_notifications.db").path
}
// Reinitialize storage with new database path if needed
if let currentStorage = storage {
if currentStorage.getDatabasePath() != finalDbPath {
storage = DailyNotificationStorage(databasePath: finalDbPath)
}
} else {
storage = DailyNotificationStorage(databasePath: finalDbPath)
}
// Delegate to storage to store configuration
storeConfiguration(
ttlSeconds: ttlSeconds,
prefetchLeadMinutes: prefetchLeadMinutes,
maxNotificationsPerDay: maxNotificationsPerDay,
retentionDays: retentionDays,
storageMode: storageMode,
dbPath: finalDbPath
)
call.resolve()
}
/**
* Configure native fetcher with API credentials (cross-platform)
*
* Matches Android configureNativeFetcher() functionality:
* - Stores configuration in database for persistence
* - Supports both jwtToken and jwtSecret for backward compatibility
* - Note: iOS native fetcher interface not yet implemented, but configuration is stored
*
* @param call Plugin call containing configuration parameters
*/
@objc func configureNativeFetcher(_ call: CAPPluginCall) {
guard let options = call.options else {
call.reject("Options are required")
return
}
guard let apiBaseUrl = options["apiBaseUrl"] as? String else {
call.reject("apiBaseUrl is required")
return
}
guard let activeDid = options["activeDid"] as? String else {
call.reject("activeDid is required")
return
}
// Support both jwtToken and jwtSecret for backward compatibility
let jwtToken = (options["jwtToken"] as? String) ?? (options["jwtSecret"] as? String)
guard let jwtToken = jwtToken else {
call.reject("jwtToken or jwtSecret is required")
return
}
print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...")
// Store configuration in database for persistence across app restarts
// Note: iOS native fetcher interface not yet implemented, but we store config for future use
let configId = "native_fetcher_config"
let configValue: [String: Any] = [
"apiBaseUrl": apiBaseUrl,
"activeDid": activeDid,
"jwtToken": jwtToken
]
// Convert to JSON string for storage
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
let jsonString = String(data: jsonData, encoding: .utf8) else {
call.reject("Failed to serialize configuration")
return
}
// Store configuration in UserDefaults for now
// This matches Android's approach of storing in database, but uses UserDefaults for simplicity
// Can be enhanced later to use CoreData when native fetcher interface is implemented
let configKey = "native_fetcher_config"
UserDefaults.standard.set(jsonString, forKey: configKey)
print("DNP-PLUGIN: Native fetcher configuration stored successfully")
call.resolve()
}
/**
* 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
}
do {
// Delegate to background fetch scheduler
try scheduleBackgroundFetch(config: config)
call.resolve()
} catch {
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
}
do {
// Delegate to user notification scheduler
try scheduleUserNotification(config: config)
call.resolve()
} catch {
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
}
do {
// Delegate to ScheduleHelper for dual scheduling orchestration
try DailyNotificationScheduleHelper.scheduleDualNotification(
contentFetchConfig: contentFetchConfig,
userNotificationConfig: userNotificationConfig,
scheduleBackgroundFetch: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleBackgroundFetch(config: config)
},
scheduleUserNotification: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleUserNotification(config: config)
}
)
call.resolve()
} catch {
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
}
}
@objc func getDualScheduleStatus(_ call: CAPPluginCall) {
Task {
do {
// Delegate to private helper (will be moved to service in future batch)
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"])
}
// Delegate to ScheduleHelper for health status (combines multiple sources)
return try await DailyNotificationScheduleHelper.getHealthStatus(
scheduler: scheduler,
storage: self.storage,
stateActor: await self.stateActor
)
}
// 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
*
* Enhanced with:
* - Recovery logic (verify scheduled notifications)
* - Next task scheduling
* - Graceful expiration handling
*
* @param task BGAppRefreshTask
*/
private func handleBackgroundFetch(task: BGAppRefreshTask) {
print("DNP-FETCH: Background fetch task started")
// Enhanced expiration handler with graceful cleanup
var taskCompleted = false
task.expirationHandler = {
guard !taskCompleted else { return }
print("DNP-FETCH: Background fetch task expired - performing graceful cleanup")
// Cancel any ongoing operations
// Note: In production, you might want to cancel URLSession tasks here
task.setTaskCompleted(success: false)
taskCompleted = true
}
// Phase 3: Check for JWT-signed fetcher configuration
// If native fetcher is configured, use it; otherwise fall back to dummy content
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
// Save content to storage via state actor (thread-safe)
Task {
let content: NotificationContent
if let configJson = nativeFetcherConfig,
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let apiBaseUrl = config["apiBaseUrl"] as? String,
let activeDid = config["activeDid"] as? String,
let jwtToken = config["jwtToken"] as? String {
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
// Attempt to fetch content from TimeSafari API
// Note: This is a minimal implementation - can be extended with full API client
do {
let fetchedContent = try await fetchContentFromAPI(
apiBaseUrl: apiBaseUrl,
activeDid: activeDid,
jwtToken: jwtToken
)
content = fetchedContent
print("DNP-FETCH: Successfully fetched content from API")
} catch {
// Fallback to dummy content on fetch failure
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
content = NotificationContent(
id: "fallback_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: nil,
payload: ["fetchError": error.localizedDescription],
etag: nil
)
}
} else {
// Fallback: Dummy content fetch (no network)
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: nil,
payload: nil,
etag: nil
)
}
do {
// Use the content (either from JWT fetcher or dummy)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveNotificationContent(content)
// Mark successful run
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
} else {
// Fallback to direct storage access
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
} else {
// Fallback for iOS < 13
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
// Phase 3.3: Recovery logic - verify scheduled notifications
// Check if notifications are still scheduled after fetch
if let reactivationManager = self.reactivationManager {
// Perform lightweight verification (non-blocking)
Task {
do {
let verificationResult = try await reactivationManager.verifyFutureNotifications()
if verificationResult.notificationsMissing > 0 {
print("DNP-FETCH: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
// Note: Full recovery happens on app launch, not in background task
}
} catch {
// Non-fatal: Log but don't fail task
print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)")
}
}
}
// Phase 3.3: Schedule next background task
// Calculate next fetch time based on notification schedule
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
self.scheduleBackgroundFetch(scheduledTime: nextTime)
print("DNP-FETCH: Next background fetch scheduled")
} else {
print("DNP-FETCH: No future notifications found, skipping next task schedule")
}
} else {
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
}
guard !taskCompleted else { return }
task.setTaskCompleted(success: true)
taskCompleted = true
print("DNP-FETCH: Background fetch task completed successfully")
} catch {
print("DNP-FETCH: Background fetch task failed: \(error.localizedDescription)")
guard !taskCompleted else { return }
task.setTaskCompleted(success: false)
taskCompleted = true
}
}
}
/**
* Handle background notification task
*
* Enhanced with:
* - Recovery logic (verify scheduled notifications)
* - Next task scheduling
* - Graceful expiration handling
*
* @param task BGProcessingTask
*/
private func handleBackgroundNotify(task: BGProcessingTask) {
print("DNP-NOTIFY: Background notify task started")
// Enhanced expiration handler with graceful cleanup
var taskCompleted = false
task.expirationHandler = {
guard !taskCompleted else { return }
print("DNP-NOTIFY: Background notify task expired - performing graceful cleanup")
task.setTaskCompleted(success: false)
taskCompleted = true
}
Task {
do {
// Phase 3.3: Recovery logic - verify scheduled notifications
// Check if notifications are still scheduled
if let reactivationManager = self.reactivationManager {
// Perform lightweight verification (non-blocking)
let verificationResult = try await reactivationManager.verifyFutureNotifications()
if verificationResult.notificationsMissing > 0 {
print("DNP-NOTIFY: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
// Note: Full recovery happens on app launch, not in background task
}
}
// Phase 1: Not used for single daily schedule
// This will be used in Phase 2+ for rolling window maintenance
// For now, just verify state
// Phase 3.3: Schedule next background task if needed
// For notify task, schedule next occurrence if applicable
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
// Calculate next notify task time (if applicable)
// Note: Notify tasks are typically scheduled less frequently than fetch tasks
print("DNP-NOTIFY: Next notification scheduled at \(nextTime)")
}
}
guard !taskCompleted else { return }
task.setTaskCompleted(success: true)
taskCompleted = true
print("DNP-NOTIFY: Background notify task completed successfully")
} catch {
print("DNP-NOTIFY: Background notify task failed: \(error.localizedDescription)")
guard !taskCompleted else { return }
task.setTaskCompleted(success: false)
taskCompleted = 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")
// Validate and 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
)
// Delegate to UNUserNotificationCenter to schedule notification
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error = error {
call.reject("Failed to schedule daily reminder: \(error.localizedDescription)")
} else {
call.resolve()
}
}
}
}
@objc func cancelDailyReminder(_ call: CAPPluginCall) {
guard let reminderId = call.getString("reminderId") else {
call.reject("Missing reminderId parameter")
return
}
// Cancel the notification
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
// Delegate to storage for reminder removal
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
}
// 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"
let rawRollover = call.getInt("rolloverIntervalMinutes") ?? 0
let rolloverIntervalMinutes = rawRollover > 0 ? rawRollover : nil
// 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 (persist rollover interval for dev/testing; survives app restart)
let content = NotificationContent(
id: "daily_\(Date().timeIntervalSince1970)",
title: title,
body: body,
scheduledTime: scheduledTime,
fetchedAt: fetchedAt,
url: url,
payload: nil,
etag: nil,
rolloverIntervalMinutes: rolloverIntervalMinutes
)
// Delegate to ScheduleHelper for orchestration
Task {
let scheduled = await DailyNotificationScheduleHelper.scheduleDailyNotification(
content: content,
scheduledTime: scheduledTime,
scheduler: scheduler,
storage: self.storage,
stateActor: await self.stateActor,
scheduleBackgroundFetch: { [weak self] (scheduledTime: Int64) -> Void in
self?.scheduleBackgroundFetch(scheduledTime: scheduledTime)
}
)
DispatchQueue.main.async {
if scheduled {
call.resolve()
} else {
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)
}
}
}
}
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
*
* @param call Plugin call with optional secondsFromNow (default: 5)
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
*/
@objc func testAlarm(_ call: CAPPluginCall) {
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
guard let scheduler = scheduler else {
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
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 secondsFromNow parameter (default: 5)
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
// Ensure minimum of 1 second (iOS requirement)
let validSeconds = max(1, secondsFromNow)
Task {
do {
// Check permissions first
let permissionStatus = await notificationCenter.notificationSettings()
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
message: "Notification permissions not granted"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Create test notification content
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Test Notification"
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
notificationContent.sound = .default
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
notificationContent.userInfo = [
"notification_id": "test_\(Date().timeIntervalSince1970)",
"is_test": true
]
// Create time interval trigger (fires in X seconds)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
// Create notification request with unique ID
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
let request = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: trigger
)
// Schedule notification
try await notificationCenter.add(request)
// Calculate trigger time in milliseconds
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
let result: [String: Any] = [
"scheduled": true,
"secondsFromNow": validSeconds,
"triggerAtMillis": triggerAtMillis
]
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
DispatchQueue.main.async {
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
call.resolve(result)
}
} catch {
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
message: "Failed to schedule test alarm: \(error.localizedDescription)"
)
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
}
}
}
/**
* Debug method: List all available plugin methods
* Useful for verifying Capacitor method discovery
*
* @param call Plugin call
*/
@objc func listAvailableMethods(_ call: CAPPluginCall) {
let methods = getObjCMethods()
let result: [String: Any] = [
"methods": methods,
"count": methods.count,
"testAlarmFound": methods.contains("testAlarm:")
]
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
call.resolve(result)
}
/**
* Get the last notification that was delivered
*
* @param call Plugin call
*/
@objc func getLastNotification(_ call: CAPPluginCall) {
Task {
let lastNotification: NotificationContent?
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
lastNotification = await stateActor.getLastNotification()
} else {
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 {
// Delegate cancellation to scheduler
await scheduler.cancelAllNotifications()
// Clear storage via stateActor if available (thread-safe), otherwise use storage directly
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
await stateActor.clearAllNotifications()
} else {
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 {
// Delegate to scheduler for permission status and pending count
let isEnabled = await scheduler.checkPermissionStatus() == .authorized
let pendingCount = await scheduler.getPendingNotificationCount()
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
let lastNotification: NotificationContent?
let settings: [String: Any]
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
lastNotification = await stateActor.getLastNotification()
settings = await stateActor.getSettings()
} else {
lastNotification = self.storage?.getLastNotification()
settings = self.storage?.getSettings() ?? [:]
}
// Delegate to scheduler for next notification time
let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0
// Delegate to storage for rollover status
let lastRolloverTime = storage?.getLastRolloverTime() ?? 0
var result: [String: Any] = [
"isEnabled": isEnabled,
"isScheduled": pendingCount > 0,
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
"nextNotificationTime": nextNotificationTime,
"pending": pendingCount,
"rolloverEnabled": true, // Indicate rollover is active
"lastRolloverTime": lastRolloverTime, // When last rollover occurred
"settings": settings
]
DispatchQueue.main.async {
call.resolve(result)
}
}
}
/**
* Handle notification delivery event (from Notification Center)
*
* This is called when AppDelegate posts notification delivery event
* Matches Android's scheduleNextNotification() behavior
*
* @param notification NSNotification with userInfo containing notification_id and scheduled_time
*/
@objc private func handleNotificationDelivery(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleNotificationDelivery called")
print("DNP-ROLLOVER: handleNotificationDelivery called")
// Extract notification data from userInfo
guard let userInfo = notification.userInfo,
let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil")
print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])")
return
}
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
// Track notify execution
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
storage?.saveLastNotifyExecution(timestamp: currentTime)
// Delegate rollover processing (glue logic - will be moved to service in future)
Task {
await processRollover(notificationId: notificationId, scheduledTime: scheduledTime)
}
}
/**
* Handle app becoming active (foreground)
*
* This is called when the app becomes active to check for missed rollovers
* that occurred while the app was backgrounded.
*
* @param notification NSNotification for app becoming active
*/
@objc private func handleAppBecameActive(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
print("DNP-ROLLOVER: handleAppBecameActive called")
// Perform lightweight rollover check when app becomes active
// This handles cases where notifications fired while app was backgrounded
reactivationManager?.performActiveRolloverCheck()
}
/**
* Process rollover for delivered notification
*
* @param notificationId ID of notification that was delivered
* @param scheduledTime Scheduled time of delivered notification
*/
private func processRollover(notificationId: String, scheduledTime: Int64) async {
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
guard let scheduler = scheduler, let storage = storage else {
NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@",
scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil")
print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")")
return
}
// Get the notification content that was delivered
guard let content = storage.getNotificationContent(id: notificationId) else {
NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId)
print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)")
// Log available notification IDs for debugging
let allNotifications = storage.getAllNotifications()
let availableIds = allNotifications.map { $0.id }.joined(separator: ", ")
NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds)
print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]")
return
}
NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId)
print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification")
// Delegate to scheduler to schedule next notification (glue logic - will be moved to service)
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
let scheduled = await scheduler.scheduleNextNotification(
content,
storage: storage,
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
)
if scheduled {
NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId)
print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled")
} else {
NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId)
print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false")
}
// Rollover processing is non-fatal - recovery will handle on next launch if needed
}
/**
* Format time for logging
*
* @param timestamp Timestamp in milliseconds
* @return Formatted time string
*/
private func formatTime(_ timestamp: Int64) -> String {
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
/**
* Check permission status
* Returns boolean flags for each permission type
*
* @param call Plugin call
*/
@objc func checkPermissionStatus(_ 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 {
do {
// Delegate to scheduler for permission status check
let notificationStatus = await scheduler.checkPermissionStatus()
let notificationsEnabled = notificationStatus == .authorized
// Format result (iOS-specific: exactAlarm and wakeLock map to notification permission)
let result: [String: Any] = [
"notificationsEnabled": notificationsEnabled,
"exactAlarmEnabled": notificationsEnabled, // iOS equivalent
"wakeLockEnabled": notificationsEnabled, // iOS equivalent (Background App Refresh)
"allPermissionsGranted": notificationsEnabled
]
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
DispatchQueue.main.async {
call.reject("Failed to check permission status: \(error.localizedDescription)", "permission_check_failed")
}
}
}
}
/**
* Request notification permissions
* Shows system permission dialog if permissions haven't been determined yet
*
* @param call Plugin call
*/
@objc func requestNotificationPermissions(_ 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 {
do {
// Delegate to scheduler for permission request
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 permissions: \(error.localizedDescription)", "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) {
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 {
do {
// Delegate to scheduler for permission status check
let status = await scheduler.checkPermissionStatus()
// Format result with all status flags
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) {
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 {
do {
// Delegate to scheduler for permission request
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 {
// Delegate to UNUserNotificationCenter for pending requests
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) {
// Note: BGTaskScheduler doesn't provide a way to query registered task identifiers
// We assume tasks are registered if setupBackgroundTasks() was called
// Background App Refresh status cannot be checked programmatically
// User must check in Settings app
// Delegate storage access to storage service
let lastFetchExecution: Any = storage?.getLastSuccessfulRun() ?? NSNull()
let lastNotifyExecution: Any = storage?.getLastNotifyExecution() ?? NSNull()
let result: [String: Any] = [
"fetchTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
"notifyTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
"lastFetchExecution": lastFetchExecution,
"lastNotifyExecution": lastNotifyExecution,
"backgroundRefreshEnabled": NSNull() // Cannot check programmatically
]
call.resolve(result)
}
/**
* Open notification settings (iOS-specific)
*
* @param call Plugin call
*/
@objc func openNotificationSettings(_ call: CAPPluginCall) {
// Delegate to UIApplication to open settings
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
call.reject("Invalid settings URL", "open_settings_failed")
return
}
guard UIApplication.shared.canOpenURL(settingsUrl) else {
call.reject("Cannot open settings URL", "open_settings_failed")
return
}
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open notification settings", "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
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
call.reject("Invalid settings URL", "open_settings_failed")
return
}
guard UIApplication.shared.canOpenURL(settingsUrl) else {
call.reject("Cannot open settings URL", "open_settings_failed")
return
}
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open settings", "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) {
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)
// iOS doesn't have per-channel control, so check app-wide notification authorization
let channelId = call.getString("channelId") ?? "default"
Task {
// Delegate to scheduler for permission status check
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) {
// Get channelId from call (optional, for API parity with Android)
// iOS doesn't have per-channel settings, so open app-wide notification settings
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
call.reject("Invalid settings URL", "open_settings_failed")
return
}
guard UIApplication.shared.canOpenURL(settingsUrl) else {
call.reject("Cannot open settings URL", "open_settings_failed")
return
}
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open settings", "open_settings_failed")
}
}
}
}
/**
* Update notification settings
*
* @param call Plugin call containing new settings
*/
@objc func updateSettings(_ call: CAPPluginCall) {
guard let settings = call.getObject("settings") else {
let error = DailyNotificationErrorCodes.missingParameter("settings")
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
Task {
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
await stateActor.saveSettings(settings)
} else {
self.storage?.saveSettings(settings)
}
DispatchQueue.main.async {
call.resolve()
}
}
}
// MARK: - Phase 3: JWT Fetcher HTTP Implementation
/**
* Fetch notification content from TimeSafari API using JWT authentication
*
* Phase 3: Complete HTTP implementation for JWT-signed fetcher
*
* This method:
* - Makes authenticated HTTP request to TimeSafari API
* - Uses JWT token in Authorization header
* - Parses response and converts to NotificationContent
* - Handles errors gracefully with fallback
*
* @param apiBaseUrl Base URL for TimeSafari API server
* @param activeDid Active DID for authentication
* @param jwtToken JWT token for Authorization header
* @return NotificationContent from API or throws error
*/
private func fetchContentFromAPI(
apiBaseUrl: String,
activeDid: String,
jwtToken: String
) async throws -> NotificationContent {
// Construct API endpoint URL
// Note: This is a minimal implementation - can be extended with full endpoint support
let endpoint = "/api/v2/report/offers"
guard let baseURL = URL(string: apiBaseUrl),
let url = URL(string: endpoint, relativeTo: baseURL) else {
throw NSError(
domain: "DailyNotification",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"]
)
}
// Create HTTP request with JWT authentication
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30.0 // 30 second timeout
print("DNP-FETCH-HTTP: Making request to \(url.absoluteString)")
// Execute HTTP request
let (data, response) = try await URLSession.shared.data(for: request)
// Validate HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "DailyNotification",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"]
)
}
print("DNP-FETCH-HTTP: Response status code: \(httpResponse.statusCode)")
// Check for successful response
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "DailyNotification",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP error: \(httpResponse.statusCode)"]
)
}
// Parse JSON response
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(
domain: "DailyNotification",
code: -3,
userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response"]
)
}
// Convert API response to NotificationContent
// Note: This is a minimal conversion - can be extended to handle full TimeSafari API response structure
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
let content = NotificationContent(
id: "api_\(currentTime)",
title: json["title"] as? String ?? "Daily Update",
body: json["body"] as? String ?? "Your daily notification is ready",
scheduledTime: currentTime + (5 * 60 * 1000), // 5 min from now
fetchedAt: currentTime,
url: apiBaseUrl,
payload: json,
etag: httpResponse.value(forHTTPHeaderField: "ETag")
)
print("DNP-FETCH-HTTP: Successfully converted API response to NotificationContent")
return content
}
// MARK: - Phase 1: Helper Methods
/**
* Get next scheduled notification time
*
* Helper method to get the next scheduled notification time for
* scheduling background tasks. Uses async/await internally.
*
* @return Next scheduled notification time in milliseconds (Int64), or nil if none
*/
private func getNextScheduledNotificationTime() -> Int64? {
guard let scheduler = scheduler else {
return nil
}
// Use async helper to get next notification time
// Note: This is called from background task handlers which are already async
var nextTime: Int64? = nil
let semaphore = DispatchSemaphore(value: 0)
Task {
nextTime = await scheduler.getNextNotificationTime()
semaphore.signal()
}
// Wait with timeout (2 seconds - background tasks have limited time)
_ = semaphore.wait(timeout: .now() + 2.0)
return nextTime
}
/**
* 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 "org.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: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "testAlarm", 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
}
}