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
2818 lines
121 KiB
Swift
2818 lines
121 KiB
Swift
//
|
||
// DailyNotificationPlugin.swift
|
||
// DailyNotificationPlugin
|
||
//
|
||
// Created by Matthew Raymer on 2025-09-22
|
||
// Copyright © 2025 TimeSafari. All rights reserved.
|
||
//
|
||
|
||
import Foundation
|
||
import UIKit
|
||
import Capacitor
|
||
import UserNotifications
|
||
import BackgroundTasks
|
||
import CoreData
|
||
import ObjectiveC
|
||
|
||
/**
|
||
* iOS implementation of Daily Notification Plugin
|
||
* Implements BGTaskScheduler + UNUserNotificationCenter for dual scheduling
|
||
*
|
||
* @author Matthew Raymer
|
||
* @version 1.1.0
|
||
* @created 2025-09-22 09:22:32 UTC
|
||
*/
|
||
@objc(DailyNotificationPlugin)
|
||
public class DailyNotificationPlugin: CAPPlugin {
|
||
|
||
let notificationCenter = UNUserNotificationCenter.current()
|
||
private let backgroundTaskScheduler = BGTaskScheduler.shared
|
||
// Note: PersistenceController available for Phase 2+ CoreData integration if needed
|
||
|
||
// Background task identifiers
|
||
private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch"
|
||
private let notifyTaskIdentifier = "org.timesafari.dailynotification.notify"
|
||
/// 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 app’s `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
|
||
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
|
||
NativeNotificationFetcherRegistry.shared.set(fetcher)
|
||
}
|
||
} |