Implement checkPermissionStatus() and requestNotificationPermissions() methods for iOS plugin, matching Android functionality. Fix compilation errors across plugin files and add comprehensive build/test infrastructure. Key Changes: - Add checkPermissionStatus() and requestNotificationPermissions() methods - Fix 13+ categories of Swift compilation errors (type conversions, logger API, access control, async/await, etc.) - Create DailyNotificationScheduler, DailyNotificationStorage, DailyNotificationStateActor, and DailyNotificationErrorCodes components - Fix CoreData initialization to handle missing model gracefully for Phase 1 - Add iOS test app build script with simulator auto-detection - Update directive with lessons learned from build and permission work Build Status: ✅ BUILD SUCCEEDED Test App: ✅ Ready for iOS Simulator testing Files Modified: - doc/directives/0003-iOS-Android-Parity-Directive.md (lessons learned) - ios/Plugin/DailyNotificationPlugin.swift (Phase 1 methods) - ios/Plugin/DailyNotificationModel.swift (CoreData fix) - 11+ other plugin files (compilation fixes) Files Added: - ios/Plugin/DailyNotificationScheduler.swift - ios/Plugin/DailyNotificationStorage.swift - ios/Plugin/DailyNotificationStateActor.swift - ios/Plugin/DailyNotificationErrorCodes.swift - scripts/build-ios-test-app.sh - scripts/setup-ios-test-app.sh - test-apps/ios-test-app/ (full test app) - Multiple Phase 1 documentation files
322 lines
10 KiB
Swift
322 lines
10 KiB
Swift
/**
|
|
* DailyNotificationScheduler.swift
|
|
*
|
|
* Handles scheduling and timing of daily notifications using UNUserNotificationCenter
|
|
* Implements calendar-based triggers with timing tolerance (±180s) and permission auto-healing
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
import Foundation
|
|
import UserNotifications
|
|
|
|
/**
|
|
* Manages scheduling of daily notifications using UNUserNotificationCenter
|
|
*
|
|
* This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline.
|
|
* It supports calendar-based triggers with iOS timing tolerance (±180s).
|
|
*/
|
|
class DailyNotificationScheduler {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let TAG = "DailyNotificationScheduler"
|
|
private static let NOTIFICATION_CATEGORY_ID = "DAILY_NOTIFICATION"
|
|
private static let TIMING_TOLERANCE_SECONDS: TimeInterval = 180 // ±180 seconds tolerance
|
|
|
|
// MARK: - Properties
|
|
|
|
private let notificationCenter: UNUserNotificationCenter
|
|
private var scheduledNotifications: Set<String> = []
|
|
private let schedulerQueue = DispatchQueue(label: "com.timesafari.dailynotification.scheduler", attributes: .concurrent)
|
|
|
|
// TTL enforcement
|
|
private weak var ttlEnforcer: DailyNotificationTTLEnforcer?
|
|
|
|
// MARK: - Initialization
|
|
|
|
/**
|
|
* Initialize scheduler
|
|
*/
|
|
init() {
|
|
self.notificationCenter = UNUserNotificationCenter.current()
|
|
setupNotificationCategory()
|
|
}
|
|
|
|
/**
|
|
* Set TTL enforcer for freshness validation
|
|
*
|
|
* @param ttlEnforcer TTL enforcement instance
|
|
*/
|
|
func setTTLEnforcer(_ ttlEnforcer: DailyNotificationTTLEnforcer) {
|
|
self.ttlEnforcer = ttlEnforcer
|
|
print("\(Self.TAG): TTL enforcer set for freshness validation")
|
|
}
|
|
|
|
// MARK: - Notification Category Setup
|
|
|
|
/**
|
|
* Setup notification category for actions
|
|
*/
|
|
private func setupNotificationCategory() {
|
|
let category = UNNotificationCategory(
|
|
identifier: Self.NOTIFICATION_CATEGORY_ID,
|
|
actions: [],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
)
|
|
|
|
notificationCenter.setNotificationCategories([category])
|
|
print("\(Self.TAG): Notification category setup complete")
|
|
}
|
|
|
|
// MARK: - Permission Management
|
|
|
|
/**
|
|
* Check notification permission status
|
|
*
|
|
* @return Authorization status
|
|
*/
|
|
func checkPermissionStatus() async -> UNAuthorizationStatus {
|
|
let settings = await notificationCenter.notificationSettings()
|
|
return settings.authorizationStatus
|
|
}
|
|
|
|
/**
|
|
* Request notification permissions
|
|
*
|
|
* @return true if permissions granted
|
|
*/
|
|
func requestPermissions() async -> Bool {
|
|
do {
|
|
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
|
print("\(Self.TAG): Permission request result: \(granted)")
|
|
return granted
|
|
} catch {
|
|
print("\(Self.TAG): Permission request failed: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-heal permissions: Check and request if needed
|
|
*
|
|
* @return Authorization status after auto-healing
|
|
*/
|
|
func autoHealPermissions() async -> UNAuthorizationStatus {
|
|
let status = await checkPermissionStatus()
|
|
|
|
switch status {
|
|
case .notDetermined:
|
|
// Request permissions
|
|
let granted = await requestPermissions()
|
|
return granted ? .authorized : .denied
|
|
case .denied:
|
|
// Cannot auto-heal denied permissions
|
|
return .denied
|
|
case .authorized, .provisional, .ephemeral:
|
|
return status
|
|
@unknown default:
|
|
return .notDetermined
|
|
}
|
|
}
|
|
|
|
// MARK: - Scheduling
|
|
|
|
/**
|
|
* Schedule a notification for delivery
|
|
*
|
|
* @param content Notification content to schedule
|
|
* @return true if scheduling was successful
|
|
*/
|
|
func scheduleNotification(_ content: NotificationContent) async -> Bool {
|
|
do {
|
|
print("\(Self.TAG): Scheduling notification: \(content.id)")
|
|
|
|
// Permission auto-healing
|
|
let permissionStatus = await autoHealPermissions()
|
|
if permissionStatus != .authorized && permissionStatus != .provisional {
|
|
print("\(Self.TAG): Notifications denied, cannot schedule")
|
|
// Log error code for debugging
|
|
print("\(Self.TAG): Error code: \(DailyNotificationErrorCodes.NOTIFICATIONS_DENIED)")
|
|
return false
|
|
}
|
|
|
|
// TTL validation before arming
|
|
if let ttlEnforcer = ttlEnforcer {
|
|
// TODO: Implement TTL validation
|
|
// For Phase 1, skip TTL validation (deferred to Phase 2)
|
|
}
|
|
|
|
// Cancel any existing notification for this ID
|
|
await cancelNotification(id: content.id)
|
|
|
|
// Create notification content
|
|
let notificationContent = UNMutableNotificationContent()
|
|
notificationContent.title = content.title ?? "Daily Update"
|
|
notificationContent.body = content.body ?? "Your daily notification is ready"
|
|
notificationContent.sound = .default
|
|
notificationContent.categoryIdentifier = Self.NOTIFICATION_CATEGORY_ID
|
|
notificationContent.userInfo = [
|
|
"notification_id": content.id,
|
|
"scheduled_time": content.scheduledTime,
|
|
"fetched_at": content.fetchedAt
|
|
]
|
|
|
|
// Create calendar trigger for daily scheduling
|
|
let scheduledDate = content.getScheduledTimeAsDate()
|
|
let calendar = Calendar.current
|
|
let dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledDate)
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: false
|
|
)
|
|
|
|
// Create notification request
|
|
let request = UNNotificationRequest(
|
|
identifier: content.id,
|
|
content: notificationContent,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule notification
|
|
try await notificationCenter.add(request)
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.insert(content.id)
|
|
}
|
|
|
|
print("\(Self.TAG): Notification scheduled successfully for \(scheduledDate)")
|
|
return true
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error scheduling notification: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a notification by ID
|
|
*
|
|
* @param id Notification ID
|
|
*/
|
|
func cancelNotification(id: String) async {
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: [id])
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.remove(id)
|
|
}
|
|
|
|
print("\(Self.TAG): Notification cancelled: \(id)")
|
|
}
|
|
|
|
/**
|
|
* Cancel all scheduled notifications
|
|
*/
|
|
func cancelAllNotifications() async {
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.removeAll()
|
|
}
|
|
|
|
print("\(Self.TAG): All notifications cancelled")
|
|
}
|
|
|
|
// MARK: - Status Queries
|
|
|
|
/**
|
|
* Get pending notification requests
|
|
*
|
|
* @return Array of pending notification identifiers
|
|
*/
|
|
func getPendingNotifications() async -> [String] {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.map { $0.identifier }
|
|
}
|
|
|
|
/**
|
|
* Get notification status
|
|
*
|
|
* @param id Notification ID
|
|
* @return true if notification is scheduled
|
|
*/
|
|
func isNotificationScheduled(id: String) async -> Bool {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.contains { $0.identifier == id }
|
|
}
|
|
|
|
/**
|
|
* Get count of pending notifications
|
|
*
|
|
* @return Count of pending notifications
|
|
*/
|
|
func getPendingNotificationCount() async -> Int {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.count
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* Calculate next occurrence of a daily time
|
|
*
|
|
* Matches Android calculateNextOccurrence() functionality
|
|
*
|
|
* @param hour Hour of day (0-23)
|
|
* @param minute Minute of hour (0-59)
|
|
* @return Timestamp in milliseconds of next occurrence
|
|
*/
|
|
func calculateNextOccurrence(hour: Int, minute: Int) -> Int64 {
|
|
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 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)
|
|
}
|
|
|
|
/**
|
|
* Get next notification time from pending notifications
|
|
*
|
|
* @return Timestamp in milliseconds of next notification or nil
|
|
*/
|
|
func getNextNotificationTime() async -> Int64? {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
|
|
guard let trigger = requests.first?.trigger as? UNCalendarNotificationTrigger,
|
|
let nextDate = trigger.nextTriggerDate() else {
|
|
return nil
|
|
}
|
|
|
|
return Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|
|
|