Files
daily-notification-plugin/ios/Plugin/DailyNotificationPlugin.swift
Jose Olarte III 7f7d65cae6 feat(ios): add local notification diagnostics for debug panels
Expose bridge-safe pending, delivered, and settings snapshots from
UNUserNotificationCenter so apps can inspect local notification state
without native date objects or large payloads.

- Add getDeliveredNotifications and getNotificationSettings on iOS
- Harden getPendingNotifications with triggerTimestamp/triggerDateIso
- Always resolve with serializable values; omit userInfo from delivered
- Register new Capacitor methods and TypeScript definitions/web stubs
2026-05-21 18:20:29 +08:00

2818 lines
121 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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"
/// Prefix for deterministic UNNotificationRequest identifiers: `predictive_\(Int(timestamp))` with `timestamp` in epoch milliseconds.
private let predictiveNotificationPrefix = "predictive_"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`predictive_*`).
private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual"
/// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private let dualScheduleConfigKey = "dual_schedule_config"
/// Matches Android [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY] stable id for chained dual notify.
private let dualNotifyScheduleIdStorageKey = "dual_notify_schedule_id"
/// Max slip after nominal notify time before showing fallback (parity with product discussion).
private let dualChainedMaxSlipSeconds: TimeInterval = 15 * 60
/// Parity with Android SharedPreferences `daily_notification_timesafari` + `starredPlanIds`.
private let starredPlanIdsStorageKey = "daily_notification_timesafari.starredPlanIds"
// 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 NativeNotificationFetcherRegistry.shared.fetcher != nil else {
call.reject("No native fetcher registered. Host app must call DailyNotificationPlugin.registerNativeFetcher(_:) before configureNativeFetcher.")
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
}
let jwtTokenPoolMax = 128
var jwtTokenPool: [String]? = nil
if let rawTokens = options["jwtTokens"] as? [Any] {
let parsed = rawTokens.compactMap { $0 as? String }.filter { !$0.isEmpty }
if parsed.count > jwtTokenPoolMax {
call.reject("jwtTokens must have at most \(jwtTokenPoolMax) entries")
return
}
jwtTokenPool = parsed.isEmpty ? nil : parsed
} else if let jsonStr = options["jwtTokenPoolJson"] as? String, !jsonStr.isEmpty {
guard let data = jsonStr.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String] else {
call.reject("jwtTokenPoolJson must be a JSON array of strings")
return
}
let filtered = parsed.filter { !$0.isEmpty }
if filtered.count > jwtTokenPoolMax {
call.reject("jwtTokens must have at most \(jwtTokenPoolMax) entries")
return
}
jwtTokenPool = filtered.isEmpty ? nil : filtered
}
print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...")
if let pool = jwtTokenPool {
print("DNP-PLUGIN: jwtTokenPool size=\(pool.count)")
}
NativeNotificationFetcherRegistry.shared.fetcher?.configure(
apiBaseUrl: apiBaseUrl,
activeDid: activeDid,
jwtToken: jwtToken,
jwtTokenPool: jwtTokenPool
)
var configValue: [String: Any] = [
"apiBaseUrl": apiBaseUrl,
"activeDid": activeDid,
"jwtToken": jwtToken
]
if let pool = jwtTokenPool {
configValue["jwtTokenPool"] = pool
}
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
let jsonString = String(data: jsonData, encoding: .utf8) else {
call.reject("Failed to serialize configuration")
return
}
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 {
// Chained dual: prefetch BG task only; user notification is armed after prefetch completes.
try scheduleBackgroundFetch(config: contentFetchConfig)
saveDualScheduleConfig(config)
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
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)")
}
}
}
}
/// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (`predictive_*`).
@objc func cancelDualSchedule(_ call: CAPPluginCall) {
do {
performCancelDualSchedule()
call.resolve()
} catch {
call.reject("Cancel dual schedule failed: \(error.localizedDescription)")
}
}
/// Cancel only the dual content-fetch task and dual user notification. Used by cancelDualSchedule() and updateDualScheduleConfig().
private func performCancelDualSchedule() {
backgroundTaskScheduler.cancel(taskRequestWithIdentifier: fetchTaskIdentifier)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
UserDefaults.standard.removeObject(forKey: dualScheduleConfigKey)
UserDefaults.standard.removeObject(forKey: dualNotifyScheduleIdStorageKey)
print("DNP-PLUGIN: Canceled dual schedule (fetch task + user notification)")
}
@objc func updateDualScheduleConfig(_ 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 {
performCancelDualSchedule()
try scheduleBackgroundFetch(config: contentFetchConfig)
saveDualScheduleConfig(config)
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
call.resolve()
} catch {
call.reject("Update dual schedule failed: \(error.localizedDescription)")
}
}
@objc func updateStarredPlans(_ call: CAPPluginCall) {
guard let raw = call.options?["planIds"] else {
call.reject("planIds is required and must be a string array")
return
}
var planIds: [String] = []
if let a = raw as? [String] {
planIds = a
} else if let anyArr = raw as? [Any] {
for (index, item) in anyArr.enumerated() {
guard let s = item as? String else {
call.reject("planIds must be an array of strings (non-string at index \(index))")
return
}
planIds.append(s)
}
} else {
call.reject("planIds must be a string array")
return
}
for (index, id) in planIds.enumerated() {
if id.trimmingCharacters(in: .whitespaces).isEmpty {
call.reject("planIds[\(index)] must be a non-empty string")
return
}
}
do {
let jsonData = try JSONSerialization.data(withJSONObject: planIds, options: [])
guard let jsonStr = String(data: jsonData, encoding: .utf8) else {
call.reject("Failed to serialize planIds")
return
}
UserDefaults.standard.set(jsonStr, forKey: starredPlanIdsStorageKey)
let updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
UserDefaults.standard.set(updatedAt, forKey: "\(starredPlanIdsStorageKey).updatedAt")
call.resolve([
"success": true,
"planIdsCount": planIds.count,
"updatedAt": updatedAt
])
} catch {
call.reject("Failed to update starred plans: \(error.localizedDescription)")
}
}
@objc func getStarredPlans(_ call: CAPPluginCall) {
let jsonStr = UserDefaults.standard.string(forKey: starredPlanIdsStorageKey) ?? "[]"
let planIds: [String]
if let data = jsonStr.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {
planIds = arr
} else {
planIds = []
}
let updatedKey = "\(starredPlanIdsStorageKey).updatedAt"
let updatedAt: Int64
if let n = UserDefaults.standard.object(forKey: updatedKey) as? Int64 {
updatedAt = n
} else if let num = UserDefaults.standard.object(forKey: updatedKey) as? NSNumber {
updatedAt = num.int64Value
} else {
updatedAt = 0
}
call.resolve([
"planIds": planIds,
"count": planIds.count,
"updatedAt": updatedAt
])
}
/// Persist dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private func saveDualScheduleConfig(_ config: [String: Any]) {
guard config["userNotification"] != nil,
let jsonData = try? JSONSerialization.data(withJSONObject: config),
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
UserDefaults.standard.set(jsonString, forKey: dualScheduleConfigKey)
}
/// Chained dual: arm a **one-shot** user notification at `max(T, prefetchCompletedAt)` (capped by slip), never before prefetch completes.
private func armChainedDualNotificationAfterPrefetch(fetchedContent: NotificationContent, prefetchCompletedAt: Date) {
guard let configJson = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let userNotification = config["userNotification"] as? [String: Any] else {
return
}
let relationship = config["relationship"] as? [String: Any]
let contentTimeoutMs = (relationship?["contentTimeout"] as? NSNumber)?.intValue ?? 300_000
let fallbackBehavior = relationship?["fallbackBehavior"] as? String ?? "show_default"
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let useFetched = (nowMs - fetchedContent.fetchedAt) <= contentTimeoutMs
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
guard let nominalT = dualNextWallClockDate(scheduleStr: scheduleStr, after: prefetchCompletedAt.addingTimeInterval(-1)) else {
print("DNP-FETCH: Dual chained: invalid notify cron")
return
}
let slippedLate = prefetchCompletedAt.timeIntervalSince(nominalT) > dualChainedMaxSlipSeconds
var fireDate = max(nominalT, prefetchCompletedAt)
if slippedLate {
fireDate = Date().addingTimeInterval(2)
}
let title: String
let body: String
if slippedLate && fallbackBehavior == "show_default" {
title = userNotification["title"] as? String ?? "Daily Notification"
body = userNotification["body"] as? String ?? "Your daily update is ready"
} else if useFetched {
title = fetchedContent.title ?? "Daily Notification"
body = fetchedContent.body ?? "Your daily update is ready"
} else if fallbackBehavior == "show_default" {
title = userNotification["title"] as? String ?? "Daily Notification"
body = userNotification["body"] as? String ?? "Your daily update is ready"
} else {
return
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = (userNotification["sound"] as? Bool ?? true) ? .default : nil
let scheduledMs = Int64(fireDate.timeIntervalSince1970 * 1000)
content.userInfo = [
"notification_id": dualNotificationRequestIdentifier,
"scheduled_time": NSNumber(value: scheduledMs)
]
let cal = Calendar.current
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: fireDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
let request = UNNotificationRequest(identifier: dualNotificationRequestIdentifier, content: content, trigger: trigger)
notificationCenter.add(request) { err in
if let e = err {
print("DNP-FETCH: Failed to arm chained dual notification: \(e.localizedDescription)")
} else {
print("DNP-FETCH: Armed chained dual notification at \(fireDate) (useFetched=\(useFetched))")
}
}
}
private func dualNextWallClockDate(scheduleStr: String, after date: Date) -> Date? {
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
guard parts.count >= 2,
let minute = Int(parts[0]), minute >= 0, minute <= 59,
let hour = Int(parts[1]), hour >= 0, hour <= 23 else {
return nil
}
var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
return Calendar.current.nextDate(after: date, matching: comp, matchingPolicy: .nextTime)
}
/**
* 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
}
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
Task {
let prefetchCompletedAt = Date()
let fetchTimeMs = Int64(prefetchCompletedAt.timeIntervalSince1970 * 1000)
let content: NotificationContent
if let reg = NativeNotificationFetcherRegistry.shared.fetcher {
let ctx = FetchContext(
trigger: "prefetch",
scheduledTimeMillis: nil,
fetchTimeMillis: fetchTimeMs,
metadata: [:]
)
do {
let list = try await withTimeout(milliseconds: 30_000) {
try await reg.fetchContent(context: ctx)
}
if let first = list.first {
content = first
print("DNP-FETCH: Native fetcher returned content id=\(first.id)")
} else {
print("DNP-FETCH: Native fetcher returned empty list; using placeholder")
content = NotificationContent(
id: "empty_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["empty": true],
etag: nil
)
}
} catch {
print("DNP-FETCH: Native fetcher failed (\(error.localizedDescription)), using fallback content")
content = NotificationContent(
id: "fallback_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["fetchError": error.localizedDescription],
etag: nil
)
}
} else 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 jwtFromPrimary = (config["jwtToken"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let jwtFromPool = (config["jwtTokenPool"] as? [String])?.first { !$0.isEmpty }
let bearerToken = jwtFromPrimary ?? jwtFromPool
if let jwtToken = bearerToken {
print("DNP-FETCH: Legacy in-plugin HTTP (no registered native fetcher)")
do {
content = try await fetchContentFromAPI(
apiBaseUrl: apiBaseUrl,
activeDid: activeDid,
jwtToken: jwtToken
)
} catch {
content = NotificationContent(
id: "fallback_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["fetchError": error.localizedDescription],
etag: nil
)
}
} else {
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: nil,
etag: nil
)
}
} else {
print("DNP-FETCH: Using dummy content (no native fetcher, no config)")
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: nil,
etag: nil
)
}
do {
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
} else {
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
} else {
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) != nil {
self.armChainedDualNotificationAfterPrefetch(fetchedContent: content, prefetchCompletedAt: prefetchCompletedAt)
do {
try self.scheduleNextDualPrefetchFromPersistedConfig()
} catch {
print("DNP-FETCH: Next dual prefetch schedule failed: \(error.localizedDescription)")
}
}
if let reactivationManager = self.reactivationManager {
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")
}
} catch {
print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)")
}
}
}
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) == nil {
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
}
}
}
private func scheduleNextDualPrefetchFromPersistedConfig() throws {
guard let json = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
let data = json.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let cf = root["contentFetch"] as? [String: Any] else {
return
}
try scheduleBackgroundFetch(config: cf)
}
/**
* 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
// Parse cron "minute hour * * *" for daily at local time (replace semantics: one dual notification)
let scheduleStr = config["schedule"] as? String ?? "0 9 * * *"
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
let hour: Int
let minute: Int
if parts.count >= 2, let m = Int(parts[0]), let h = Int(parts[1]), m >= 0, m <= 59, h >= 0, h <= 23 {
minute = m
hour = h
} else {
minute = 0
hour = 9
}
var dateComp = DateComponents()
dateComp.hour = hour
dateComp.minute = minute
dateComp.second = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
// Replace any existing dual notification (stable id for cancelDualSchedule and updateDualScheduleConfig)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
let request = UNNotificationRequest(
identifier: dualNotificationRequestIdentifier,
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 (id: \(self.dualNotificationRequestIdentifier))")
}
}
}
/// Parse cron "minute hour * * *" (daily) and return seconds from now until next occurrence (device local time).
/// Matches Android calculateNextRunTime semantics for parity.
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
let parts = schedule.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
guard parts.count >= 2,
let minute = Int(parts[0]), minute >= 0, minute <= 59,
let hour = Int(parts[1]), hour >= 0, hour <= 23 else {
print("DNP-SCHEDULE: Invalid cron format: \(schedule), defaulting to 24h from now")
return 24 * 60 * 60
}
var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
let cal = Calendar.current
guard let next = cal.nextDate(after: Date(), matching: comp, matchingPolicy: .nextTime) else {
return 24 * 60 * 60
}
let interval = next.timeIntervalSinceNow
return interval > 0 ? interval : (24 * 60 * 60)
}
/// Epoch milliseconds for the next local occurrence of `hour`:`minute` (used only to build predictive IDs from HH:mm schedules).
private func epochMillisNextDailyOccurrence(hour: Int, minute: Int) -> Int64 {
var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
guard let next = Calendar.current.nextDate(after: Date(), matching: comp, matchingPolicy: .nextTime) else {
return Int64(Date().timeIntervalSince1970 * 1000)
}
return Int64(next.timeIntervalSince1970 * 1000)
}
private func predictiveNotificationId(epochMillis: Int64) -> String {
"\(predictiveNotificationPrefix)\(epochMillis)"
}
/// Reads persisted millis for a reminder, or derives from stored `time` (HH:mm) for legacy rows.
private func predictiveEpochMillis(from reminder: [String: Any]) -> Int64? {
if let n = reminder["predictiveEpochMillis"] as? NSNumber {
return n.int64Value
}
guard let time = reminder["time"] as? String else { return nil }
let parts = time.components(separatedBy: ":")
guard parts.count == 2,
let hour = Int(parts[0]),
let minute = Int(parts[1]),
hour >= 0, hour <= 23,
minute >= 0, minute <= 59 else {
return nil
}
return epochMillisNextDailyOccurrence(hour: hour, minute: minute)
}
// MARK: - Predictive batch API (replace-all UNUserNotificationCenter)
/// Removes only pending and delivered notifications whose identifiers begin with `predictive_`. Does not touch dual/org IDs or other stacks.
@objc func clearAllNotifications(_ call: CAPPluginCall) {
NSLog("DNP-BATCH: clearAllNotifications — removing predictive_* pending and delivered only")
print("DNP-BATCH: clearAllNotifications — removing predictive_* pending and delivered only")
let center = notificationCenter
let prefix = predictiveNotificationPrefix
let group = DispatchGroup()
group.enter()
center.getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: ids)
NSLog("DNP-BATCH: cleared \(ids.count) pending predictive id(s)")
print("DNP-BATCH: cleared \(ids.count) pending predictive id(s)")
group.leave()
}
group.enter()
center.getDeliveredNotifications { notifications in
let ids = notifications.map { $0.request.identifier }.filter { $0.hasPrefix(prefix) }
center.removeDeliveredNotifications(withIdentifiers: ids)
NSLog("DNP-BATCH: cleared \(ids.count) delivered predictive id(s)")
print("DNP-BATCH: cleared \(ids.count) delivered predictive id(s)")
group.leave()
}
group.notify(queue: .main) {
call.resolve()
}
}
/// Add one-shot reminders at epoch-ms timestamps. Does not remove other requests; identical IDs replace pending entries. Caller should clear first if needed.
/// Adds/overwrites predictive notifications using deterministic IDs.
/// Does NOT clear existing notifications. Caller is responsible for lifecycle.
@objc func scheduleNotifications(_ call: CAPPluginCall) {
guard let timestamps = call.getArray("timestamps", Double.self) else {
call.reject("Missing timestamps")
return
}
NSLog("DNP-BATCH: scheduleNotifications — additive scheduling for \(timestamps.count) timestamp(s)")
print("DNP-BATCH: scheduleNotifications — additive scheduling for \(timestamps.count) timestamp(s)")
for ts in timestamps {
let date = Date(timeIntervalSince1970: ts / 1000)
guard date.timeIntervalSinceNow > 0 else {
NSLog("DNP-BATCH: skip stale timestamp ts=\(ts) (not in the future)")
print("DNP-BATCH: skip stale timestamp ts=\(ts) (not in the future)")
continue
}
let interval = date.timeIntervalSinceNow
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: interval,
repeats: false
)
let content = UNMutableNotificationContent()
content.title = "Reminder"
content.body = "You have a scheduled notification"
let id = "\(predictiveNotificationPrefix)\(Int(ts))"
NSLog("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)")
print("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)")
let request = UNNotificationRequest(
identifier: id,
content: content,
trigger: trigger
)
notificationCenter.add(request) { error in
if let error = error {
NSLog("DNP-BATCH: add failed id=\(id) error=\(error.localizedDescription)")
print("DNP-BATCH: add failed id=\(id) error=\(error.localizedDescription)")
}
}
}
call.resolve()
}
// 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 epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest(
identifier: requestId,
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,
predictiveEpochMillis: epochMillis
)
// 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 (ID from stored epoch millis)
let reminders = getRemindersFromUserDefaults()
if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }),
let epochMillis = predictiveEpochMillis(from: stored) {
let requestId = predictiveNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId])
}
// 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(self.predictiveNotificationPrefix) }
// Get stored reminder data from UserDefaults
let reminders = self.getRemindersFromUserDefaults()
var result: [[String: Any]] = []
for reminder in reminders {
let expectedId: String? = self.predictiveEpochMillis(from: reminder).map {
self.predictiveNotificationId(epochMillis: $0)
}
let isScheduled = reminderRequests.contains { expectedId != nil && $0.identifier == expectedId }
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 (before UserDefaults update)
let remindersBeforeUpdate = getRemindersFromUserDefaults()
if let stored = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }),
let epochMillis = predictiveEpochMillis(from: stored) {
let oldRequestId = predictiveNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId])
}
// 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 epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest(
identifier: requestId,
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 {
self.updateReminderInUserDefaults(
id: reminderId,
title: nil,
body: nil,
time: nil,
sound: nil,
vibration: nil,
priority: nil,
repeatDaily: nil,
timezone: nil,
predictiveEpochMillis: epochMillis
)
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?,
predictiveEpochMillis: Int64
) {
let reminderData: [String: Any] = [
"id": id,
"title": title,
"body": body,
"time": time,
"sound": sound,
"vibration": vibration,
"priority": priority,
"repeatDaily": repeatDaily,
"timezone": timezone ?? "",
"predictiveEpochMillis": predictiveEpochMillis,
"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?,
predictiveEpochMillis: Int64? = nil
) {
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 }
if let predictiveEpochMillis = predictiveEpochMillis {
reminders[i]["predictiveEpochMillis"] = predictiveEpochMillis
}
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)
let fireDate = Date().addingTimeInterval(TimeInterval(validSeconds))
let epochMillis = Int64(fireDate.timeIntervalSince1970 * 1000)
let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest(
identifier: requestId,
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 diagnostics)
*
* Exposes UNUserNotificationCenter pending requests with bridge-safe fields only.
*
* @param call Plugin call
*/
@objc func getPendingNotifications(_ call: CAPPluginCall) {
diagnosticLog("getPendingNotifications called")
notificationCenter.getPendingNotificationRequests { requests in
var notifications: [[String: Any]] = []
for request in requests {
let content = request.content
let (triggerTimestamp, triggerDateIso) = Self.pendingTriggerFields(from: request.trigger)
var item: [String: Any] = [
"identifier": request.identifier,
"title": content.title,
"body": content.body,
"triggerType": Self.pendingTriggerTypeString(from: request.trigger),
"repeats": request.trigger?.repeats ?? false
]
if let triggerTimestamp = triggerTimestamp {
item["triggerTimestamp"] = triggerTimestamp
item["triggerDate"] = triggerTimestamp
} else {
item["triggerTimestamp"] = NSNull()
item["triggerDate"] = NSNull()
}
item["triggerDateIso"] = triggerDateIso ?? NSNull()
notifications.append(item)
}
self.diagnosticLog("getPendingNotifications resolved count=\(notifications.count)")
call.resolve([
"count": notifications.count,
"notifications": notifications
])
}
}
/**
* Get delivered notification metadata (iOS diagnostics)
*
* Lightweight delivery snapshot; omits userInfo and attachment payloads.
*
* @param call Plugin call
*/
@objc func getDeliveredNotifications(_ call: CAPPluginCall) {
diagnosticLog("getDeliveredNotifications called")
notificationCenter.getDeliveredNotifications { delivered in
var notifications: [[String: Any]] = []
for notification in delivered {
let request = notification.request
let content = request.content
let deliveredTimestamp = Int64(notification.date.timeIntervalSince1970 * 1000)
notifications.append([
"identifier": request.identifier,
"deliveredTimestamp": deliveredTimestamp,
"deliveredDateIso": Self.iso8601String(from: notification.date) ?? NSNull(),
"title": content.title,
"body": content.body,
"categoryIdentifier": content.categoryIdentifier.isEmpty ? NSNull() : content.categoryIdentifier
])
}
self.diagnosticLog("getDeliveredNotifications resolved count=\(notifications.count)")
call.resolve([
"count": notifications.count,
"notifications": notifications
])
}
}
/**
* Get notification authorization and presentation settings (iOS diagnostics)
*
* Always resolves with serializable settings; does not require plugin scheduler init.
*
* @param call Plugin call
*/
@objc func getNotificationSettings(_ call: CAPPluginCall) {
diagnosticLog("getNotificationSettings called")
notificationCenter.getNotificationSettings { settings in
let result = Self.serializeNotificationSettings(settings)
self.diagnosticLog("getNotificationSettings resolved authorization=\(result["authorizationStatus"] ?? "unknown")")
call.resolve(result)
}
}
/**
* 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: - Diagnostics helpers
extension DailyNotificationPlugin {
private func diagnosticLog(_ message: String) {
NSLog("[DailyNotificationPlugin] %@", message)
}
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
static func iso8601String(from date: Date) -> String? {
iso8601Formatter.string(from: date)
}
static func pendingTriggerFields(from trigger: UNNotificationTrigger?) -> (Int64?, String?) {
guard let trigger = trigger else { return (nil, nil) }
var nextDate: Date?
if let calendarTrigger = trigger as? UNCalendarNotificationTrigger {
nextDate = calendarTrigger.nextTriggerDate()
} else if let timeIntervalTrigger = trigger as? UNTimeIntervalNotificationTrigger {
nextDate = timeIntervalTrigger.nextTriggerDate()
}
guard let date = nextDate else { return (nil, nil) }
let timestamp = Int64(date.timeIntervalSince1970 * 1000)
return (timestamp, iso8601String(from: date))
}
static func pendingTriggerTypeString(from trigger: UNNotificationTrigger?) -> String {
guard let trigger = trigger else { return "unknown" }
if trigger is UNCalendarNotificationTrigger { return "calendar" }
if trigger is UNTimeIntervalNotificationTrigger { return "timeInterval" }
if trigger is UNLocationNotificationTrigger { return "location" }
return "unknown"
}
static func authorizationStatusString(_ status: UNAuthorizationStatus) -> String {
if #available(iOS 14.0, *), status == .ephemeral {
return "ephemeral"
}
switch status {
case .notDetermined: return "notDetermined"
case .denied: return "denied"
case .authorized: return "authorized"
case .provisional: return "provisional"
@unknown default: return "unknown"
}
}
static func notificationSettingString(_ setting: UNNotificationSetting) -> String {
switch setting {
case .notSupported: return "notSupported"
case .disabled: return "disabled"
case .enabled: return "enabled"
@unknown default: return "unknown"
}
}
static func showPreviewsSettingString(_ setting: UNShowPreviewsSetting) -> String {
switch setting {
case .always: return "always"
case .whenAuthenticated: return "whenAuthenticated"
case .never: return "never"
@unknown default: return "unknown"
}
}
static func serializeNotificationSettings(_ settings: UNNotificationSettings) -> [String: Any] {
var result: [String: Any] = [
"authorizationStatus": authorizationStatusString(settings.authorizationStatus),
"notificationCenterSetting": notificationSettingString(settings.notificationCenterSetting),
"lockScreenSetting": notificationSettingString(settings.lockScreenSetting),
"carPlaySetting": notificationSettingString(settings.carPlaySetting),
"alertSetting": notificationSettingString(settings.alertSetting),
"badgeSetting": notificationSettingString(settings.badgeSetting),
"soundSetting": notificationSettingString(settings.soundSetting),
"criticalAlertSetting": notificationSettingString(settings.criticalAlertSetting),
"showPreviewsSetting": showPreviewsSettingString(settings.showPreviewsSetting),
]
if #available(iOS 13.0, *) {
result["announcementSetting"] = notificationSettingString(settings.announcementSetting)
}
if #available(iOS 15.0, *) {
result["scheduledDeliverySetting"] = notificationSettingString(settings.scheduledDeliverySetting)
result["timeSensitiveSetting"] = notificationSettingString(settings.timeSensitiveSetting)
}
return result
}
}
// 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: "getDeliveredNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationSettings", 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))
// Predictive batch API (replace-all UNUserNotificationCenter)
methods.append(CAPPluginMethod(name: "clearAllNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleNotifications", 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))
methods.append(CAPPluginMethod(name: "updateDualScheduleConfig", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateStarredPlans", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getStarredPlans", returnType: CAPPluginReturnPromise))
return methods
}
}
extension DailyNotificationPlugin {
/// Register the host apps `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
NativeNotificationFetcherRegistry.shared.set(fetcher)
}
}