// // DailyNotificationModel.xcdatamodeld // DailyNotificationPlugin // // Created by Matthew Raymer on 2025-09-22 // Copyright © 2025 TimeSafari. All rights reserved. // import Foundation import CoreData /** * Core Data model for Daily Notification Plugin * Mirrors Android SQLite schema for cross-platform consistency * * @author Matthew Raymer * @version 1.1.0 * @created 2025-09-22 09:22:32 UTC */ // MARK: - ContentCache Entity @objc(ContentCache) public class ContentCache: NSManagedObject { } extension ContentCache { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "ContentCache") } @NSManaged public var id: String? @NSManaged public var fetchedAt: Date? @NSManaged public var ttlSeconds: Int32 @NSManaged public var payload: Data? @NSManaged public var meta: String? } extension ContentCache: Identifiable { } // MARK: - Schedule Entity @objc(Schedule) public class Schedule: NSManagedObject { } extension Schedule { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "Schedule") } @NSManaged public var id: String? @NSManaged public var kind: String? @NSManaged public var cron: String? @NSManaged public var clockTime: String? @NSManaged public var enabled: Bool @NSManaged public var lastRunAt: Date? @NSManaged public var nextRunAt: Date? @NSManaged public var jitterMs: Int32 @NSManaged public var backoffPolicy: String? @NSManaged public var stateJson: String? } extension Schedule: Identifiable { } // MARK: - Callback Entity @objc(Callback) public class Callback: NSManagedObject { } extension Callback { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "Callback") } @NSManaged public var id: String? @NSManaged public var kind: String? @NSManaged public var target: String? @NSManaged public var headersJson: String? @NSManaged public var enabled: Bool @NSManaged public var createdAt: Date? } extension Callback: Identifiable { } // MARK: - History Entity @objc(History) public class History: NSManagedObject { } extension History { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "History") } @NSManaged public var id: String? @NSManaged public var refId: String? @NSManaged public var kind: String? @NSManaged public var occurredAt: Date? @NSManaged public var durationMs: Int32 @NSManaged public var outcome: String? @NSManaged public var diagJson: String? } extension History: Identifiable { } // MARK: - NotificationContent Entity @objc(NotificationContentEntity) public class NotificationContentEntity: NSManagedObject { } extension NotificationContentEntity { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "NotificationContent") } @NSManaged public var id: String? @NSManaged public var pluginVersion: String? @NSManaged public var timesafariDid: String? @NSManaged public var notificationType: String? @NSManaged public var title: String? @NSManaged public var body: String? @NSManaged public var scheduledTime: Date? @NSManaged public var timezone: String? @NSManaged public var priority: Int32 @NSManaged public var vibrationEnabled: Bool @NSManaged public var soundEnabled: Bool @NSManaged public var mediaUrl: String? @NSManaged public var encryptedContent: String? @NSManaged public var encryptionKeyId: String? @NSManaged public var createdAt: Date? @NSManaged public var updatedAt: Date? @NSManaged public var ttlSeconds: Int64 @NSManaged public var deliveryStatus: String? @NSManaged public var deliveryAttempts: Int32 @NSManaged public var lastDeliveryAttempt: Date? @NSManaged public var userInteractionCount: Int32 @NSManaged public var lastUserInteraction: Date? @NSManaged public var metadata: String? } extension NotificationContentEntity: Identifiable { } // MARK: - NotificationDelivery Entity @objc(NotificationDelivery) public class NotificationDelivery: NSManagedObject { } extension NotificationDelivery { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "NotificationDelivery") } @NSManaged public var id: String? @NSManaged public var notificationId: String? @NSManaged public var notificationContent: NotificationContentEntity? @NSManaged public var timesafariDid: String? @NSManaged public var deliveryTimestamp: Date? @NSManaged public var deliveryStatus: String? @NSManaged public var deliveryMethod: String? @NSManaged public var deliveryAttemptNumber: Int32 @NSManaged public var deliveryDurationMs: Int64 @NSManaged public var userInteractionType: String? @NSManaged public var userInteractionTimestamp: Date? @NSManaged public var userInteractionDurationMs: Int64 @NSManaged public var errorCode: String? @NSManaged public var errorMessage: String? @NSManaged public var deviceInfo: String? @NSManaged public var networkInfo: String? @NSManaged public var batteryLevel: Int32 @NSManaged public var dozeModeActive: Bool @NSManaged public var exactAlarmPermission: Bool @NSManaged public var notificationPermission: Bool @NSManaged public var metadata: String? } extension NotificationDelivery: Identifiable { } // MARK: - NotificationConfig Entity @objc(NotificationConfig) public class NotificationConfig: NSManagedObject { } extension NotificationConfig { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "NotificationConfig") } @NSManaged public var id: String? @NSManaged public var timesafariDid: String? @NSManaged public var configType: String? @NSManaged public var configKey: String? @NSManaged public var configValue: String? @NSManaged public var configDataType: String? @NSManaged public var isEncrypted: Bool @NSManaged public var encryptionKeyId: String? @NSManaged public var createdAt: Date? @NSManaged public var updatedAt: Date? @NSManaged public var ttlSeconds: Int64 @NSManaged public var isActive: Bool @NSManaged public var metadata: String? } extension NotificationConfig: Identifiable { } // MARK: - Persistence Controller // Phase 2: CoreData integration for advanced features // All entities now available: ContentCache, Schedule, Callback, History, // NotificationContent, NotificationDelivery, NotificationConfig class PersistenceController { // MARK: - Schema Versioning /// Current schema version (incremented when schema changes) /// This is a logical contract for observability, not a migration gate. /// CoreData auto-migration remains authoritative. private static let SCHEMA_VERSION = 1 // Lazy initialization private static var _shared: PersistenceController? static var shared: PersistenceController { if _shared == nil { _shared = PersistenceController() } return _shared! } let container: NSPersistentContainer? private var initializationError: Error? init(inMemory: Bool = false) { var tempContainer: NSPersistentContainer? = nil do { tempContainer = NSPersistentContainer(name: "DailyNotificationModel") if inMemory { tempContainer?.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") } // Configure persistent store options let description = tempContainer?.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = true description?.shouldInferMappingModelAutomatically = true var loadError: Error? = nil tempContainer?.loadPersistentStores { storeDescription, error in if let error = error as NSError? { loadError = error print("DNP-PLUGIN: CoreData store load error: \(error.localizedDescription)") print("DNP-PLUGIN: Error domain: \(error.domain), code: \(error.code)") if let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String { print("DNP-PLUGIN: Failure reason: \(failureReason)") } } else { print("DNP-PLUGIN: CoreData store loaded successfully") print("DNP-PLUGIN: Store URL: \(storeDescription.url?.absoluteString ?? "unknown")") // Set initial schema version metadata (for new stores) // Metadata must be set using the coordinator after the store is loaded if !inMemory, let coordinator = tempContainer?.persistentStoreCoordinator, let store = coordinator.persistentStores.first, let metadata = store.metadata, metadata["schema_version"] == nil { var newMetadata = metadata newMetadata["schema_version"] = PersistenceController.SCHEMA_VERSION coordinator.setMetadata(newMetadata, for: store) } } } if let error = loadError { self.initializationError = error self.container = nil } else { // Configure view context if let context = tempContainer?.viewContext { context.automaticallyMergesChangesFromParent = true context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy } self.container = tempContainer // Check schema version (after container is initialized) checkSchemaVersion() // Verify all entities are available (after container is initialized) if let context = tempContainer?.viewContext { verifyEntities(in: context) } } } catch { print("DNP-PLUGIN: Failed to initialize CoreData container: \(error.localizedDescription)") self.initializationError = error self.container = nil } } /** * Check if CoreData is available */ var isAvailable: Bool { return container != nil && initializationError == nil } /** * Get the main view context * * @return NSManagedObjectContext or nil if not available */ var viewContext: NSManagedObjectContext? { return container?.viewContext } /** * Create a new background context for async operations * * @return NSManagedObjectContext or nil if not available */ func newBackgroundContext() -> NSManagedObjectContext? { return container?.newBackgroundContext() } /** * Save the view context * * @return true if saved successfully, false otherwise */ func save() -> Bool { guard let context = viewContext else { print("DNP-PLUGIN: Cannot save - CoreData not available") return false } if context.hasChanges { do { try context.save() print("DNP-PLUGIN: CoreData context saved successfully") return true } catch { print("DNP-PLUGIN: Error saving CoreData context: \(error.localizedDescription)") context.rollback() return false } } return true } /** * Check and log schema version * * Schema version is a logical contract, not a forced migration trigger. * CoreData auto-migration remains authoritative; version mismatches are * logged, not blocked. */ private func checkSchemaVersion() { guard let store = container?.persistentStoreCoordinator.persistentStores.first else { return } // store.metadata is optional, so we need to unwrap it guard let metadata = store.metadata else { print("DNP-PLUGIN: Store metadata is nil, using default schema version") return } let currentVersion = metadata["schema_version"] as? Int ?? 1 let expectedVersion = PersistenceController.SCHEMA_VERSION if currentVersion != expectedVersion { print("DNP-PLUGIN: Schema version mismatch - current: \(currentVersion), expected: \(expectedVersion)") print("DNP-PLUGIN: CoreData auto-migration will handle schema changes") // Update metadata for future reference (does not trigger migration) // Use the coordinator to set metadata if let coordinator = container?.persistentStoreCoordinator { var newMetadata = metadata newMetadata["schema_version"] = expectedVersion coordinator.setMetadata(newMetadata, for: store) // Note: Metadata persists on next store save } } else { print("DNP-PLUGIN: Schema version verified: \(currentVersion)") } } /** * Verify all entities are available in the model * * @param context Managed object context */ private func verifyEntities(in context: NSManagedObjectContext) { guard let model = context.persistentStoreCoordinator?.managedObjectModel else { print("DNP-PLUGIN: Cannot verify entities - no managed object model") return } let entityNames = [ "ContentCache", "Schedule", "Callback", "History", "NotificationContent", "NotificationDelivery", "NotificationConfig" ] var missingEntities: [String] = [] for entityName in entityNames { if model.entitiesByName[entityName] == nil { missingEntities.append(entityName) } } if missingEntities.isEmpty { print("DNP-PLUGIN: All \(entityNames.count) entities verified in CoreData model") } else { print("DNP-PLUGIN: WARNING - Missing entities: \(missingEntities.joined(separator: ", "))") } } }