Add optional rolloverIntervalMinutes to scheduleDailyNotification so the next occurrence can be scheduled N minutes after the current trigger (e.g. 10 minutes) instead of 24 hours. Value is persisted and used on rollover and after reboot. - TypeScript: NotificationOptions.rolloverIntervalMinutes?: number - Android: Schedule.rolloverIntervalMinutes in Room (migration 2→3); Plugin and ScheduleHelper persist it; Worker uses it in rollover and updates nextRunAt; ReactivationManager uses it in boot recovery - iOS: NotificationContent.rolloverIntervalMinutes (Codable); Plugin passes it into content; Scheduler uses it in calculateNextScheduledTime and copies to nextContent on rollover When absent or ≤0, behavior unchanged (24h). App can clear by calling scheduleDailyNotification without the parameter.
2203 lines
92 KiB
Swift
2203 lines
92 KiB
Swift
//
|
|
// DailyNotificationPlugin.swift
|
|
// DailyNotificationPlugin
|
|
//
|
|
// Created by Matthew Raymer on 2025-09-22
|
|
// Copyright © 2025 TimeSafari. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import Capacitor
|
|
import UserNotifications
|
|
import BackgroundTasks
|
|
import CoreData
|
|
import ObjectiveC
|
|
|
|
/**
|
|
* iOS implementation of Daily Notification Plugin
|
|
* Implements BGTaskScheduler + UNUserNotificationCenter for dual scheduling
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
* @created 2025-09-22 09:22:32 UTC
|
|
*/
|
|
@objc(DailyNotificationPlugin)
|
|
public class DailyNotificationPlugin: CAPPlugin {
|
|
|
|
let notificationCenter = UNUserNotificationCenter.current()
|
|
private let backgroundTaskScheduler = BGTaskScheduler.shared
|
|
// Note: PersistenceController available for Phase 2+ CoreData integration if needed
|
|
|
|
// Background task identifiers
|
|
private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
|
|
private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify"
|
|
|
|
// Phase 1: Storage and Scheduler components
|
|
var storage: DailyNotificationStorage?
|
|
var scheduler: DailyNotificationScheduler?
|
|
|
|
// Phase 1: Reactivation manager for recovery
|
|
var reactivationManager: DailyNotificationReactivationManager?
|
|
|
|
// Phase 1: Concurrency actor for thread-safe state access
|
|
@available(iOS 13.0, *)
|
|
var stateActor: DailyNotificationStateActor?
|
|
|
|
override public func load() {
|
|
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() called - Capacitor discovered the plugin!")
|
|
super.load()
|
|
setupBackgroundTasks()
|
|
|
|
// Initialize Phase 1 components
|
|
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
let defaultPath = documentsPath.appendingPathComponent("daily_notifications.db").path
|
|
let database = DailyNotificationDatabase(path: defaultPath)
|
|
storage = DailyNotificationStorage(databasePath: database.getPath())
|
|
scheduler = DailyNotificationScheduler()
|
|
|
|
// Initialize reactivation manager for recovery
|
|
reactivationManager = DailyNotificationReactivationManager(
|
|
database: database,
|
|
storage: storage!,
|
|
scheduler: scheduler!
|
|
)
|
|
|
|
// Initialize state actor for thread-safe access
|
|
if #available(iOS 13.0, *) {
|
|
stateActor = DailyNotificationStateActor(
|
|
database: database,
|
|
storage: storage!
|
|
)
|
|
}
|
|
|
|
// Perform recovery on app launch (async, non-blocking)
|
|
reactivationManager?.performRecovery()
|
|
|
|
// Register for notification delivery events (Notification Center pattern)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleNotificationDelivery(_:)),
|
|
name: NSNotification.Name("DailyNotificationDelivered"),
|
|
object: nil
|
|
)
|
|
|
|
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
|
|
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
|
|
|
|
// Register for app becoming active to check for missed rollovers
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleAppBecameActive(_:)),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
|
|
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
|
|
print("DNP-ROLLOVER: Observer registered for app becoming active")
|
|
|
|
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
|
|
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
|
|
|
|
// Debug: Log all available @objc methods for Capacitor discovery
|
|
let methods = getObjCMethods()
|
|
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
|
|
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
|
|
}
|
|
|
|
/**
|
|
* Debug helper: Get all @objc methods for this class
|
|
*/
|
|
private func getObjCMethods() -> [String] {
|
|
var methods: [String] = []
|
|
var methodCount: UInt32 = 0
|
|
let methodList = class_copyMethodList(type(of: self), &methodCount)
|
|
|
|
for i in 0..<Int(methodCount) {
|
|
if let method = methodList?[i] {
|
|
let selector = method_getName(method)
|
|
let methodName = NSStringFromSelector(selector)
|
|
// Filter for methods that look like plugin methods (take CAPPluginCall)
|
|
if methodName.contains(":") && !methodName.hasPrefix("_") {
|
|
methods.append(methodName)
|
|
}
|
|
}
|
|
}
|
|
|
|
free(methodList)
|
|
return methods.sorted()
|
|
}
|
|
|
|
// MARK: - Configuration Methods
|
|
|
|
/**
|
|
* Configure the plugin with database and storage options
|
|
*
|
|
* Matches Android configure() functionality:
|
|
* - dbPath: Custom database path (optional)
|
|
* - storage: "shared" or "tiered" (default: "tiered")
|
|
* - ttlSeconds: Time-to-live for cached content (optional)
|
|
* - prefetchLeadMinutes: Minutes before notification to prefetch (optional)
|
|
* - maxNotificationsPerDay: Maximum notifications per day (optional)
|
|
* - retentionDays: Days to retain notification history (optional)
|
|
* - activeDidIntegration: Phase 1 activeDid configuration (optional)
|
|
*
|
|
* @param call Plugin call containing configuration parameters
|
|
*/
|
|
@objc func configure(_ call: CAPPluginCall) {
|
|
// Validate and extract configuration parameters
|
|
let dbPath = call.getString("dbPath")
|
|
let storageMode = call.getString("storage") ?? "tiered"
|
|
let ttlSeconds = call.getInt("ttlSeconds")
|
|
let prefetchLeadMinutes = call.getInt("prefetchLeadMinutes")
|
|
let maxNotificationsPerDay = call.getInt("maxNotificationsPerDay")
|
|
let retentionDays = call.getInt("retentionDays")
|
|
|
|
// Phase 3: Process activeDidIntegration configuration
|
|
if let activeDidConfig = call.getObject("activeDidIntegration") {
|
|
// Extract and store activeDidIntegration configuration
|
|
// This enables TimeSafari-specific DID-based authentication and API integration
|
|
if let platform = activeDidConfig["platform"] as? String {
|
|
UserDefaults.standard.set(platform, forKey: "activeDidIntegration_platform")
|
|
}
|
|
if let storageType = activeDidConfig["storageType"] as? String {
|
|
UserDefaults.standard.set(storageType, forKey: "activeDidIntegration_storageType")
|
|
}
|
|
if let jwtExpirationSeconds = activeDidConfig["jwtExpirationSeconds"] as? Int {
|
|
UserDefaults.standard.set(jwtExpirationSeconds, forKey: "activeDidIntegration_jwtExpirationSeconds")
|
|
}
|
|
if let apiServer = activeDidConfig["apiServer"] as? String {
|
|
UserDefaults.standard.set(apiServer, forKey: "activeDidIntegration_apiServer")
|
|
}
|
|
if let activeDid = activeDidConfig["activeDid"] as? String {
|
|
UserDefaults.standard.set(activeDid, forKey: "activeDidIntegration_activeDid")
|
|
}
|
|
if let autoSync = activeDidConfig["autoSync"] as? Bool {
|
|
UserDefaults.standard.set(autoSync, forKey: "activeDidIntegration_autoSync")
|
|
}
|
|
if let identityChangeGraceSeconds = activeDidConfig["identityChangeGraceSeconds"] as? Int {
|
|
UserDefaults.standard.set(identityChangeGraceSeconds, forKey: "activeDidIntegration_identityChangeGraceSeconds")
|
|
}
|
|
print("DNP-PLUGIN: activeDidIntegration configuration stored")
|
|
}
|
|
|
|
// Determine database path (use provided or default)
|
|
let finalDbPath: String
|
|
if let dbPath = dbPath, !dbPath.isEmpty {
|
|
finalDbPath = dbPath
|
|
} else {
|
|
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
finalDbPath = documentsPath.appendingPathComponent("daily_notifications.db").path
|
|
}
|
|
|
|
// Reinitialize storage with new database path if needed
|
|
if let currentStorage = storage {
|
|
if currentStorage.getDatabasePath() != finalDbPath {
|
|
storage = DailyNotificationStorage(databasePath: finalDbPath)
|
|
}
|
|
} else {
|
|
storage = DailyNotificationStorage(databasePath: finalDbPath)
|
|
}
|
|
|
|
// Delegate to storage to store configuration
|
|
storeConfiguration(
|
|
ttlSeconds: ttlSeconds,
|
|
prefetchLeadMinutes: prefetchLeadMinutes,
|
|
maxNotificationsPerDay: maxNotificationsPerDay,
|
|
retentionDays: retentionDays,
|
|
storageMode: storageMode,
|
|
dbPath: finalDbPath
|
|
)
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
/**
|
|
* Configure native fetcher with API credentials (cross-platform)
|
|
*
|
|
* Matches Android configureNativeFetcher() functionality:
|
|
* - Stores configuration in database for persistence
|
|
* - Supports both jwtToken and jwtSecret for backward compatibility
|
|
* - Note: iOS native fetcher interface not yet implemented, but configuration is stored
|
|
*
|
|
* @param call Plugin call containing configuration parameters
|
|
*/
|
|
@objc func configureNativeFetcher(_ call: CAPPluginCall) {
|
|
guard let options = call.options else {
|
|
call.reject("Options are required")
|
|
return
|
|
}
|
|
|
|
guard let apiBaseUrl = options["apiBaseUrl"] as? String else {
|
|
call.reject("apiBaseUrl is required")
|
|
return
|
|
}
|
|
|
|
guard let activeDid = options["activeDid"] as? String else {
|
|
call.reject("activeDid is required")
|
|
return
|
|
}
|
|
|
|
// Support both jwtToken and jwtSecret for backward compatibility
|
|
let jwtToken = (options["jwtToken"] as? String) ?? (options["jwtSecret"] as? String)
|
|
guard let jwtToken = jwtToken else {
|
|
call.reject("jwtToken or jwtSecret is required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...")
|
|
|
|
// Store configuration in database for persistence across app restarts
|
|
// Note: iOS native fetcher interface not yet implemented, but we store config for future use
|
|
let configId = "native_fetcher_config"
|
|
let configValue: [String: Any] = [
|
|
"apiBaseUrl": apiBaseUrl,
|
|
"activeDid": activeDid,
|
|
"jwtToken": jwtToken
|
|
]
|
|
|
|
// Convert to JSON string for storage
|
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
|
|
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
call.reject("Failed to serialize configuration")
|
|
return
|
|
}
|
|
|
|
// Store configuration in UserDefaults for now
|
|
// This matches Android's approach of storing in database, but uses UserDefaults for simplicity
|
|
// Can be enhanced later to use CoreData when native fetcher interface is implemented
|
|
let configKey = "native_fetcher_config"
|
|
UserDefaults.standard.set(jsonString, forKey: configKey)
|
|
print("DNP-PLUGIN: Native fetcher configuration stored successfully")
|
|
call.resolve()
|
|
}
|
|
|
|
/**
|
|
* Store configuration values
|
|
*
|
|
* @param ttlSeconds TTL in seconds
|
|
* @param prefetchLeadMinutes Prefetch lead time in minutes
|
|
* @param maxNotificationsPerDay Maximum notifications per day
|
|
* @param retentionDays Retention period in days
|
|
* @param storageMode Storage mode ("shared" or "tiered")
|
|
* @param dbPath Database path
|
|
*/
|
|
private func storeConfiguration(
|
|
ttlSeconds: Int?,
|
|
prefetchLeadMinutes: Int?,
|
|
maxNotificationsPerDay: Int?,
|
|
retentionDays: Int?,
|
|
storageMode: String,
|
|
dbPath: String
|
|
) {
|
|
var config: [String: Any] = [
|
|
"storageMode": storageMode,
|
|
"dbPath": dbPath
|
|
]
|
|
|
|
if let ttlSeconds = ttlSeconds {
|
|
config["ttlSeconds"] = ttlSeconds
|
|
}
|
|
|
|
if let prefetchLeadMinutes = prefetchLeadMinutes {
|
|
config["prefetchLeadMinutes"] = prefetchLeadMinutes
|
|
}
|
|
|
|
if let maxNotificationsPerDay = maxNotificationsPerDay {
|
|
config["maxNotificationsPerDay"] = maxNotificationsPerDay
|
|
}
|
|
|
|
if let retentionDays = retentionDays {
|
|
config["retentionDays"] = retentionDays
|
|
}
|
|
|
|
storage?.saveSettings(config)
|
|
print("DNP-PLUGIN: Configuration stored successfully")
|
|
}
|
|
|
|
// MARK: - Dual Scheduling Methods
|
|
|
|
@objc func scheduleContentFetch(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config") else {
|
|
call.reject("Content fetch config required")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Delegate to background fetch scheduler
|
|
try scheduleBackgroundFetch(config: config)
|
|
call.resolve()
|
|
} catch {
|
|
call.reject("Content fetch scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func scheduleUserNotification(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config") else {
|
|
call.reject("User notification config required")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Delegate to user notification scheduler
|
|
try scheduleUserNotification(config: config)
|
|
call.resolve()
|
|
} catch {
|
|
call.reject("User notification scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func scheduleDualNotification(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config"),
|
|
let contentFetchConfig = config["contentFetch"] as? [String: Any],
|
|
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
|
|
call.reject("Dual notification config required")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Delegate to ScheduleHelper for dual scheduling orchestration
|
|
try DailyNotificationScheduleHelper.scheduleDualNotification(
|
|
contentFetchConfig: contentFetchConfig,
|
|
userNotificationConfig: userNotificationConfig,
|
|
scheduleBackgroundFetch: { [weak self] config in
|
|
guard let strongSelf = self else {
|
|
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
|
}
|
|
try strongSelf.scheduleBackgroundFetch(config: config)
|
|
},
|
|
scheduleUserNotification: { [weak self] config in
|
|
guard let strongSelf = self else {
|
|
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
|
}
|
|
try strongSelf.scheduleUserNotification(config: config)
|
|
}
|
|
)
|
|
call.resolve()
|
|
} catch {
|
|
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func getDualScheduleStatus(_ call: CAPPluginCall) {
|
|
Task {
|
|
do {
|
|
// Delegate to private helper (will be moved to service in future batch)
|
|
let status = try await getHealthStatus()
|
|
DispatchQueue.main.async {
|
|
call.resolve(status)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
print("DNP-PLUGIN: Failed to get dual schedule status: \(error)")
|
|
call.reject("Status retrieval failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get health status for dual scheduling system
|
|
*
|
|
* @return Health status dictionary
|
|
*/
|
|
private func getHealthStatus() async throws -> [String: Any] {
|
|
guard let scheduler = scheduler else {
|
|
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
|
|
}
|
|
|
|
// Delegate to ScheduleHelper for health status (combines multiple sources)
|
|
return try await DailyNotificationScheduleHelper.getHealthStatus(
|
|
scheduler: scheduler,
|
|
storage: self.storage,
|
|
stateActor: await self.stateActor
|
|
)
|
|
}
|
|
|
|
// MARK: - Private Implementation Methods
|
|
|
|
private func setupBackgroundTasks() {
|
|
// Register background fetch task
|
|
backgroundTaskScheduler.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in
|
|
self.handleBackgroundFetch(task: task as! BGAppRefreshTask)
|
|
}
|
|
|
|
// Register background processing task
|
|
backgroundTaskScheduler.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in
|
|
self.handleBackgroundNotify(task: task as! BGProcessingTask)
|
|
}
|
|
|
|
// Phase 1: Check for missed BGTask on app launch
|
|
checkForMissedBGTask()
|
|
}
|
|
|
|
/**
|
|
* Handle background fetch task
|
|
*
|
|
* Phase 1: Dummy fetcher - returns static content
|
|
* Phase 3: Will be replaced with JWT-signed fetcher
|
|
*
|
|
* Enhanced with:
|
|
* - Recovery logic (verify scheduled notifications)
|
|
* - Next task scheduling
|
|
* - Graceful expiration handling
|
|
*
|
|
* @param task BGAppRefreshTask
|
|
*/
|
|
private func handleBackgroundFetch(task: BGAppRefreshTask) {
|
|
print("DNP-FETCH: Background fetch task started")
|
|
|
|
// Enhanced expiration handler with graceful cleanup
|
|
var taskCompleted = false
|
|
task.expirationHandler = {
|
|
guard !taskCompleted else { return }
|
|
print("DNP-FETCH: Background fetch task expired - performing graceful cleanup")
|
|
|
|
// Cancel any ongoing operations
|
|
// Note: In production, you might want to cancel URLSession tasks here
|
|
|
|
task.setTaskCompleted(success: false)
|
|
taskCompleted = true
|
|
}
|
|
|
|
// Phase 3: Check for JWT-signed fetcher configuration
|
|
// If native fetcher is configured, use it; otherwise fall back to dummy content
|
|
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
|
|
|
|
// Save content to storage via state actor (thread-safe)
|
|
Task {
|
|
let content: NotificationContent
|
|
|
|
if let configJson = nativeFetcherConfig,
|
|
let configData = configJson.data(using: .utf8),
|
|
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
|
let apiBaseUrl = config["apiBaseUrl"] as? String,
|
|
let activeDid = config["activeDid"] as? String,
|
|
let jwtToken = config["jwtToken"] as? String {
|
|
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
|
|
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
|
|
|
|
// Attempt to fetch content from TimeSafari API
|
|
// Note: This is a minimal implementation - can be extended with full API client
|
|
do {
|
|
let fetchedContent = try await fetchContentFromAPI(
|
|
apiBaseUrl: apiBaseUrl,
|
|
activeDid: activeDid,
|
|
jwtToken: jwtToken
|
|
)
|
|
content = fetchedContent
|
|
print("DNP-FETCH: Successfully fetched content from API")
|
|
} catch {
|
|
// Fallback to dummy content on fetch failure
|
|
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
|
|
content = NotificationContent(
|
|
id: "fallback_\(Date().timeIntervalSince1970)",
|
|
title: "Daily Update",
|
|
body: "Your daily notification is ready",
|
|
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
|
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
|
url: nil,
|
|
payload: ["fetchError": error.localizedDescription],
|
|
etag: nil
|
|
)
|
|
}
|
|
} else {
|
|
// Fallback: Dummy content fetch (no network)
|
|
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
|
|
content = NotificationContent(
|
|
id: "dummy_\(Date().timeIntervalSince1970)",
|
|
title: "Daily Update",
|
|
body: "Your daily notification is ready",
|
|
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
|
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
|
url: nil,
|
|
payload: nil,
|
|
etag: nil
|
|
)
|
|
}
|
|
do {
|
|
// Use the content (either from JWT fetcher or dummy)
|
|
if #available(iOS 13.0, *) {
|
|
if let stateActor = await self.stateActor {
|
|
await stateActor.saveNotificationContent(content)
|
|
|
|
// Mark successful run
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
|
|
} else {
|
|
// Fallback to direct storage access
|
|
self.storage?.saveNotificationContent(content)
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
|
|
}
|
|
} else {
|
|
// Fallback for iOS < 13
|
|
self.storage?.saveNotificationContent(content)
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
|
|
}
|
|
|
|
// Phase 3.3: Recovery logic - verify scheduled notifications
|
|
// Check if notifications are still scheduled after fetch
|
|
if let reactivationManager = self.reactivationManager {
|
|
// Perform lightweight verification (non-blocking)
|
|
Task {
|
|
do {
|
|
let verificationResult = try await reactivationManager.verifyFutureNotifications()
|
|
if verificationResult.notificationsMissing > 0 {
|
|
print("DNP-FETCH: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
|
|
// Note: Full recovery happens on app launch, not in background task
|
|
}
|
|
} catch {
|
|
// Non-fatal: Log but don't fail task
|
|
print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3.3: Schedule next background task
|
|
// Calculate next fetch time based on notification schedule
|
|
if let scheduler = self.scheduler {
|
|
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
|
if let nextTime = nextScheduledTime {
|
|
self.scheduleBackgroundFetch(scheduledTime: nextTime)
|
|
print("DNP-FETCH: Next background fetch scheduled")
|
|
} else {
|
|
print("DNP-FETCH: No future notifications found, skipping next task schedule")
|
|
}
|
|
} else {
|
|
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
|
|
}
|
|
|
|
guard !taskCompleted else { return }
|
|
task.setTaskCompleted(success: true)
|
|
taskCompleted = true
|
|
print("DNP-FETCH: Background fetch task completed successfully")
|
|
|
|
} catch {
|
|
print("DNP-FETCH: Background fetch task failed: \(error.localizedDescription)")
|
|
guard !taskCompleted else { return }
|
|
task.setTaskCompleted(success: false)
|
|
taskCompleted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle background notification task
|
|
*
|
|
* Enhanced with:
|
|
* - Recovery logic (verify scheduled notifications)
|
|
* - Next task scheduling
|
|
* - Graceful expiration handling
|
|
*
|
|
* @param task BGProcessingTask
|
|
*/
|
|
private func handleBackgroundNotify(task: BGProcessingTask) {
|
|
print("DNP-NOTIFY: Background notify task started")
|
|
|
|
// Enhanced expiration handler with graceful cleanup
|
|
var taskCompleted = false
|
|
task.expirationHandler = {
|
|
guard !taskCompleted else { return }
|
|
print("DNP-NOTIFY: Background notify task expired - performing graceful cleanup")
|
|
task.setTaskCompleted(success: false)
|
|
taskCompleted = true
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Phase 3.3: Recovery logic - verify scheduled notifications
|
|
// Check if notifications are still scheduled
|
|
if let reactivationManager = self.reactivationManager {
|
|
// Perform lightweight verification (non-blocking)
|
|
let verificationResult = try await reactivationManager.verifyFutureNotifications()
|
|
if verificationResult.notificationsMissing > 0 {
|
|
print("DNP-NOTIFY: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
|
|
// Note: Full recovery happens on app launch, not in background task
|
|
}
|
|
}
|
|
|
|
// Phase 1: Not used for single daily schedule
|
|
// This will be used in Phase 2+ for rolling window maintenance
|
|
// For now, just verify state
|
|
|
|
// Phase 3.3: Schedule next background task if needed
|
|
// For notify task, schedule next occurrence if applicable
|
|
if let scheduler = self.scheduler {
|
|
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
|
if let nextTime = nextScheduledTime {
|
|
// Calculate next notify task time (if applicable)
|
|
// Note: Notify tasks are typically scheduled less frequently than fetch tasks
|
|
print("DNP-NOTIFY: Next notification scheduled at \(nextTime)")
|
|
}
|
|
}
|
|
|
|
guard !taskCompleted else { return }
|
|
task.setTaskCompleted(success: true)
|
|
taskCompleted = true
|
|
print("DNP-NOTIFY: Background notify task completed successfully")
|
|
|
|
} catch {
|
|
print("DNP-NOTIFY: Background notify task failed: \(error.localizedDescription)")
|
|
guard !taskCompleted else { return }
|
|
task.setTaskCompleted(success: false)
|
|
taskCompleted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for missed BGTask and reschedule if needed
|
|
*
|
|
* Phase 1: BGTask Miss Detection
|
|
* - Checks if BGTask was scheduled but not run within 15 min window
|
|
* - Reschedules if missed
|
|
*/
|
|
private func checkForMissedBGTask() {
|
|
Task {
|
|
var earliestBeginTimestamp: Int64?
|
|
var lastSuccessfulRun: Int64?
|
|
|
|
// Get BGTask tracking info via state actor (thread-safe)
|
|
if #available(iOS 13.0, *) {
|
|
if let stateActor = await self.stateActor {
|
|
earliestBeginTimestamp = await stateActor.getBGTaskEarliestBegin()
|
|
lastSuccessfulRun = await stateActor.getLastSuccessfulRun()
|
|
} else {
|
|
// Fallback to direct storage access
|
|
earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin()
|
|
lastSuccessfulRun = self.storage?.getLastSuccessfulRun()
|
|
}
|
|
} else {
|
|
// Fallback for iOS < 13
|
|
earliestBeginTimestamp = self.storage?.getBGTaskEarliestBegin()
|
|
lastSuccessfulRun = self.storage?.getLastSuccessfulRun()
|
|
}
|
|
|
|
guard let earliestBeginTime = earliestBeginTimestamp else {
|
|
// No BGTask scheduled
|
|
return
|
|
}
|
|
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let missWindow = Int64(15 * 60 * 1000) // 15 minutes in milliseconds
|
|
|
|
// Check if task was missed (current time > earliestBeginTime + 15 min)
|
|
if currentTime > earliestBeginTime + missWindow {
|
|
// Check if there was a successful run
|
|
if let lastRun = lastSuccessfulRun {
|
|
// If last successful run was after earliestBeginTime, task was not missed
|
|
if lastRun >= earliestBeginTime {
|
|
print("DNP-FETCH: BGTask completed successfully, no reschedule needed")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Task was missed - reschedule
|
|
print("DNP-FETCH: BGTask missed window; rescheduling")
|
|
|
|
// Reschedule for 1 minute from now
|
|
let rescheduleTime = currentTime + (1 * 60 * 1000) // 1 minute from now
|
|
let rescheduleDate = Date(timeIntervalSince1970: Double(rescheduleTime) / 1000.0)
|
|
|
|
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
|
|
request.earliestBeginDate = rescheduleDate
|
|
|
|
do {
|
|
try backgroundTaskScheduler.submit(request)
|
|
|
|
// Save rescheduled time via state actor (thread-safe)
|
|
if #available(iOS 13.0, *) {
|
|
if let stateActor = await self.stateActor {
|
|
await stateActor.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
|
|
} else {
|
|
// Fallback to direct storage access
|
|
self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
|
|
}
|
|
} else {
|
|
// Fallback for iOS < 13
|
|
self.storage?.saveBGTaskEarliestBegin(timestamp: rescheduleTime)
|
|
}
|
|
|
|
print("DNP-FETCH: BGTask rescheduled for \(rescheduleDate)")
|
|
} catch {
|
|
print("DNP-FETCH: Failed to reschedule BGTask: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleBackgroundFetch(config: [String: Any]) throws {
|
|
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
|
|
|
|
// Calculate next run time (simplified - would use proper cron parsing in production)
|
|
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime)
|
|
|
|
try backgroundTaskScheduler.submit(request)
|
|
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)")
|
|
}
|
|
|
|
private func scheduleUserNotification(config: [String: Any]) throws {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = config["title"] as? String ?? "Daily Notification"
|
|
content.body = config["body"] as? String ?? "Your daily update is ready"
|
|
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
|
|
|
|
// Create trigger (simplified - would use proper cron parsing in production)
|
|
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
notificationCenter.add(request) { error in
|
|
if let error = error {
|
|
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
|
|
} else {
|
|
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
|
|
// Simplified implementation - would use proper cron parsing in production
|
|
// For now, return next day at 9 AM
|
|
return 86400 // 24 hours
|
|
}
|
|
|
|
// MARK: - Static Daily Reminder Methods
|
|
|
|
@objc func scheduleDailyReminder(_ call: CAPPluginCall) {
|
|
guard let id = call.getString("id"),
|
|
let title = call.getString("title"),
|
|
let body = call.getString("body"),
|
|
let time = call.getString("time") else {
|
|
call.reject("Missing required parameters: id, title, body, time")
|
|
return
|
|
}
|
|
|
|
let sound = call.getBool("sound", true)
|
|
let vibration = call.getBool("vibration", true)
|
|
let priority = call.getString("priority", "normal")
|
|
let repeatDaily = call.getBool("repeatDaily", true)
|
|
let timezone = call.getString("timezone")
|
|
|
|
// Validate and parse time (HH:mm format)
|
|
let timeComponents = time.components(separatedBy: ":")
|
|
guard timeComponents.count == 2,
|
|
let hour = Int(timeComponents[0]),
|
|
let minute = Int(timeComponents[1]),
|
|
hour >= 0 && hour <= 23,
|
|
minute >= 0 && minute <= 59 else {
|
|
call.reject("Invalid time format. Use HH:mm (e.g., 09:00)")
|
|
return
|
|
}
|
|
|
|
// Create notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = sound ? .default : nil
|
|
content.categoryIdentifier = "DAILY_REMINDER"
|
|
|
|
// Set priority
|
|
if #available(iOS 15.0, *) {
|
|
switch priority {
|
|
case "high":
|
|
content.interruptionLevel = .critical
|
|
case "low":
|
|
content.interruptionLevel = .passive
|
|
default:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
|
|
// Create date components for daily trigger
|
|
var dateComponents = DateComponents()
|
|
dateComponents.hour = hour
|
|
dateComponents.minute = minute
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: repeatDaily
|
|
)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "reminder_\(id)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Store reminder in UserDefaults
|
|
storeReminderInUserDefaults(
|
|
id: id,
|
|
title: title,
|
|
body: body,
|
|
time: time,
|
|
sound: sound,
|
|
vibration: vibration,
|
|
priority: priority,
|
|
repeatDaily: repeatDaily,
|
|
timezone: timezone
|
|
)
|
|
|
|
// Delegate to UNUserNotificationCenter to schedule notification
|
|
notificationCenter.add(request) { error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
call.reject("Failed to schedule daily reminder: \(error.localizedDescription)")
|
|
} else {
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func cancelDailyReminder(_ call: CAPPluginCall) {
|
|
guard let reminderId = call.getString("reminderId") else {
|
|
call.reject("Missing reminderId parameter")
|
|
return
|
|
}
|
|
|
|
// Cancel the notification
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
|
|
|
|
// Delegate to storage for reminder removal
|
|
removeReminderFromUserDefaults(id: reminderId)
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func getScheduledReminders(_ call: CAPPluginCall) {
|
|
print("DNP-REMINDER: Getting scheduled reminders")
|
|
|
|
// Get pending notifications
|
|
notificationCenter.getPendingNotificationRequests { requests in
|
|
let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") }
|
|
|
|
// Get stored reminder data from UserDefaults
|
|
let reminders = self.getRemindersFromUserDefaults()
|
|
|
|
var result: [[String: Any]] = []
|
|
for reminder in reminders {
|
|
let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" }
|
|
var reminderInfo = reminder
|
|
reminderInfo["isScheduled"] = isScheduled
|
|
result.append(reminderInfo)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(["reminders": result])
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func updateDailyReminder(_ call: CAPPluginCall) {
|
|
guard let reminderId = call.getString("reminderId") else {
|
|
call.reject("Missing reminderId parameter")
|
|
return
|
|
}
|
|
|
|
// Cancel existing reminder
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
|
|
|
|
// Update in UserDefaults
|
|
let title = call.getString("title")
|
|
let body = call.getString("body")
|
|
let time = call.getString("time")
|
|
let sound = call.getBool("sound")
|
|
let vibration = call.getBool("vibration")
|
|
let priority = call.getString("priority")
|
|
let repeatDaily = call.getBool("repeatDaily")
|
|
let timezone = call.getString("timezone")
|
|
|
|
updateReminderInUserDefaults(
|
|
id: reminderId,
|
|
title: title,
|
|
body: body,
|
|
time: time,
|
|
sound: sound,
|
|
vibration: vibration,
|
|
priority: priority,
|
|
repeatDaily: repeatDaily,
|
|
timezone: timezone
|
|
)
|
|
|
|
// Reschedule with new settings if all required fields are provided
|
|
if let title = title, let body = body, let time = time {
|
|
// Parse time
|
|
let timeComponents = time.components(separatedBy: ":")
|
|
guard timeComponents.count == 2,
|
|
let hour = Int(timeComponents[0]),
|
|
let minute = Int(timeComponents[1]) else {
|
|
call.reject("Invalid time format")
|
|
return
|
|
}
|
|
|
|
// Create new notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = (sound ?? true) ? .default : nil
|
|
content.categoryIdentifier = "DAILY_REMINDER"
|
|
|
|
// Set priority
|
|
let finalPriority = priority ?? "normal"
|
|
if #available(iOS 15.0, *) {
|
|
switch finalPriority {
|
|
case "high":
|
|
content.interruptionLevel = .critical
|
|
case "low":
|
|
content.interruptionLevel = .passive
|
|
default:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
|
|
// Create date components for daily trigger
|
|
var dateComponents = DateComponents()
|
|
dateComponents.hour = hour
|
|
dateComponents.minute = minute
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: repeatDaily ?? true
|
|
)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "reminder_\(reminderId)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule the updated notification
|
|
notificationCenter.add(request) { error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)")
|
|
} else {
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
call.resolve()
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Methods for Reminder Storage
|
|
|
|
private func storeReminderInUserDefaults(
|
|
id: String,
|
|
title: String,
|
|
body: String,
|
|
time: String,
|
|
sound: Bool,
|
|
vibration: Bool,
|
|
priority: String,
|
|
repeatDaily: Bool,
|
|
timezone: String?
|
|
) {
|
|
let reminderData: [String: Any] = [
|
|
"id": id,
|
|
"title": title,
|
|
"body": body,
|
|
"time": time,
|
|
"sound": sound,
|
|
"vibration": vibration,
|
|
"priority": priority,
|
|
"repeatDaily": repeatDaily,
|
|
"timezone": timezone ?? "",
|
|
"createdAt": Date().timeIntervalSince1970,
|
|
"lastTriggered": 0
|
|
]
|
|
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
reminders.append(reminderData)
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
|
|
print("DNP-REMINDER: Reminder stored: \(id)")
|
|
}
|
|
|
|
private func removeReminderFromUserDefaults(id: String) {
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
reminders.removeAll { ($0["id"] as? String) == id }
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
|
|
print("DNP-REMINDER: Reminder removed: \(id)")
|
|
}
|
|
|
|
private func getRemindersFromUserDefaults() -> [[String: Any]] {
|
|
return UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
}
|
|
|
|
private func updateReminderInUserDefaults(
|
|
id: String,
|
|
title: String?,
|
|
body: String?,
|
|
time: String?,
|
|
sound: Bool?,
|
|
vibration: Bool?,
|
|
priority: String?,
|
|
repeatDaily: Bool?,
|
|
timezone: String?
|
|
) {
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
|
|
for i in 0..<reminders.count {
|
|
if reminders[i]["id"] as? String == id {
|
|
if let title = title { reminders[i]["title"] = title }
|
|
if let body = body { reminders[i]["body"] = body }
|
|
if let time = time { reminders[i]["time"] = time }
|
|
if let sound = sound { reminders[i]["sound"] = sound }
|
|
if let vibration = vibration { reminders[i]["vibration"] = vibration }
|
|
if let priority = priority { reminders[i]["priority"] = priority }
|
|
if let repeatDaily = repeatDaily { reminders[i]["repeatDaily"] = repeatDaily }
|
|
if let timezone = timezone { reminders[i]["timezone"] = timezone }
|
|
break
|
|
}
|
|
}
|
|
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
print("DNP-REMINDER: Reminder updated: \(id)")
|
|
}
|
|
|
|
// MARK: - Phase 1: Core Notification Methods
|
|
|
|
/**
|
|
* Schedule a daily notification with the specified options
|
|
*
|
|
* Phase 1: Single daily schedule (one prefetch 5 min before + one notification)
|
|
*
|
|
* @param call Plugin call containing notification parameters
|
|
*/
|
|
@objc func scheduleDailyNotification(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Capacitor passes the options object directly as call data
|
|
// Read parameters directly from call (matching Android implementation)
|
|
guard let time = call.getString("time"), !time.isEmpty else {
|
|
let error = DailyNotificationErrorCodes.missingParameter("time")
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Parse time (HH:mm format)
|
|
let timeComponents = time.components(separatedBy: ":")
|
|
guard timeComponents.count == 2,
|
|
let hour = Int(timeComponents[0]),
|
|
let minute = Int(timeComponents[1]),
|
|
hour >= 0 && hour <= 23,
|
|
minute >= 0 && minute <= 59 else {
|
|
let error = DailyNotificationErrorCodes.invalidTimeFormat()
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Extract other parameters (with defaults matching Android)
|
|
let title = call.getString("title") ?? "Daily Update"
|
|
let body = call.getString("body") ?? "Your daily notification is ready"
|
|
let sound = call.getBool("sound", true)
|
|
let url = call.getString("url")
|
|
let priority = call.getString("priority") ?? "default"
|
|
let rawRollover = call.getInt("rolloverIntervalMinutes") ?? 0
|
|
let rolloverIntervalMinutes = rawRollover > 0 ? rawRollover : nil
|
|
|
|
// Calculate scheduled time (next occurrence at specified hour:minute)
|
|
let scheduledTime = calculateNextScheduledTime(hour: hour, minute: minute)
|
|
let fetchedAt = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds
|
|
|
|
// Create notification content (persist rollover interval for dev/testing; survives app restart)
|
|
let content = NotificationContent(
|
|
id: "daily_\(Date().timeIntervalSince1970)",
|
|
title: title,
|
|
body: body,
|
|
scheduledTime: scheduledTime,
|
|
fetchedAt: fetchedAt,
|
|
url: url,
|
|
payload: nil,
|
|
etag: nil,
|
|
rolloverIntervalMinutes: rolloverIntervalMinutes
|
|
)
|
|
|
|
// Delegate to ScheduleHelper for orchestration
|
|
Task {
|
|
let scheduled = await DailyNotificationScheduleHelper.scheduleDailyNotification(
|
|
content: content,
|
|
scheduledTime: scheduledTime,
|
|
scheduler: scheduler,
|
|
storage: self.storage,
|
|
stateActor: await self.stateActor,
|
|
scheduleBackgroundFetch: { [weak self] (scheduledTime: Int64) -> Void in
|
|
self?.scheduleBackgroundFetch(scheduledTime: scheduledTime)
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
if scheduled {
|
|
call.resolve()
|
|
} else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
|
|
message: "Failed to schedule notification"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test method: Schedule an alarm to fire in a few seconds
|
|
* Useful for verifying alarm delivery works correctly
|
|
*
|
|
* @param call Plugin call with optional secondsFromNow (default: 5)
|
|
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
|
|
*/
|
|
@objc func testAlarm(_ call: CAPPluginCall) {
|
|
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
|
|
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
|
|
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
|
|
|
|
guard let scheduler = scheduler else {
|
|
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
|
|
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Get secondsFromNow parameter (default: 5)
|
|
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
|
|
|
|
// Ensure minimum of 1 second (iOS requirement)
|
|
let validSeconds = max(1, secondsFromNow)
|
|
|
|
Task {
|
|
do {
|
|
// Check permissions first
|
|
let permissionStatus = await notificationCenter.notificationSettings()
|
|
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
|
|
message: "Notification permissions not granted"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Create test notification content
|
|
let notificationContent = UNMutableNotificationContent()
|
|
notificationContent.title = "Test Notification"
|
|
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
|
|
notificationContent.sound = .default
|
|
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
|
|
notificationContent.userInfo = [
|
|
"notification_id": "test_\(Date().timeIntervalSince1970)",
|
|
"is_test": true
|
|
]
|
|
|
|
// Create time interval trigger (fires in X seconds)
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
|
|
|
|
// Create notification request with unique ID
|
|
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
|
|
let request = UNNotificationRequest(
|
|
identifier: notificationId,
|
|
content: notificationContent,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule notification
|
|
try await notificationCenter.add(request)
|
|
|
|
// Calculate trigger time in milliseconds
|
|
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
|
|
|
|
let result: [String: Any] = [
|
|
"scheduled": true,
|
|
"secondsFromNow": validSeconds,
|
|
"triggerAtMillis": triggerAtMillis
|
|
]
|
|
|
|
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
|
|
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
|
|
|
|
DispatchQueue.main.async {
|
|
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
|
|
call.resolve(result)
|
|
}
|
|
|
|
} catch {
|
|
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
|
|
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
|
|
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
|
|
message: "Failed to schedule test alarm: \(error.localizedDescription)"
|
|
)
|
|
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
|
|
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
|
|
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
|
|
DispatchQueue.main.async {
|
|
call.reject(errorMessage, errorCode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debug method: List all available plugin methods
|
|
* Useful for verifying Capacitor method discovery
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func listAvailableMethods(_ call: CAPPluginCall) {
|
|
let methods = getObjCMethods()
|
|
let result: [String: Any] = [
|
|
"methods": methods,
|
|
"count": methods.count,
|
|
"testAlarmFound": methods.contains("testAlarm:")
|
|
]
|
|
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
|
|
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
|
|
call.resolve(result)
|
|
}
|
|
|
|
/**
|
|
* Get the last notification that was delivered
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func getLastNotification(_ call: CAPPluginCall) {
|
|
Task {
|
|
let lastNotification: NotificationContent?
|
|
|
|
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
|
|
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
|
|
lastNotification = await stateActor.getLastNotification()
|
|
} else {
|
|
lastNotification = self.storage?.getLastNotification()
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
if let notification = lastNotification {
|
|
let result: [String: Any] = [
|
|
"id": notification.id,
|
|
"title": notification.title ?? "",
|
|
"body": notification.body ?? "",
|
|
"timestamp": notification.scheduledTime,
|
|
"url": notification.url ?? ""
|
|
]
|
|
call.resolve(result)
|
|
} else {
|
|
call.resolve([:])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel all scheduled notifications
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func cancelAllNotifications(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
// Delegate cancellation to scheduler
|
|
await scheduler.cancelAllNotifications()
|
|
|
|
// Clear storage via stateActor if available (thread-safe), otherwise use storage directly
|
|
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
|
|
await stateActor.clearAllNotifications()
|
|
} else {
|
|
self.storage?.clearAllNotifications()
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
print("DNP-PLUGIN: All notifications cancelled successfully")
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current status of notifications
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func getNotificationStatus(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
// Delegate to scheduler for permission status and pending count
|
|
let isEnabled = await scheduler.checkPermissionStatus() == .authorized
|
|
let pendingCount = await scheduler.getPendingNotificationCount()
|
|
|
|
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
|
|
let lastNotification: NotificationContent?
|
|
let settings: [String: Any]
|
|
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
|
|
lastNotification = await stateActor.getLastNotification()
|
|
settings = await stateActor.getSettings()
|
|
} else {
|
|
lastNotification = self.storage?.getLastNotification()
|
|
settings = self.storage?.getSettings() ?? [:]
|
|
}
|
|
|
|
// Delegate to scheduler for next notification time
|
|
let nextNotificationTime = await scheduler.getNextNotificationTime() ?? 0
|
|
|
|
// Delegate to storage for rollover status
|
|
let lastRolloverTime = storage?.getLastRolloverTime() ?? 0
|
|
|
|
var result: [String: Any] = [
|
|
"isEnabled": isEnabled,
|
|
"isScheduled": pendingCount > 0,
|
|
"lastNotificationTime": lastNotification?.scheduledTime ?? 0,
|
|
"nextNotificationTime": nextNotificationTime,
|
|
"pending": pendingCount,
|
|
"rolloverEnabled": true, // Indicate rollover is active
|
|
"lastRolloverTime": lastRolloverTime, // When last rollover occurred
|
|
"settings": settings
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle notification delivery event (from Notification Center)
|
|
*
|
|
* This is called when AppDelegate posts notification delivery event
|
|
* Matches Android's scheduleNextNotification() behavior
|
|
*
|
|
* @param notification NSNotification with userInfo containing notification_id and scheduled_time
|
|
*/
|
|
@objc private func handleNotificationDelivery(_ notification: Notification) {
|
|
NSLog("DNP-ROLLOVER: handleNotificationDelivery called")
|
|
print("DNP-ROLLOVER: handleNotificationDelivery called")
|
|
|
|
// Extract notification data from userInfo
|
|
guard let userInfo = notification.userInfo,
|
|
let notificationId = userInfo["notification_id"] as? String,
|
|
let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
|
|
NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil")
|
|
print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])")
|
|
return
|
|
}
|
|
|
|
let scheduledTimeStr = formatTime(scheduledTime)
|
|
NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
|
|
print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
|
|
|
// Track notify execution
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
storage?.saveLastNotifyExecution(timestamp: currentTime)
|
|
|
|
// Delegate rollover processing (glue logic - will be moved to service in future)
|
|
Task {
|
|
await processRollover(notificationId: notificationId, scheduledTime: scheduledTime)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle app becoming active (foreground)
|
|
*
|
|
* This is called when the app becomes active to check for missed rollovers
|
|
* that occurred while the app was backgrounded.
|
|
*
|
|
* @param notification NSNotification for app becoming active
|
|
*/
|
|
@objc private func handleAppBecameActive(_ notification: Notification) {
|
|
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
|
|
print("DNP-ROLLOVER: handleAppBecameActive called")
|
|
|
|
// Perform lightweight rollover check when app becomes active
|
|
// This handles cases where notifications fired while app was backgrounded
|
|
reactivationManager?.performActiveRolloverCheck()
|
|
}
|
|
|
|
/**
|
|
* Process rollover for delivered notification
|
|
*
|
|
* @param notificationId ID of notification that was delivered
|
|
* @param scheduledTime Scheduled time of delivered notification
|
|
*/
|
|
private func processRollover(notificationId: String, scheduledTime: Int64) async {
|
|
let scheduledTimeStr = formatTime(scheduledTime)
|
|
NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
|
|
print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
|
|
|
guard let scheduler = scheduler, let storage = storage else {
|
|
NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@",
|
|
scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil")
|
|
print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")")
|
|
return
|
|
}
|
|
|
|
// Get the notification content that was delivered
|
|
guard let content = storage.getNotificationContent(id: notificationId) else {
|
|
NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId)
|
|
print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)")
|
|
|
|
// Log available notification IDs for debugging
|
|
let allNotifications = storage.getAllNotifications()
|
|
let availableIds = allNotifications.map { $0.id }.joined(separator: ", ")
|
|
NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds)
|
|
print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]")
|
|
return
|
|
}
|
|
|
|
NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId)
|
|
print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification")
|
|
|
|
// Delegate to scheduler to schedule next notification (glue logic - will be moved to service)
|
|
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
|
|
let scheduled = await scheduler.scheduleNextNotification(
|
|
content,
|
|
storage: storage,
|
|
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
|
|
)
|
|
|
|
if scheduled {
|
|
NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId)
|
|
print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled")
|
|
} else {
|
|
NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId)
|
|
print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false")
|
|
}
|
|
|
|
// Rollover processing is non-fatal - recovery will handle on next launch if needed
|
|
}
|
|
|
|
/**
|
|
* Format time for logging
|
|
*
|
|
* @param timestamp Timestamp in milliseconds
|
|
* @return Formatted time string
|
|
*/
|
|
private func formatTime(_ timestamp: Int64) -> String {
|
|
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .short
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
/**
|
|
* Check permission status
|
|
* Returns boolean flags for each permission type
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func checkPermissionStatus(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Delegate to scheduler for permission status check
|
|
let notificationStatus = await scheduler.checkPermissionStatus()
|
|
let notificationsEnabled = notificationStatus == .authorized
|
|
|
|
// Format result (iOS-specific: exactAlarm and wakeLock map to notification permission)
|
|
let result: [String: Any] = [
|
|
"notificationsEnabled": notificationsEnabled,
|
|
"exactAlarmEnabled": notificationsEnabled, // iOS equivalent
|
|
"wakeLockEnabled": notificationsEnabled, // iOS equivalent (Background App Refresh)
|
|
"allPermissionsGranted": notificationsEnabled
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
call.reject("Failed to check permission status: \(error.localizedDescription)", "permission_check_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request notification permissions
|
|
* Shows system permission dialog if permissions haven't been determined yet
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func requestNotificationPermissions(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Delegate to scheduler for permission request
|
|
let granted = await scheduler.requestPermissions()
|
|
|
|
let result: [String: Any] = [
|
|
"granted": granted
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
call.reject("Failed to request permissions: \(error.localizedDescription)", "permission_request_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS-Specific Methods
|
|
|
|
/**
|
|
* Get notification permission status (iOS-specific)
|
|
*
|
|
* Returns detailed permission status matching API.md specification
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func getNotificationPermissionStatus(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Delegate to scheduler for permission status check
|
|
let status = await scheduler.checkPermissionStatus()
|
|
|
|
// Format result with all status flags
|
|
let result: [String: Any] = [
|
|
"authorized": status == .authorized,
|
|
"denied": status == .denied,
|
|
"notDetermined": status == .notDetermined,
|
|
"provisional": status == .provisional
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
call.reject("Failed to get permission status: \(error.localizedDescription)", "permission_status_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request notification permission (iOS-specific)
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func requestNotificationPermission(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Delegate to scheduler for permission request
|
|
let granted = await scheduler.requestPermissions()
|
|
|
|
let result: [String: Any] = [
|
|
"granted": granted
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
call.reject("Failed to request permission: \(error.localizedDescription)", "permission_request_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get pending notifications (iOS-specific)
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func getPendingNotifications(_ call: CAPPluginCall) {
|
|
Task {
|
|
do {
|
|
// Delegate to UNUserNotificationCenter for pending requests
|
|
let requests = try await notificationCenter.pendingNotificationRequests()
|
|
|
|
var notifications: [[String: Any]] = []
|
|
for request in requests {
|
|
let content = request.content
|
|
var triggerDate: Int64 = 0
|
|
|
|
if let calendarTrigger = request.trigger as? UNCalendarNotificationTrigger {
|
|
if let nextDate = calendarTrigger.nextTriggerDate() {
|
|
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
}
|
|
} else if let timeIntervalTrigger = request.trigger as? UNTimeIntervalNotificationTrigger {
|
|
if let nextDate = timeIntervalTrigger.nextTriggerDate() {
|
|
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|
|
|
|
let notification: [String: Any] = [
|
|
"identifier": request.identifier,
|
|
"title": content.title,
|
|
"body": content.body,
|
|
"triggerDate": triggerDate,
|
|
"triggerType": request.trigger is UNCalendarNotificationTrigger ? "calendar" : (request.trigger is UNTimeIntervalNotificationTrigger ? "timeInterval" : "location"),
|
|
"repeats": request.trigger?.repeats ?? false
|
|
]
|
|
notifications.append(notification)
|
|
}
|
|
|
|
let result: [String: Any] = [
|
|
"count": notifications.count,
|
|
"notifications": notifications
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
call.reject("Failed to get pending notifications: \(error.localizedDescription)", "pending_notifications_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get background task status (iOS-specific)
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func getBackgroundTaskStatus(_ call: CAPPluginCall) {
|
|
// Note: BGTaskScheduler doesn't provide a way to query registered task identifiers
|
|
// We assume tasks are registered if setupBackgroundTasks() was called
|
|
// Background App Refresh status cannot be checked programmatically
|
|
// User must check in Settings app
|
|
|
|
// Delegate storage access to storage service
|
|
let lastFetchExecution: Any = storage?.getLastSuccessfulRun() ?? NSNull()
|
|
let lastNotifyExecution: Any = storage?.getLastNotifyExecution() ?? NSNull()
|
|
|
|
let result: [String: Any] = [
|
|
"fetchTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
|
|
"notifyTaskRegistered": true, // Assumed registered if setupBackgroundTasks() was called
|
|
"lastFetchExecution": lastFetchExecution,
|
|
"lastNotifyExecution": lastNotifyExecution,
|
|
"backgroundRefreshEnabled": NSNull() // Cannot check programmatically
|
|
]
|
|
|
|
call.resolve(result)
|
|
}
|
|
|
|
/**
|
|
* Open notification settings (iOS-specific)
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func openNotificationSettings(_ call: CAPPluginCall) {
|
|
// Delegate to UIApplication to open settings
|
|
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
|
|
call.reject("Invalid settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
guard UIApplication.shared.canOpenURL(settingsUrl) else {
|
|
call.reject("Cannot open settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
UIApplication.shared.open(settingsUrl) { success in
|
|
DispatchQueue.main.async {
|
|
if success {
|
|
call.resolve()
|
|
} else {
|
|
call.reject("Failed to open notification settings", "open_settings_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open Background App Refresh settings (iOS-specific)
|
|
*
|
|
* Note: iOS doesn't provide a direct URL to Background App Refresh settings.
|
|
* This opens the app's settings page where user can find Background App Refresh.
|
|
*
|
|
* @param call Plugin call
|
|
*/
|
|
@objc func openBackgroundAppRefreshSettings(_ call: CAPPluginCall) {
|
|
// iOS doesn't have a direct URL to Background App Refresh settings
|
|
// Open app settings instead, where user can find Background App Refresh
|
|
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
|
|
call.reject("Invalid settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
guard UIApplication.shared.canOpenURL(settingsUrl) else {
|
|
call.reject("Cannot open settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
UIApplication.shared.open(settingsUrl) { success in
|
|
DispatchQueue.main.async {
|
|
if success {
|
|
call.resolve()
|
|
} else {
|
|
call.reject("Failed to open settings", "open_settings_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Channel Methods (iOS Parity with Android)
|
|
|
|
/**
|
|
* Check if notification channel is enabled
|
|
*
|
|
* iOS Note: iOS doesn't have per-channel control like Android. This method
|
|
* checks if notifications are authorized app-wide, which is the iOS equivalent.
|
|
*
|
|
* @param call Plugin call with optional channelId parameter
|
|
*/
|
|
@objc func isChannelEnabled(_ call: CAPPluginCall) {
|
|
guard let scheduler = scheduler else {
|
|
let error = DailyNotificationErrorCodes.createErrorResponse(
|
|
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
|
|
message: "Plugin not initialized"
|
|
)
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
// Get channelId from call (optional, for API parity with Android)
|
|
// iOS doesn't have per-channel control, so check app-wide notification authorization
|
|
let channelId = call.getString("channelId") ?? "default"
|
|
Task {
|
|
// Delegate to scheduler for permission status check
|
|
let status = await scheduler.checkPermissionStatus()
|
|
let enabled = (status == .authorized || status == .provisional)
|
|
|
|
let result: [String: Any] = [
|
|
"enabled": enabled,
|
|
"channelId": channelId
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open notification channel settings
|
|
*
|
|
* iOS Note: iOS doesn't have per-channel settings. This method opens
|
|
* the app's notification settings in iOS Settings, which is the iOS equivalent.
|
|
*
|
|
* @param call Plugin call with optional channelId parameter
|
|
*/
|
|
@objc func openChannelSettings(_ call: CAPPluginCall) {
|
|
// Get channelId from call (optional, for API parity with Android)
|
|
// iOS doesn't have per-channel settings, so open app-wide notification settings
|
|
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
|
|
call.reject("Invalid settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
guard UIApplication.shared.canOpenURL(settingsUrl) else {
|
|
call.reject("Cannot open settings URL", "open_settings_failed")
|
|
return
|
|
}
|
|
|
|
UIApplication.shared.open(settingsUrl) { success in
|
|
DispatchQueue.main.async {
|
|
if success {
|
|
call.resolve()
|
|
} else {
|
|
call.reject("Failed to open settings", "open_settings_failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update notification settings
|
|
*
|
|
* @param call Plugin call containing new settings
|
|
*/
|
|
@objc func updateSettings(_ call: CAPPluginCall) {
|
|
guard let settings = call.getObject("settings") else {
|
|
let error = DailyNotificationErrorCodes.missingParameter("settings")
|
|
let errorMessage = error["message"] as? String ?? "Unknown error"
|
|
let errorCode = error["error"] as? String ?? "unknown_error"
|
|
call.reject(errorMessage, errorCode)
|
|
return
|
|
}
|
|
|
|
Task {
|
|
// Delegate to stateActor if available (thread-safe), otherwise use storage directly
|
|
if #available(iOS 13.0, *), let stateActor = await self.stateActor {
|
|
await stateActor.saveSettings(settings)
|
|
} else {
|
|
self.storage?.saveSettings(settings)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Phase 3: JWT Fetcher HTTP Implementation
|
|
|
|
/**
|
|
* Fetch notification content from TimeSafari API using JWT authentication
|
|
*
|
|
* Phase 3: Complete HTTP implementation for JWT-signed fetcher
|
|
*
|
|
* This method:
|
|
* - Makes authenticated HTTP request to TimeSafari API
|
|
* - Uses JWT token in Authorization header
|
|
* - Parses response and converts to NotificationContent
|
|
* - Handles errors gracefully with fallback
|
|
*
|
|
* @param apiBaseUrl Base URL for TimeSafari API server
|
|
* @param activeDid Active DID for authentication
|
|
* @param jwtToken JWT token for Authorization header
|
|
* @return NotificationContent from API or throws error
|
|
*/
|
|
private func fetchContentFromAPI(
|
|
apiBaseUrl: String,
|
|
activeDid: String,
|
|
jwtToken: String
|
|
) async throws -> NotificationContent {
|
|
// Construct API endpoint URL
|
|
// Note: This is a minimal implementation - can be extended with full endpoint support
|
|
let endpoint = "/api/v2/report/offers"
|
|
guard let baseURL = URL(string: apiBaseUrl),
|
|
let url = URL(string: endpoint, relativeTo: baseURL) else {
|
|
throw NSError(
|
|
domain: "DailyNotification",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"]
|
|
)
|
|
}
|
|
|
|
// Create HTTP request with JWT authentication
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.timeoutInterval = 30.0 // 30 second timeout
|
|
|
|
print("DNP-FETCH-HTTP: Making request to \(url.absoluteString)")
|
|
|
|
// Execute HTTP request
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
// Validate HTTP response
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw NSError(
|
|
domain: "DailyNotification",
|
|
code: -2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"]
|
|
)
|
|
}
|
|
|
|
print("DNP-FETCH-HTTP: Response status code: \(httpResponse.statusCode)")
|
|
|
|
// Check for successful response
|
|
guard httpResponse.statusCode == 200 else {
|
|
throw NSError(
|
|
domain: "DailyNotification",
|
|
code: httpResponse.statusCode,
|
|
userInfo: [NSLocalizedDescriptionKey: "HTTP error: \(httpResponse.statusCode)"]
|
|
)
|
|
}
|
|
|
|
// Parse JSON response
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw NSError(
|
|
domain: "DailyNotification",
|
|
code: -3,
|
|
userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response"]
|
|
)
|
|
}
|
|
|
|
// Convert API response to NotificationContent
|
|
// Note: This is a minimal conversion - can be extended to handle full TimeSafari API response structure
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let content = NotificationContent(
|
|
id: "api_\(currentTime)",
|
|
title: json["title"] as? String ?? "Daily Update",
|
|
body: json["body"] as? String ?? "Your daily notification is ready",
|
|
scheduledTime: currentTime + (5 * 60 * 1000), // 5 min from now
|
|
fetchedAt: currentTime,
|
|
url: apiBaseUrl,
|
|
payload: json,
|
|
etag: httpResponse.value(forHTTPHeaderField: "ETag")
|
|
)
|
|
|
|
print("DNP-FETCH-HTTP: Successfully converted API response to NotificationContent")
|
|
return content
|
|
}
|
|
|
|
// MARK: - Phase 1: Helper Methods
|
|
|
|
/**
|
|
* Get next scheduled notification time
|
|
*
|
|
* Helper method to get the next scheduled notification time for
|
|
* scheduling background tasks. Uses async/await internally.
|
|
*
|
|
* @return Next scheduled notification time in milliseconds (Int64), or nil if none
|
|
*/
|
|
private func getNextScheduledNotificationTime() -> Int64? {
|
|
guard let scheduler = scheduler else {
|
|
return nil
|
|
}
|
|
|
|
// Use async helper to get next notification time
|
|
// Note: This is called from background task handlers which are already async
|
|
var nextTime: Int64? = nil
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
Task {
|
|
nextTime = await scheduler.getNextNotificationTime()
|
|
semaphore.signal()
|
|
}
|
|
|
|
// Wait with timeout (2 seconds - background tasks have limited time)
|
|
_ = semaphore.wait(timeout: .now() + 2.0)
|
|
return nextTime
|
|
}
|
|
|
|
/**
|
|
* Calculate next scheduled time for given hour and minute
|
|
*
|
|
* Uses scheduler's calculateNextOccurrence for consistency
|
|
*
|
|
* @param hour Hour (0-23)
|
|
* @param minute Minute (0-59)
|
|
* @return Timestamp in milliseconds
|
|
*/
|
|
private func calculateNextScheduledTime(hour: Int, minute: Int) -> Int64 {
|
|
guard let scheduler = scheduler else {
|
|
// Fallback calculation if scheduler not available
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
|
|
var components = calendar.dateComponents([.year, .month, .day], from: now)
|
|
components.hour = hour
|
|
components.minute = minute
|
|
components.second = 0
|
|
|
|
var scheduledDate = calendar.date(from: components) ?? now
|
|
|
|
// If scheduled time has passed today, schedule for tomorrow
|
|
if scheduledDate <= now {
|
|
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
|
|
}
|
|
|
|
return Int64(scheduledDate.timeIntervalSince1970 * 1000)
|
|
}
|
|
|
|
return scheduler.calculateNextOccurrence(hour: hour, minute: minute)
|
|
}
|
|
|
|
/**
|
|
* Schedule background fetch 5 minutes before notification time
|
|
*
|
|
* @param scheduledTime Notification scheduled time in milliseconds
|
|
*/
|
|
private func scheduleBackgroundFetch(scheduledTime: Int64) {
|
|
// Calculate fetch time (5 minutes before notification)
|
|
let fetchTime = scheduledTime - (5 * 60 * 1000) // 5 minutes in milliseconds
|
|
let fetchDate = Date(timeIntervalSince1970: Double(fetchTime) / 1000.0)
|
|
|
|
// Schedule BGTaskScheduler task
|
|
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
|
|
request.earliestBeginDate = fetchDate
|
|
|
|
do {
|
|
try backgroundTaskScheduler.submit(request)
|
|
|
|
// Store earliest begin date for miss detection via state actor (thread-safe)
|
|
Task {
|
|
if #available(iOS 13.0, *) {
|
|
if let stateActor = await self.stateActor {
|
|
await stateActor.saveBGTaskEarliestBegin(timestamp: fetchTime)
|
|
} else {
|
|
// Fallback to direct storage access
|
|
self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime)
|
|
}
|
|
} else {
|
|
// Fallback for iOS < 13
|
|
self.storage?.saveBGTaskEarliestBegin(timestamp: fetchTime)
|
|
}
|
|
}
|
|
|
|
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(fetchDate)")
|
|
} catch {
|
|
// BGTaskScheduler errors are common on simulator (Code=1: notPermitted)
|
|
// This is expected behavior - simulators don't reliably support background tasks
|
|
// On real devices, this should work if Background App Refresh is enabled
|
|
let errorDescription = error.localizedDescription
|
|
if errorDescription.contains("BGTaskSchedulerErrorDomain") ||
|
|
errorDescription.contains("Code=1") ||
|
|
(error as NSError).domain == "BGTaskSchedulerErrorDomain" {
|
|
print("DNP-FETCH-SCHEDULE: Background fetch scheduling failed (expected on simulator): \(errorDescription)")
|
|
print("DNP-FETCH-SCHEDULE: Note: BGTaskScheduler requires real device or Background App Refresh enabled")
|
|
} else {
|
|
print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)")
|
|
}
|
|
// Don't fail notification scheduling if background fetch fails
|
|
// Notification will still be delivered, just without prefetch
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CAPBridgedPlugin Conformance
|
|
// This extension makes the plugin conform to CAPBridgedPlugin protocol
|
|
// which is required for Capacitor to discover and register the plugin
|
|
@objc extension DailyNotificationPlugin: CAPBridgedPlugin {
|
|
@objc public var identifier: String {
|
|
return "com.timesafari.dailynotification"
|
|
}
|
|
|
|
@objc public var jsName: String {
|
|
return "DailyNotification"
|
|
}
|
|
|
|
@objc public var pluginMethods: [CAPPluginMethod] {
|
|
var methods: [CAPPluginMethod] = []
|
|
|
|
// Core methods
|
|
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "updateSettings", returnType: CAPPluginReturnPromise))
|
|
|
|
// Permission methods
|
|
methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise))
|
|
|
|
// iOS-specific methods
|
|
methods.append(CAPPluginMethod(name: "getNotificationPermissionStatus", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "requestNotificationPermission", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getPendingNotifications", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getBackgroundTaskStatus", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "openNotificationSettings", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "openBackgroundAppRefreshSettings", returnType: CAPPluginReturnPromise))
|
|
|
|
// Channel methods (iOS parity with Android)
|
|
methods.append(CAPPluginMethod(name: "isChannelEnabled", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "openChannelSettings", returnType: CAPPluginReturnPromise))
|
|
|
|
// Reminder methods
|
|
methods.append(CAPPluginMethod(name: "scheduleDailyReminder", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "cancelDailyReminder", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getScheduledReminders", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "updateDailyReminder", returnType: CAPPluginReturnPromise))
|
|
|
|
// Dual scheduling methods
|
|
methods.append(CAPPluginMethod(name: "scheduleContentFetch", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise))
|
|
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
|
|
|
|
return methods
|
|
}
|
|
} |