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
This commit is contained in:
321
ios/Plugin/DailyNotificationScheduler.swift
Normal file
321
ios/Plugin/DailyNotificationScheduler.swift
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user