Files
daily-notification-plugin/ios/Plugin/DailyNotificationScheduler.swift
Server 5844b92e18 feat(ios): implement Phase 1 permission methods and fix build issues
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
2025-11-13 05:14:24 -08:00

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)
}
}