Implemented TimeSafari integration configuration methods: configureNativeFetcher(): - Accepts apiBaseUrl, activeDid, and jwtToken/jwtSecret - Stores configuration as JSON in UserDefaults - Matches Android database storage pattern - Supports backward compatibility (jwtToken/jwtSecret) - Stores configuredAt timestamp setActiveDidFromHost(): - Simpler method for updating just the activeDid - Updates activeDid in UserDefaults - Updates existing native fetcher config if present - Stores updatedAt timestamp iOS Adaptations: - Uses UserDefaults instead of database (iOS equivalent of SharedPreferences) - JSON serialization for config storage - No native fetcher interface (unlike Android) - config stored for background tasks Progress: 19/52 methods implemented (37% complete)
1202 lines
46 KiB
Swift
1202 lines
46 KiB
Swift
//
|
|
// DailyNotificationPlugin.swift
|
|
// DailyNotificationPlugin
|
|
//
|
|
// Created by Matthew Raymer on 2025-09-22
|
|
// Copyright © 2025 TimeSafari. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Capacitor
|
|
import UserNotifications
|
|
import BackgroundTasks
|
|
import CoreData
|
|
import UIKit
|
|
|
|
/**
|
|
* 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()
|
|
let backgroundTaskScheduler = BGTaskScheduler.shared
|
|
let persistenceController = PersistenceController.shared
|
|
|
|
// Background task identifiers
|
|
private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
|
|
private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify"
|
|
|
|
override public func load() {
|
|
super.load()
|
|
setupBackgroundTasks()
|
|
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
|
|
}
|
|
|
|
// MARK: - Configuration Methods
|
|
|
|
@objc func configure(_ call: CAPPluginCall) {
|
|
guard let options = call.getObject("options") else {
|
|
call.reject("Configuration options required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Configure called with options: \(options)")
|
|
|
|
// Store configuration in UserDefaults
|
|
UserDefaults.standard.set(options, forKey: "DailyNotificationConfig")
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
// MARK: - Dual Scheduling Methods
|
|
|
|
@objc func scheduleContentFetch(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config") else {
|
|
call.reject("Content fetch config required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Scheduling content fetch")
|
|
|
|
do {
|
|
try scheduleBackgroundFetch(config: config)
|
|
call.resolve()
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to schedule content fetch: \(error)")
|
|
call.reject("Content fetch scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func scheduleUserNotification(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config") else {
|
|
call.reject("User notification config required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Scheduling user notification")
|
|
|
|
do {
|
|
try scheduleUserNotification(config: config)
|
|
call.resolve()
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to schedule user notification: \(error)")
|
|
call.reject("User notification scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func scheduleDualNotification(_ call: CAPPluginCall) {
|
|
guard let config = call.getObject("config"),
|
|
let contentFetchConfig = config["contentFetch"] as? [String: Any],
|
|
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
|
|
call.reject("Dual notification config required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Scheduling dual notification")
|
|
|
|
do {
|
|
try scheduleBackgroundFetch(config: contentFetchConfig)
|
|
try scheduleUserNotification(config: userNotificationConfig)
|
|
call.resolve()
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to schedule dual notification: \(error)")
|
|
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func getDualScheduleStatus(_ call: CAPPluginCall) {
|
|
Task {
|
|
do {
|
|
let status = try await getHealthStatus()
|
|
call.resolve(status)
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to get dual schedule status: \(error)")
|
|
call.reject("Status retrieval failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Scheduling Method
|
|
|
|
/**
|
|
* Schedule a daily notification
|
|
*
|
|
* This is the main scheduling method, equivalent to Android's scheduleDailyNotification.
|
|
* Schedules both the notification and a prefetch 5 minutes before.
|
|
*
|
|
* @param call Plugin call with options:
|
|
* - time: String (required) - Time in HH:mm format (e.g., "09:00")
|
|
* - title: String (optional) - Notification title (default: "Daily Notification")
|
|
* - body: String (optional) - Notification body (default: "")
|
|
* - sound: Bool (optional) - Enable sound (default: true)
|
|
* - priority: String (optional) - Priority: "high", "default", "low" (default: "default")
|
|
* - url: String (optional) - URL for prefetch (optional, native fetcher used if registered)
|
|
*/
|
|
@objc func scheduleDailyNotification(_ call: CAPPluginCall) {
|
|
// Check notification permissions first
|
|
notificationCenter.getNotificationSettings { settings in
|
|
if settings.authorizationStatus != .authorized {
|
|
// Request permission if not granted
|
|
self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
print("DNP-PLUGIN: Permission request failed: \(error)")
|
|
call.reject("Notification permission request failed: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
if !granted {
|
|
print("DNP-PLUGIN: Notification permission denied")
|
|
call.reject("Notification permission denied. Please enable notifications in Settings.", "PERMISSION_DENIED")
|
|
return
|
|
}
|
|
|
|
// Permission granted, proceed with scheduling
|
|
self.performScheduleDailyNotification(call: call)
|
|
}
|
|
}
|
|
} else {
|
|
// Permission already granted, proceed
|
|
self.performScheduleDailyNotification(call: call)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform the actual scheduling after permission check
|
|
*/
|
|
private func performScheduleDailyNotification(call: CAPPluginCall) {
|
|
guard let options = call.options else {
|
|
call.reject("Options are required")
|
|
return
|
|
}
|
|
|
|
guard let timeString = options["time"] as? String else {
|
|
call.reject("Time is required (format: HH:mm)")
|
|
return
|
|
}
|
|
|
|
let title = options["title"] as? String ?? "Daily Notification"
|
|
let body = options["body"] as? String ?? ""
|
|
let sound = options["sound"] as? Bool ?? true
|
|
let priority = options["priority"] as? String ?? "default"
|
|
let url = options["url"] as? String // Optional URL for prefetch
|
|
|
|
print("DNP-PLUGIN: Scheduling daily notification: time=\(timeString), title=\(title)")
|
|
|
|
// Parse time (HH:mm format)
|
|
let timeComponents = timeString.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
|
|
}
|
|
|
|
// Calculate next run time
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
var dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
|
|
dateComponents.hour = hour
|
|
dateComponents.minute = minute
|
|
dateComponents.second = 0
|
|
|
|
guard var nextRunDate = calendar.date(from: dateComponents) else {
|
|
call.reject("Failed to calculate next run time")
|
|
return
|
|
}
|
|
|
|
// If the time has already passed today, schedule for tomorrow
|
|
if nextRunDate <= now {
|
|
nextRunDate = calendar.date(byAdding: .day, value: 1, to: nextRunDate) ?? nextRunDate
|
|
}
|
|
|
|
let nextRunTime = nextRunDate.timeIntervalSince1970 * 1000 // Convert to milliseconds
|
|
let nextRunTimeInterval = nextRunDate.timeIntervalSinceNow
|
|
|
|
// Create notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = sound ? .default : nil
|
|
content.categoryIdentifier = "DAILY_NOTIFICATION"
|
|
|
|
// Set priority/interruption level
|
|
if #available(iOS 15.0, *) {
|
|
switch priority.lowercased() {
|
|
case "high", "max":
|
|
content.interruptionLevel = .critical
|
|
case "low", "min":
|
|
content.interruptionLevel = .passive
|
|
default:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
|
|
// Create date components for daily trigger
|
|
var triggerComponents = DateComponents()
|
|
triggerComponents.hour = hour
|
|
triggerComponents.minute = minute
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: triggerComponents,
|
|
repeats: true // Daily repeat
|
|
)
|
|
|
|
// Create unique identifier
|
|
let scheduleId = "daily_\(Int(Date().timeIntervalSince1970 * 1000))"
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: scheduleId,
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule the notification
|
|
notificationCenter.add(request) { error in
|
|
if let error = error {
|
|
print("DNP-PLUGIN: Failed to schedule notification: \(error)")
|
|
call.reject("Failed to schedule notification: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Notification scheduled successfully: \(scheduleId)")
|
|
|
|
// Schedule prefetch 5 minutes before notification
|
|
let fetchTime = nextRunTime - (5 * 60 * 1000) // 5 minutes before in milliseconds
|
|
let fetchTimeInterval = (fetchTime / 1000) - Date().timeIntervalSince1970
|
|
|
|
if fetchTimeInterval > 0 {
|
|
// Schedule background fetch task
|
|
do {
|
|
let fetchRequest = BGAppRefreshTaskRequest(identifier: self.fetchTaskIdentifier)
|
|
fetchRequest.earliestBeginDate = Date(timeIntervalSinceNow: fetchTimeInterval)
|
|
|
|
try self.backgroundTaskScheduler.submit(fetchRequest)
|
|
print("DNP-PLUGIN: Prefetch scheduled: fetchTime=\(fetchTime), notificationTime=\(nextRunTime)")
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to schedule prefetch: \(error)")
|
|
// Don't fail the whole operation if prefetch scheduling fails
|
|
}
|
|
} else {
|
|
// Fetch time is in the past, trigger immediate fetch if possible
|
|
print("DNP-PLUGIN: Fetch time is in the past, skipping prefetch scheduling")
|
|
}
|
|
|
|
// Store schedule in UserDefaults (similar to Android database storage)
|
|
self.storeScheduleInUserDefaults(
|
|
id: scheduleId,
|
|
time: timeString,
|
|
title: title,
|
|
body: body,
|
|
nextRunTime: nextRunTime
|
|
)
|
|
|
|
call.resolve()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store schedule in UserDefaults
|
|
*/
|
|
private func storeScheduleInUserDefaults(
|
|
id: String,
|
|
time: String,
|
|
title: String,
|
|
body: String,
|
|
nextRunTime: TimeInterval
|
|
) {
|
|
var schedules = UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? []
|
|
|
|
// Remove existing schedule with same ID if present
|
|
schedules.removeAll { ($0["id"] as? String) == id }
|
|
|
|
let schedule: [String: Any] = [
|
|
"id": id,
|
|
"kind": "notify",
|
|
"time": time,
|
|
"title": title,
|
|
"body": body,
|
|
"nextRunTime": nextRunTime,
|
|
"enabled": true,
|
|
"createdAt": Date().timeIntervalSince1970 * 1000
|
|
]
|
|
|
|
schedules.append(schedule)
|
|
UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules")
|
|
|
|
print("DNP-PLUGIN: Schedule stored: \(id)")
|
|
}
|
|
|
|
// MARK: - Status & Cancellation Methods
|
|
|
|
/**
|
|
* Get notification status
|
|
*
|
|
* Returns the current status of scheduled notifications, including:
|
|
* - Whether notifications are enabled/scheduled
|
|
* - Last notification time
|
|
* - Next notification time
|
|
* - Pending notification count
|
|
*
|
|
* Equivalent to Android's getNotificationStatus method.
|
|
*/
|
|
@objc func getNotificationStatus(_ call: CAPPluginCall) {
|
|
// Get pending notifications from UNUserNotificationCenter
|
|
notificationCenter.getPendingNotificationRequests { requests in
|
|
// Filter for daily notifications (those starting with "daily_")
|
|
let dailyNotifications = requests.filter { $0.identifier.hasPrefix("daily_") }
|
|
|
|
// Get schedules from UserDefaults
|
|
let schedules = self.getSchedulesFromUserDefaults()
|
|
let notifySchedules = schedules.filter {
|
|
($0["kind"] as? String) == "notify" &&
|
|
($0["enabled"] as? Bool) == true
|
|
}
|
|
|
|
// Calculate next notification time
|
|
var nextNotificationTime: TimeInterval = 0
|
|
if let nextRunTimes = notifySchedules.compactMap({ $0["nextRunTime"] as? TimeInterval }) as? [TimeInterval],
|
|
!nextRunTimes.isEmpty {
|
|
nextNotificationTime = nextRunTimes.min() ?? 0
|
|
}
|
|
|
|
// Get last notification time from UserDefaults (stored when notification is delivered)
|
|
// For now, we'll use 0 if not available (history tracking can be added later)
|
|
let lastNotificationTime = UserDefaults.standard.double(forKey: "DailyNotificationLastDeliveryTime")
|
|
|
|
// Build result matching Android API
|
|
let result: [String: Any] = [
|
|
"isEnabled": !notifySchedules.isEmpty,
|
|
"isScheduled": !notifySchedules.isEmpty,
|
|
"lastNotificationTime": lastNotificationTime > 0 ? lastNotificationTime : 0,
|
|
"nextNotificationTime": nextNotificationTime,
|
|
"scheduledCount": notifySchedules.count,
|
|
"pending": dailyNotifications.count,
|
|
"settings": [
|
|
"enabled": !notifySchedules.isEmpty,
|
|
"count": notifySchedules.count
|
|
] as [String: Any]
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel all notifications
|
|
*
|
|
* Cancels all scheduled daily notifications:
|
|
* 1. Removes all pending notifications from UNUserNotificationCenter
|
|
* 2. Cancels all background fetch tasks
|
|
* 3. Clears schedules from UserDefaults
|
|
*
|
|
* Equivalent to Android's cancelAllNotifications method.
|
|
* The method is idempotent - safe to call multiple times.
|
|
*/
|
|
@objc func cancelAllNotifications(_ call: CAPPluginCall) {
|
|
print("DNP-PLUGIN: Cancelling all notifications")
|
|
|
|
// 1. Get all pending notifications
|
|
notificationCenter.getPendingNotificationRequests { requests in
|
|
let dailyNotificationIds = requests
|
|
.filter { $0.identifier.hasPrefix("daily_") }
|
|
.map { $0.identifier }
|
|
|
|
// 2. Remove all daily notifications
|
|
if !dailyNotificationIds.isEmpty {
|
|
self.notificationCenter.removePendingNotificationRequests(withIdentifiers: dailyNotificationIds)
|
|
print("DNP-PLUGIN: Removed \(dailyNotificationIds.count) pending notification(s)")
|
|
}
|
|
|
|
// 3. Cancel all background tasks
|
|
// Cancel by identifier (BGTaskScheduler requires identifier to cancel)
|
|
// Note: cancel() doesn't throw, it's safe to call even if task doesn't exist
|
|
self.backgroundTaskScheduler.cancel(taskRequestWithIdentifier: self.fetchTaskIdentifier)
|
|
self.backgroundTaskScheduler.cancel(taskRequestWithIdentifier: self.notifyTaskIdentifier)
|
|
print("DNP-PLUGIN: Cancelled background tasks")
|
|
|
|
// 4. Clear schedules from UserDefaults
|
|
var schedules = UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? []
|
|
let notifySchedules = schedules.filter { ($0["kind"] as? String) == "notify" }
|
|
schedules.removeAll { ($0["kind"] as? String) == "notify" }
|
|
UserDefaults.standard.set(schedules, forKey: "DailyNotificationSchedules")
|
|
print("DNP-PLUGIN: Removed \(notifySchedules.count) schedule(s) from storage")
|
|
|
|
DispatchQueue.main.async {
|
|
print("DNP-PLUGIN: All notifications cancelled successfully")
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get schedules from UserDefaults
|
|
*/
|
|
private func getSchedulesFromUserDefaults() -> [[String: Any]] {
|
|
return UserDefaults.standard.array(forKey: "DailyNotificationSchedules") as? [[String: Any]] ?? []
|
|
}
|
|
|
|
// MARK: - Permission Methods
|
|
|
|
/**
|
|
* Check permission status
|
|
*
|
|
* Returns boolean flags for each permission type:
|
|
* - notificationsEnabled: Notification authorization status
|
|
* - exactAlarmEnabled: Background App Refresh status (iOS equivalent)
|
|
* - wakeLockEnabled: Always true on iOS (not applicable)
|
|
* - allPermissionsGranted: All permissions granted
|
|
*
|
|
* Equivalent to Android's checkPermissionStatus method.
|
|
*/
|
|
@objc func checkPermissionStatus(_ call: CAPPluginCall) {
|
|
print("DNP-PLUGIN: Checking permission status")
|
|
|
|
// Check notification authorization
|
|
notificationCenter.getNotificationSettings { settings in
|
|
let notificationsEnabled = settings.authorizationStatus == .authorized
|
|
|
|
// Check Background App Refresh (iOS equivalent of exact alarm permission)
|
|
// Note: We can't directly check this, but we can infer it's enabled
|
|
// if the app is allowed to run background tasks
|
|
// For now, we'll assume it's enabled if notifications are authorized
|
|
// (Background App Refresh is a system setting, not a runtime permission)
|
|
let exactAlarmEnabled = notificationsEnabled // Simplified - actual check would require checking system settings
|
|
|
|
// Wake lock is not applicable on iOS (always available if app is running)
|
|
let wakeLockEnabled = true
|
|
|
|
let allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled
|
|
|
|
let result: [String: Any] = [
|
|
"notificationsEnabled": notificationsEnabled,
|
|
"exactAlarmEnabled": exactAlarmEnabled,
|
|
"wakeLockEnabled": wakeLockEnabled,
|
|
"allPermissionsGranted": allPermissionsGranted
|
|
]
|
|
|
|
print("DNP-PLUGIN: Permission status: notifications=\(notificationsEnabled), exactAlarm=\(exactAlarmEnabled), wakeLock=\(wakeLockEnabled), all=\(allPermissionsGranted)")
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request notification permissions
|
|
*
|
|
* Requests notification authorization from the user.
|
|
* Returns PermissionStatus matching Android API format.
|
|
*
|
|
* Equivalent to Android's requestNotificationPermissions method.
|
|
*/
|
|
@objc func requestNotificationPermissions(_ call: CAPPluginCall) {
|
|
print("DNP-PLUGIN: Requesting notification permissions")
|
|
|
|
// Check current authorization status
|
|
notificationCenter.getNotificationSettings { settings in
|
|
if settings.authorizationStatus == .authorized {
|
|
// Already granted
|
|
let result: [String: Any] = [
|
|
"status": "granted",
|
|
"granted": true,
|
|
"notifications": "granted",
|
|
"alert": settings.alertSetting == .enabled,
|
|
"badge": settings.badgeSetting == .enabled,
|
|
"sound": settings.soundSetting == .enabled,
|
|
"lockScreen": settings.lockScreenSetting == .enabled,
|
|
"carPlay": settings.carPlaySetting == .enabled
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Request authorization
|
|
self.notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
print("DNP-PLUGIN: Permission request failed: \(error)")
|
|
call.reject("Permission request failed: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
// Get updated settings after request
|
|
self.notificationCenter.getNotificationSettings { updatedSettings in
|
|
let result: [String: Any] = [
|
|
"status": granted ? "granted" : "denied",
|
|
"granted": granted,
|
|
"notifications": granted ? "granted" : "denied",
|
|
"alert": updatedSettings.alertSetting == .enabled,
|
|
"badge": updatedSettings.badgeSetting == .enabled,
|
|
"sound": updatedSettings.soundSetting == .enabled,
|
|
"lockScreen": updatedSettings.lockScreenSetting == .enabled,
|
|
"carPlay": updatedSettings.carPlaySetting == .enabled
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
print("DNP-PLUGIN: Permission request completed: granted=\(granted)")
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check permissions (Capacitor standard format)
|
|
*
|
|
* Returns PermissionStatus with notifications field as PermissionState.
|
|
* This is the standard Capacitor permission check format.
|
|
*/
|
|
@objc func checkPermissions(_ call: CAPPluginCall) {
|
|
print("DNP-PLUGIN: Checking permissions (Capacitor format)")
|
|
|
|
notificationCenter.getNotificationSettings { settings in
|
|
let permissionState: String
|
|
switch settings.authorizationStatus {
|
|
case .authorized:
|
|
permissionState = "granted"
|
|
case .denied:
|
|
permissionState = "denied"
|
|
case .notDetermined:
|
|
permissionState = "prompt"
|
|
case .provisional:
|
|
permissionState = "provisional"
|
|
@unknown default:
|
|
permissionState = "unknown"
|
|
}
|
|
|
|
let result: [String: Any] = [
|
|
"status": permissionState,
|
|
"granted": settings.authorizationStatus == .authorized,
|
|
"notifications": permissionState,
|
|
"alert": settings.alertSetting == .enabled,
|
|
"badge": settings.badgeSetting == .enabled,
|
|
"sound": settings.soundSetting == .enabled,
|
|
"lockScreen": settings.lockScreenSetting == .enabled,
|
|
"carPlay": settings.carPlaySetting == .enabled
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request permissions (alias for requestNotificationPermissions)
|
|
*
|
|
* Standard Capacitor permission request method.
|
|
*/
|
|
@objc func requestPermissions(_ call: CAPPluginCall) {
|
|
// Delegate to requestNotificationPermissions
|
|
requestNotificationPermissions(call)
|
|
}
|
|
|
|
/**
|
|
* Get battery status
|
|
*
|
|
* Returns battery information including:
|
|
* - level: Battery level (0-100)
|
|
* - isCharging: Whether device is charging
|
|
* - powerState: Power state code
|
|
* - isOptimizationExempt: Whether app is exempt from battery optimization (iOS: always false)
|
|
*
|
|
* Equivalent to Android's getBatteryStatus method.
|
|
*/
|
|
@objc func getBatteryStatus(_ call: CAPPluginCall) {
|
|
print("DNP-PLUGIN: Getting battery status")
|
|
|
|
// Enable battery monitoring if not already enabled
|
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
|
|
// Get battery level (0.0 to 1.0, -1.0 if unknown)
|
|
let batteryLevel = UIDevice.current.batteryLevel
|
|
let batteryLevelPercent = batteryLevel >= 0 ? Int(batteryLevel * 100) : -1
|
|
|
|
// Get battery state
|
|
let batteryState = UIDevice.current.batteryState
|
|
let isCharging = batteryState == .charging || batteryState == .full
|
|
|
|
// Map battery state to power state code
|
|
// 0 = unknown, 1 = unplugged, 2 = charging, 3 = full
|
|
let powerState: Int
|
|
switch batteryState {
|
|
case .unknown:
|
|
powerState = 0
|
|
case .unplugged:
|
|
powerState = 1
|
|
case .charging:
|
|
powerState = 2
|
|
case .full:
|
|
powerState = 3
|
|
@unknown default:
|
|
powerState = 0
|
|
}
|
|
|
|
// iOS doesn't have battery optimization like Android
|
|
// Background App Refresh is the closest equivalent, but we can't check it directly
|
|
// Return false to indicate we're subject to system power management
|
|
let isOptimizationExempt = false
|
|
|
|
let result: [String: Any] = [
|
|
"level": batteryLevelPercent,
|
|
"isCharging": isCharging,
|
|
"powerState": powerState,
|
|
"isOptimizationExempt": isOptimizationExempt
|
|
]
|
|
|
|
print("DNP-PLUGIN: Battery status: level=\(batteryLevelPercent)%, charging=\(isCharging), state=\(powerState)")
|
|
|
|
call.resolve(result)
|
|
}
|
|
|
|
// MARK: - Configuration Methods
|
|
|
|
/**
|
|
* Update starred plan IDs
|
|
*
|
|
* Stores plan IDs in UserDefaults for native fetcher to use.
|
|
* Matches Android SharedPreferences storage pattern.
|
|
*
|
|
* Equivalent to Android's updateStarredPlans method.
|
|
*/
|
|
@objc func updateStarredPlans(_ call: CAPPluginCall) {
|
|
guard let options = call.options else {
|
|
call.reject("Options are required")
|
|
return
|
|
}
|
|
|
|
// Extract planIds array from options
|
|
guard let planIdsValue = options["planIds"] else {
|
|
call.reject("planIds array is required")
|
|
return
|
|
}
|
|
|
|
var planIds: [String] = []
|
|
|
|
// Handle different array formats from Capacitor
|
|
if let planIdsArray = planIdsValue as? [String] {
|
|
planIds = planIdsArray
|
|
} else if let planIdsArray = planIdsValue as? [Any] {
|
|
planIds = planIdsArray.compactMap { $0 as? String }
|
|
} else if let singlePlanId = planIdsValue as? String {
|
|
planIds = [singlePlanId]
|
|
} else {
|
|
call.reject("planIds must be an array of strings")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Updating starred plans: count=\(planIds.count)")
|
|
|
|
// Store in UserDefaults (matching Android SharedPreferences)
|
|
// Use suite name to match Android prefs name pattern
|
|
let prefsName = "daily_notification_timesafari"
|
|
let keyStarredPlanIds = "starredPlanIds"
|
|
|
|
// Convert planIds to JSON array string (matching Android format)
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: planIds, options: [])
|
|
let jsonString = String(data: jsonData, encoding: .utf8) ?? "[]"
|
|
|
|
// Store in UserDefaults with suite name
|
|
let userDefaults = UserDefaults(suiteName: prefsName) ?? UserDefaults.standard
|
|
userDefaults.set(jsonString, forKey: keyStarredPlanIds)
|
|
userDefaults.synchronize()
|
|
|
|
let result: [String: Any] = [
|
|
"success": true,
|
|
"planIdsCount": planIds.count,
|
|
"updatedAt": Int64(Date().timeIntervalSince1970 * 1000)
|
|
]
|
|
|
|
print("DNP-PLUGIN: Starred plans updated: count=\(planIds.count)")
|
|
call.resolve(result)
|
|
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to serialize planIds: \(error)")
|
|
call.reject("Failed to update starred plans: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure native fetcher
|
|
*
|
|
* Configures the native content fetcher with API credentials.
|
|
* Stores configuration in UserDefaults for persistence.
|
|
*
|
|
* Note: iOS doesn't have the same native fetcher interface as Android.
|
|
* Configuration is stored and can be used by background fetch tasks.
|
|
*
|
|
* Equivalent to Android's configureNativeFetcher method.
|
|
*/
|
|
@objc func configureNativeFetcher(_ call: CAPPluginCall) {
|
|
guard let options = call.options else {
|
|
call.reject("Options are required")
|
|
return
|
|
}
|
|
|
|
// Extract required parameters
|
|
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
|
|
guard let jwtToken = (options["jwtToken"] as? String) ?? (options["jwtSecret"] as? String) else {
|
|
call.reject("jwtToken or jwtSecret is required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...")
|
|
|
|
// Store configuration in UserDefaults (matching Android database storage)
|
|
let configId = "native_fetcher_config"
|
|
let configKey = "DailyNotificationNativeFetcherConfig"
|
|
|
|
let config: [String: Any] = [
|
|
"apiBaseUrl": apiBaseUrl,
|
|
"activeDid": activeDid,
|
|
"jwtToken": jwtToken,
|
|
"configuredAt": Date().timeIntervalSince1970 * 1000
|
|
]
|
|
|
|
// Store as JSON string for consistency with Android
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: config, options: [])
|
|
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
|
|
|
|
UserDefaults.standard.set(jsonString, forKey: configKey)
|
|
UserDefaults.standard.synchronize()
|
|
|
|
print("DNP-PLUGIN: Native fetcher configuration stored successfully")
|
|
call.resolve()
|
|
|
|
} catch {
|
|
print("DNP-PLUGIN: Failed to store native fetcher config: \(error)")
|
|
call.reject("Failed to store configuration: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set active DID from host
|
|
*
|
|
* Sets the active DID (identity) for TimeSafari integration.
|
|
* This is a simpler method than configureNativeFetcher for just updating the DID.
|
|
*
|
|
* Equivalent to Android's setActiveDidFromHost method.
|
|
*/
|
|
@objc func setActiveDidFromHost(_ call: CAPPluginCall) {
|
|
guard let activeDid = call.getString("activeDid") else {
|
|
call.reject("activeDid is required")
|
|
return
|
|
}
|
|
|
|
print("DNP-PLUGIN: Setting activeDid from host: \(activeDid.prefix(30))...")
|
|
|
|
// Store activeDid in UserDefaults
|
|
let keyActiveDid = "DailyNotificationActiveDid"
|
|
UserDefaults.standard.set(activeDid, forKey: keyActiveDid)
|
|
UserDefaults.standard.synchronize()
|
|
|
|
// If there's existing native fetcher config, update it
|
|
let configKey = "DailyNotificationNativeFetcherConfig"
|
|
if let configJson = UserDefaults.standard.string(forKey: configKey),
|
|
let configData = configJson.data(using: .utf8),
|
|
var config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] {
|
|
config["activeDid"] = activeDid
|
|
config["updatedAt"] = Date().timeIntervalSince1970 * 1000
|
|
|
|
if let updatedJsonData = try? JSONSerialization.data(withJSONObject: config, options: []),
|
|
let updatedJsonString = String(data: updatedJsonData, encoding: .utf8) {
|
|
UserDefaults.standard.set(updatedJsonString, forKey: configKey)
|
|
UserDefaults.standard.synchronize()
|
|
}
|
|
}
|
|
|
|
print("DNP-PLUGIN: ActiveDid set successfully")
|
|
call.resolve()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
private func scheduleBackgroundFetch(config: [String: Any]) throws {
|
|
let request = BGAppRefreshTaskRequest(identifier: fetchTaskIdentifier)
|
|
|
|
// Calculate next run time (simplified - would use proper cron parsing in production)
|
|
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
|
|
request.earliestBeginDate = Date(timeIntervalSinceNow: nextRunTime)
|
|
|
|
try backgroundTaskScheduler.submit(request)
|
|
print("DNP-FETCH-SCHEDULE: Background fetch scheduled for \(request.earliestBeginDate!)")
|
|
}
|
|
|
|
private func scheduleUserNotification(config: [String: Any]) throws {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = config["title"] as? String ?? "Daily Notification"
|
|
content.body = config["body"] as? String ?? "Your daily update is ready"
|
|
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
|
|
|
|
// Create trigger (simplified - would use proper cron parsing in production)
|
|
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
notificationCenter.add(request) { error in
|
|
if let error = error {
|
|
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
|
|
} else {
|
|
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
|
|
// Simplified implementation - would use proper cron parsing in production
|
|
// For now, return next day at 9 AM
|
|
return 86400 // 24 hours
|
|
}
|
|
|
|
// MARK: - Static Daily Reminder Methods
|
|
|
|
@objc func scheduleDailyReminder(_ call: CAPPluginCall) {
|
|
guard let id = call.getString("id"),
|
|
let title = call.getString("title"),
|
|
let body = call.getString("body"),
|
|
let time = call.getString("time") else {
|
|
call.reject("Missing required parameters: id, title, body, time")
|
|
return
|
|
}
|
|
|
|
let sound = call.getBool("sound", true)
|
|
let vibration = call.getBool("vibration", true)
|
|
let priority = call.getString("priority", "normal")
|
|
let repeatDaily = call.getBool("repeatDaily", true)
|
|
let timezone = call.getString("timezone")
|
|
|
|
print("DNP-REMINDER: Scheduling daily reminder: \(id)")
|
|
|
|
// Parse time (HH:mm format)
|
|
let timeComponents = time.components(separatedBy: ":")
|
|
guard timeComponents.count == 2,
|
|
let hour = Int(timeComponents[0]),
|
|
let minute = Int(timeComponents[1]),
|
|
hour >= 0 && hour <= 23,
|
|
minute >= 0 && minute <= 59 else {
|
|
call.reject("Invalid time format. Use HH:mm (e.g., 09:00)")
|
|
return
|
|
}
|
|
|
|
// Create notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = sound ? .default : nil
|
|
content.categoryIdentifier = "DAILY_REMINDER"
|
|
|
|
// Set priority
|
|
if #available(iOS 15.0, *) {
|
|
switch priority {
|
|
case "high":
|
|
content.interruptionLevel = .critical
|
|
case "low":
|
|
content.interruptionLevel = .passive
|
|
default:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
|
|
// Create date components for daily trigger
|
|
var dateComponents = DateComponents()
|
|
dateComponents.hour = hour
|
|
dateComponents.minute = minute
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: repeatDaily
|
|
)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "reminder_\(id)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Store reminder in UserDefaults
|
|
storeReminderInUserDefaults(
|
|
id: id,
|
|
title: title,
|
|
body: body,
|
|
time: time,
|
|
sound: sound,
|
|
vibration: vibration,
|
|
priority: priority,
|
|
repeatDaily: repeatDaily,
|
|
timezone: timezone
|
|
)
|
|
|
|
// Schedule the notification
|
|
notificationCenter.add(request) { error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
print("DNP-REMINDER: Failed to schedule reminder: \(error)")
|
|
call.reject("Failed to schedule daily reminder: \(error.localizedDescription)")
|
|
} else {
|
|
print("DNP-REMINDER: Daily reminder scheduled successfully: \(id)")
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func cancelDailyReminder(_ call: CAPPluginCall) {
|
|
guard let reminderId = call.getString("reminderId") else {
|
|
call.reject("Missing reminderId parameter")
|
|
return
|
|
}
|
|
|
|
print("DNP-REMINDER: Cancelling daily reminder: \(reminderId)")
|
|
|
|
// Cancel the notification
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
|
|
|
|
// Remove from UserDefaults
|
|
removeReminderFromUserDefaults(id: reminderId)
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func getScheduledReminders(_ call: CAPPluginCall) {
|
|
print("DNP-REMINDER: Getting scheduled reminders")
|
|
|
|
// Get pending notifications
|
|
notificationCenter.getPendingNotificationRequests { requests in
|
|
let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") }
|
|
|
|
// Get stored reminder data from UserDefaults
|
|
let reminders = self.getRemindersFromUserDefaults()
|
|
|
|
var result: [[String: Any]] = []
|
|
for reminder in reminders {
|
|
let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" }
|
|
var reminderInfo = reminder
|
|
reminderInfo["isScheduled"] = isScheduled
|
|
result.append(reminderInfo)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
call.resolve(["reminders": result])
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func updateDailyReminder(_ call: CAPPluginCall) {
|
|
guard let reminderId = call.getString("reminderId") else {
|
|
call.reject("Missing reminderId parameter")
|
|
return
|
|
}
|
|
|
|
print("DNP-REMINDER: Updating daily reminder: \(reminderId)")
|
|
|
|
// Cancel existing reminder
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"])
|
|
|
|
// Update in UserDefaults
|
|
let title = call.getString("title")
|
|
let body = call.getString("body")
|
|
let time = call.getString("time")
|
|
let sound = call.getBool("sound")
|
|
let vibration = call.getBool("vibration")
|
|
let priority = call.getString("priority")
|
|
let repeatDaily = call.getBool("repeatDaily")
|
|
let timezone = call.getString("timezone")
|
|
|
|
updateReminderInUserDefaults(
|
|
id: reminderId,
|
|
title: title,
|
|
body: body,
|
|
time: time,
|
|
sound: sound,
|
|
vibration: vibration,
|
|
priority: priority,
|
|
repeatDaily: repeatDaily,
|
|
timezone: timezone
|
|
)
|
|
|
|
// Reschedule with new settings if all required fields are provided
|
|
if let title = title, let body = body, let time = time {
|
|
// Parse time
|
|
let timeComponents = time.components(separatedBy: ":")
|
|
guard timeComponents.count == 2,
|
|
let hour = Int(timeComponents[0]),
|
|
let minute = Int(timeComponents[1]) else {
|
|
call.reject("Invalid time format")
|
|
return
|
|
}
|
|
|
|
// Create new notification content
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = (sound ?? true) ? .default : nil
|
|
content.categoryIdentifier = "DAILY_REMINDER"
|
|
|
|
// Set priority
|
|
let finalPriority = priority ?? "normal"
|
|
if #available(iOS 15.0, *) {
|
|
switch finalPriority {
|
|
case "high":
|
|
content.interruptionLevel = .critical
|
|
case "low":
|
|
content.interruptionLevel = .passive
|
|
default:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
|
|
// Create date components for daily trigger
|
|
var dateComponents = DateComponents()
|
|
dateComponents.hour = hour
|
|
dateComponents.minute = minute
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: repeatDaily ?? true
|
|
)
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "reminder_\(reminderId)",
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule the updated notification
|
|
notificationCenter.add(request) { error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)")
|
|
} else {
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
call.resolve()
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Methods for Reminder Storage
|
|
|
|
private func storeReminderInUserDefaults(
|
|
id: String,
|
|
title: String,
|
|
body: String,
|
|
time: String,
|
|
sound: Bool,
|
|
vibration: Bool,
|
|
priority: String,
|
|
repeatDaily: Bool,
|
|
timezone: String?
|
|
) {
|
|
let reminderData: [String: Any] = [
|
|
"id": id,
|
|
"title": title,
|
|
"body": body,
|
|
"time": time,
|
|
"sound": sound,
|
|
"vibration": vibration,
|
|
"priority": priority,
|
|
"repeatDaily": repeatDaily,
|
|
"timezone": timezone ?? "",
|
|
"createdAt": Date().timeIntervalSince1970,
|
|
"lastTriggered": 0
|
|
]
|
|
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
reminders.append(reminderData)
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
|
|
print("DNP-REMINDER: Reminder stored: \(id)")
|
|
}
|
|
|
|
private func removeReminderFromUserDefaults(id: String) {
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
reminders.removeAll { ($0["id"] as? String) == id }
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
|
|
print("DNP-REMINDER: Reminder removed: \(id)")
|
|
}
|
|
|
|
private func getRemindersFromUserDefaults() -> [[String: Any]] {
|
|
return UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
}
|
|
|
|
private func updateReminderInUserDefaults(
|
|
id: String,
|
|
title: String?,
|
|
body: String?,
|
|
time: String?,
|
|
sound: Bool?,
|
|
vibration: Bool?,
|
|
priority: String?,
|
|
repeatDaily: Bool?,
|
|
timezone: String?
|
|
) {
|
|
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
|
|
|
|
for i in 0..<reminders.count {
|
|
if reminders[i]["id"] as? String == id {
|
|
if let title = title { reminders[i]["title"] = title }
|
|
if let body = body { reminders[i]["body"] = body }
|
|
if let time = time { reminders[i]["time"] = time }
|
|
if let sound = sound { reminders[i]["sound"] = sound }
|
|
if let vibration = vibration { reminders[i]["vibration"] = vibration }
|
|
if let priority = priority { reminders[i]["priority"] = priority }
|
|
if let repeatDaily = repeatDaily { reminders[i]["repeatDaily"] = repeatDaily }
|
|
if let timezone = timezone { reminders[i]["timezone"] = timezone }
|
|
break
|
|
}
|
|
}
|
|
|
|
UserDefaults.standard.set(reminders, forKey: "daily_reminders")
|
|
print("DNP-REMINDER: Reminder updated: \(id)")
|
|
}
|
|
} |