feat(ios): add Core Data DAO layer and unit tests

Implement comprehensive data access layer for Core Data entities:

- Add NotificationContentDAO, NotificationDeliveryDAO, and NotificationConfigDAO
  with full CRUD operations and query helpers
- Add DailyNotificationDataConversions utility for type conversions
  (Date ↔ Int64, Int ↔ Int32, JSON, optional strings)
- Update PersistenceController with entity verification and migration policies
- Add comprehensive unit tests for all DAO classes and data conversions
- Update Core Data model with NotificationContent, NotificationDelivery,
  and NotificationConfig entities (relationships and indexes)
- Integrate ReactivationManager into DailyNotificationPlugin.load()

DAO Features:
- Create/Insert methods with dictionary support
- Read/Query methods with predicates (by timesafariDid, notificationType,
  scheduledTime range, deliveryStatus, etc.)
- Update methods (touch, updateDeliveryStatus, recordUserInteraction)
- Delete methods (by ID, by key, delete all)
- Relationship management (NotificationContent ↔ NotificationDelivery)
- Cascade delete support

Test Coverage:
- 328 lines: DailyNotificationDataConversionsTests (time, numeric, string, JSON)
- 490 lines: NotificationContentDAOTests (CRUD, queries, updates)
- 415 lines: NotificationDeliveryDAOTests (CRUD, relationships, cascade delete)
- 412 lines: NotificationConfigDAOTests (CRUD, queries, active filtering)

All tests use in-memory Core Data stack for isolation and speed.

Completes sections 4.4, 4.5, and 6.0 of iOS implementation checklist.
This commit is contained in:
Matthew
2025-12-09 02:23:05 -08:00
parent dd8d67462f
commit a90d08c425
14 changed files with 5201 additions and 9 deletions

View File

@@ -0,0 +1,194 @@
/**
* DailyNotificationDataConversions.swift
*
* Data type conversion helpers for Core Data operations
* Handles conversions between Swift types and Core Data types,
* especially for time (Date Long/Int64) and numeric types.
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-12-08
*/
import Foundation
import CoreData
/**
* Data conversion utilities for Core Data operations
*
* This module provides helper functions for converting between:
* - Date Int64 (epoch milliseconds)
* - Int Int32
* - Long Int64
* - Optional string handling
*/
class DailyNotificationDataConversions {
// MARK: - Constants
private static let TAG = "DNP-DATA-CONVERSIONS"
// MARK: - Time Conversions (Section 6.1)
/**
* Convert epoch milliseconds (Int64) to Date
*
* @param epochMillis Milliseconds since epoch (1970-01-01 00:00:00 UTC)
* @return Date object
*/
static func dateFromEpochMillis(_ epochMillis: Int64) -> Date {
return Date(timeIntervalSince1970: Double(epochMillis) / 1000.0)
}
/**
* Convert Date to epoch milliseconds (Int64)
*
* @param date Date object
* @return Milliseconds since epoch (1970-01-01 00:00:00 UTC)
*/
static func epochMillisFromDate(_ date: Date) -> Int64 {
return Int64(date.timeIntervalSince1970 * 1000.0)
}
/**
* Convert optional epoch milliseconds to optional Date
*
* @param epochMillis Optional milliseconds since epoch
* @return Optional Date object
*/
static func dateFromEpochMillis(_ epochMillis: Int64?) -> Date? {
guard let millis = epochMillis else { return nil }
return dateFromEpochMillis(millis)
}
/**
* Convert optional Date to optional epoch milliseconds
*
* @param date Optional Date object
* @return Optional milliseconds since epoch
*/
static func epochMillisFromDate(_ date: Date?) -> Int64? {
guard let dateValue = date else { return nil }
return epochMillisFromDate(dateValue)
}
// MARK: - Numeric Conversions (Section 6.2)
/**
* Convert Int to Int32 (for Core Data Integer 32)
*
* @param value Int value
* @return Int32 value
*/
static func int32FromInt(_ value: Int) -> Int32 {
return Int32(value)
}
/**
* Convert Int32 to Int
*
* @param value Int32 value
* @return Int value
*/
static func intFromInt32(_ value: Int32) -> Int {
return Int(value)
}
/**
* Convert Int64 to Int32 (with clamping if needed)
*
* @param value Int64 value
* @return Int32 value (clamped if out of range)
*/
static func int32FromInt64(_ value: Int64) -> Int32 {
if value > Int64(Int32.max) {
return Int32.max
} else if value < Int64(Int32.min) {
return Int32.min
}
return Int32(value)
}
/**
* Convert Int32 to Int64
*
* @param value Int32 value
* @return Int64 value
*/
static func int64FromInt32(_ value: Int32) -> Int64 {
return Int64(value)
}
/**
* Convert Long (Int64) to Int64 (no-op, but explicit)
*
* @param value Int64 value
* @return Int64 value
*/
static func int64FromLong(_ value: Int64) -> Int64 {
return value
}
/**
* Convert Boolean to Bool (direct, but explicit)
*
* @param value Boolean value
* @return Bool value
*/
static func boolFromBoolean(_ value: Bool) -> Bool {
return value
}
// MARK: - String Conversions (Section 6.3)
/**
* Safely convert optional String to String
*
* @param value Optional String
* @return String (empty string if nil)
*/
static func stringFromOptional(_ value: String?) -> String {
return value ?? ""
}
/**
* Safely convert String to optional String
*
* @param value String value
* @return Optional String (nil if empty)
*/
static func optionalStringFromString(_ value: String) -> String? {
return value.isEmpty ? nil : value
}
/**
* Convert JSON dictionary to JSON string
*
* @param dict Dictionary to encode
* @return JSON string or nil if encoding fails
*/
static func jsonStringFromDictionary(_ dict: [String: Any]?) -> String? {
guard let dict = dict else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: dict),
let jsonString = String(data: data, encoding: .utf8) else {
return nil
}
return jsonString
}
/**
* Convert JSON string to dictionary
*
* @param jsonString JSON string to decode
* @return Dictionary or nil if decoding fails
*/
static func dictionaryFromJsonString(_ jsonString: String?) -> [String: Any]? {
guard let jsonString = jsonString,
let data = jsonString.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return dict
}
}

View File

@@ -116,9 +116,10 @@ extension History: Identifiable {
// MARK: - Persistence Controller
// Phase 2: CoreData integration for advanced features
// Phase 1: Stubbed out - CoreData model not yet created
// All entities now available: ContentCache, Schedule, Callback, History,
// NotificationContent, NotificationDelivery, NotificationConfig
class PersistenceController {
// Lazy initialization to prevent Phase 1 errors
// Lazy initialization
private static var _shared: PersistenceController?
static var shared: PersistenceController {
if _shared == nil {
@@ -131,8 +132,6 @@ class PersistenceController {
private var initializationError: Error?
init(inMemory: Bool = false) {
// Phase 1: CoreData model doesn't exist yet, so we'll handle gracefully
// Phase 2: Will create DailyNotificationModel.xcdatamodeld
var tempContainer: NSPersistentContainer? = nil
do {
@@ -142,12 +141,23 @@ class PersistenceController {
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 { _, error in
tempContainer?.loadPersistentStores { description, error in
if let error = error as NSError? {
loadError = error
print("DNP-PLUGIN: CoreData model not found (Phase 1 - expected). Error: \(error.localizedDescription)")
print("DNP-PLUGIN: CoreData features will be available in Phase 2")
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: \(description.url?.absoluteString ?? "unknown")")
}
}
@@ -155,7 +165,14 @@ class PersistenceController {
self.initializationError = error
self.container = nil
} else {
tempContainer?.viewContext.automaticallyMergesChangesFromParent = true
// Configure view context
if let context = tempContainer?.viewContext {
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Verify all entities are available
verifyEntities(in: context)
}
self.container = tempContainer
}
} catch {
@@ -166,10 +183,88 @@ class PersistenceController {
}
/**
* Check if CoreData is available (Phase 2+)
* 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
}
/**
* 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: ", "))")
}
}
}

View File

@@ -36,4 +36,92 @@
<attribute name="nextRunAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="stateJson" optional="YES" attributeType="String"/>
</entity>
<entity name="NotificationContent" representedClassName="NotificationContent" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="NO" attributeType="String"/>
<attribute name="pluginVersion" optional="YES" attributeType="String"/>
<attribute name="timesafariDid" optional="YES" attributeType="String"/>
<attribute name="notificationType" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<attribute name="body" optional="YES" attributeType="String"/>
<attribute name="scheduledTime" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="timezone" optional="YES" attributeType="String"/>
<attribute name="priority" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="vibrationEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="soundEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="mediaUrl" optional="YES" attributeType="String"/>
<attribute name="encryptedContent" optional="YES" attributeType="String"/>
<attribute name="encryptionKeyId" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="ttlSeconds" optional="YES" attributeType="Integer 64" defaultValueString="604800" usesScalarValueType="YES"/>
<attribute name="deliveryStatus" optional="YES" attributeType="String"/>
<attribute name="deliveryAttempts" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastDeliveryAttempt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userInteractionCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUserInteraction" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="metadata" optional="YES" attributeType="String"/>
<relationship name="deliveries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="NotificationDelivery" inverseName="notificationContent" inverseEntity="NotificationDelivery"/>
<index name="index_notification_content_timesafari_did">
<indexElement value="timesafariDid"/>
</index>
<index name="index_notification_content_notification_type">
<indexElement value="notificationType"/>
</index>
<index name="index_notification_content_scheduled_time">
<indexElement value="scheduledTime"/>
</index>
</entity>
<entity name="NotificationDelivery" representedClassName="NotificationDelivery" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="NO" attributeType="String"/>
<attribute name="notificationId" optional="YES" attributeType="String"/>
<attribute name="timesafariDid" optional="YES" attributeType="String"/>
<attribute name="deliveryTimestamp" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deliveryStatus" optional="YES" attributeType="String"/>
<attribute name="deliveryMethod" optional="YES" attributeType="String"/>
<attribute name="deliveryAttemptNumber" optional="YES" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="deliveryDurationMs" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="userInteractionType" optional="YES" attributeType="String"/>
<attribute name="userInteractionTimestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userInteractionDurationMs" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="errorCode" optional="YES" attributeType="String"/>
<attribute name="errorMessage" optional="YES" attributeType="String"/>
<attribute name="deviceInfo" optional="YES" attributeType="String"/>
<attribute name="networkInfo" optional="YES" attributeType="String"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="-1" usesScalarValueType="YES"/>
<attribute name="dozeModeActive" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="exactAlarmPermission" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="notificationPermission" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="metadata" optional="YES" attributeType="String"/>
<relationship name="notificationContent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NotificationContent" inverseName="deliveries" inverseEntity="NotificationContent"/>
<index name="index_notification_delivery_notification_id">
<indexElement value="notificationId"/>
</index>
<index name="index_notification_delivery_delivery_timestamp">
<indexElement value="deliveryTimestamp"/>
</index>
</entity>
<entity name="NotificationConfig" representedClassName="NotificationConfig" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="NO" attributeType="String"/>
<attribute name="timesafariDid" optional="YES" attributeType="String"/>
<attribute name="configType" optional="YES" attributeType="String"/>
<attribute name="configKey" optional="YES" attributeType="String"/>
<attribute name="configValue" optional="YES" attributeType="String"/>
<attribute name="configDataType" optional="YES" attributeType="String"/>
<attribute name="isEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="encryptionKeyId" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="NO" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="ttlSeconds" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isActive" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="metadata" optional="YES" attributeType="String"/>
<index name="index_notification_config_config_key">
<indexElement value="configKey"/>
</index>
<index name="index_notification_config_config_type">
<indexElement value="configType"/>
</index>
<index name="index_notification_config_timesafari_did">
<indexElement value="timesafariDid"/>
</index>
</entity>
</model>

View File

@@ -35,6 +35,9 @@ public class DailyNotificationPlugin: CAPPlugin {
var storage: DailyNotificationStorage?
var scheduler: DailyNotificationScheduler?
// Phase 1: Reactivation manager for recovery
var reactivationManager: DailyNotificationReactivationManager?
// Phase 1: Concurrency actor for thread-safe state access
@available(iOS 13.0, *)
var stateActor: DailyNotificationStateActor?
@@ -51,6 +54,13 @@ public class DailyNotificationPlugin: CAPPlugin {
storage = DailyNotificationStorage(databasePath: database.getPath())
scheduler = DailyNotificationScheduler()
// Initialize reactivation manager for recovery
reactivationManager = DailyNotificationReactivationManager(
database: database,
storage: storage!,
scheduler: scheduler!
)
// Initialize state actor for thread-safe access
if #available(iOS 13.0, *) {
stateActor = DailyNotificationStateActor(
@@ -59,6 +69,9 @@ public class DailyNotificationPlugin: CAPPlugin {
)
}
// Perform recovery on app launch (async, non-blocking)
reactivationManager?.performRecovery()
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
}
@@ -1259,6 +1272,202 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
// MARK: - iOS-Specific Methods
/**
* Get notification permission status (iOS-specific)
*
* Returns detailed permission status matching API.md specification
*
* @param call Plugin call
*/
@objc func getNotificationPermissionStatus(_ call: CAPPluginCall) {
Task {
do {
guard let scheduler = scheduler else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
}
let status = await scheduler.checkPermissionStatus()
let result: [String: Any] = [
"authorized": status == .authorized,
"denied": status == .denied,
"notDetermined": status == .notDetermined,
"provisional": status == .provisional
]
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
DispatchQueue.main.async {
call.reject("Failed to get permission status: \(error.localizedDescription)", "permission_status_failed")
}
}
}
}
/**
* Request notification permission (iOS-specific)
*
* @param call Plugin call
*/
@objc func requestNotificationPermission(_ call: CAPPluginCall) {
Task {
do {
guard let scheduler = scheduler else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Scheduler not initialized"])
}
let granted = await scheduler.requestPermissions()
let result: [String: Any] = [
"granted": granted
]
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
DispatchQueue.main.async {
call.reject("Failed to request permission: \(error.localizedDescription)", "permission_request_failed")
}
}
}
}
/**
* Get pending notifications (iOS-specific)
*
* @param call Plugin call
*/
@objc func getPendingNotifications(_ call: CAPPluginCall) {
Task {
do {
let requests = try await notificationCenter.pendingNotificationRequests()
var notifications: [[String: Any]] = []
for request in requests {
let content = request.content
var triggerDate: Int64 = 0
if let calendarTrigger = request.trigger as? UNCalendarNotificationTrigger {
if let nextDate = calendarTrigger.nextTriggerDate() {
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
}
} else if let timeIntervalTrigger = request.trigger as? UNTimeIntervalNotificationTrigger {
if let nextDate = timeIntervalTrigger.nextTriggerDate() {
triggerDate = Int64(nextDate.timeIntervalSince1970 * 1000)
}
}
let notification: [String: Any] = [
"identifier": request.identifier,
"title": content.title,
"body": content.body,
"triggerDate": triggerDate,
"triggerType": request.trigger is UNCalendarNotificationTrigger ? "calendar" : (request.trigger is UNTimeIntervalNotificationTrigger ? "timeInterval" : "location"),
"repeats": request.trigger?.repeats ?? false
]
notifications.append(notification)
}
let result: [String: Any] = [
"count": notifications.count,
"notifications": notifications
]
DispatchQueue.main.async {
call.resolve(result)
}
} catch {
DispatchQueue.main.async {
call.reject("Failed to get pending notifications: \(error.localizedDescription)", "pending_notifications_failed")
}
}
}
}
/**
* Get background task status (iOS-specific)
*
* @param call Plugin call
*/
@objc func getBackgroundTaskStatus(_ call: CAPPluginCall) {
let registeredIdentifiers = backgroundTaskScheduler.registeredTaskIdentifiers
let fetchTaskRegistered = registeredIdentifiers.contains(fetchTaskIdentifier)
let notifyTaskRegistered = registeredIdentifiers.contains(notifyTaskIdentifier)
// Note: Background App Refresh status cannot be checked programmatically
// User must check in Settings app
let result: [String: Any] = [
"fetchTaskRegistered": fetchTaskRegistered,
"notifyTaskRegistered": notifyTaskRegistered,
"lastFetchExecution": storage?.getLastSuccessfulRun() ?? NSNull(),
"lastNotifyExecution": NSNull(), // TODO: Track notify execution
"backgroundRefreshEnabled": NSNull() // Cannot check programmatically
]
call.resolve(result)
}
/**
* Open notification settings (iOS-specific)
*
* @param call Plugin call
*/
@objc func openNotificationSettings(_ call: CAPPluginCall) {
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open notification settings", "open_settings_failed")
}
}
}
} else {
call.reject("Cannot open settings URL", "open_settings_failed")
}
} else {
call.reject("Invalid settings URL", "open_settings_failed")
}
}
/**
* Open Background App Refresh settings (iOS-specific)
*
* Note: iOS doesn't provide a direct URL to Background App Refresh settings.
* This opens the app's settings page where user can find Background App Refresh.
*
* @param call Plugin call
*/
@objc func openBackgroundAppRefreshSettings(_ call: CAPPluginCall) {
// iOS doesn't have a direct URL to Background App Refresh settings
// Open app settings instead, where user can find Background App Refresh
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl) { success in
DispatchQueue.main.async {
if success {
call.resolve()
} else {
call.reject("Failed to open settings", "open_settings_failed")
}
}
}
} else {
call.reject("Cannot open settings URL", "open_settings_failed")
}
} else {
call.reject("Invalid settings URL", "open_settings_failed")
}
}
// MARK: - Channel Methods (iOS Parity with Android)
/**
@@ -1494,6 +1703,14 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise))
// iOS-specific methods
methods.append(CAPPluginMethod(name: "getNotificationPermissionStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "requestNotificationPermission", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getPendingNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getBackgroundTaskStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openNotificationSettings", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openBackgroundAppRefreshSettings", returnType: CAPPluginReturnPromise))
// Channel methods (iOS parity with Android)
methods.append(CAPPluginMethod(name: "isChannelEnabled", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "openChannelSettings", returnType: CAPPluginReturnPromise))

View File

@@ -0,0 +1,738 @@
//
// DailyNotificationReactivationManager.swift
// DailyNotificationPlugin
//
// Created by Matthew Raymer on 2025-12-08
// Copyright © 2025 TimeSafari. All rights reserved.
//
import Foundation
import UserNotifications
import BackgroundTasks
/**
* Manages recovery of notifications on app launch
* Phase 1: Cold start recovery only
*
* Implements:
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start) (iOS equivalent)
* Platform Reference: [iOS §3.1.1](../docs/alarms/01-platform-capability-reference.md#311-notifications-survive-app-termination)
*
* @author Matthew Raymer
* @version 1.0.0 - Phase 1: Cold start recovery
*/
class DailyNotificationReactivationManager {
// MARK: - Constants
private static let TAG = "DNP-REACTIVATION"
private static let RECOVERY_TIMEOUT_SECONDS: TimeInterval = 2.0
private static let LAST_LAUNCH_TIME_KEY = "DNP_LAST_LAUNCH_TIME"
private static let BOOT_DETECTION_THRESHOLD_SECONDS: TimeInterval = 60.0 // 1 minute
// MARK: - Properties
private let notificationCenter: UNUserNotificationCenter
private let database: DailyNotificationDatabase
private let storage: DailyNotificationStorage
private let scheduler: DailyNotificationScheduler
// MARK: - Initialization
/**
* Initialize reactivation manager
*
* @param database Database instance for querying schedules and notifications
* @param storage Storage instance for accessing notification content
* @param scheduler Scheduler instance for rescheduling notifications
*/
init(database: DailyNotificationDatabase,
storage: DailyNotificationStorage,
scheduler: DailyNotificationScheduler) {
self.notificationCenter = UNUserNotificationCenter.current()
self.database = database
self.storage = storage
self.scheduler = scheduler
NSLog("\(Self.TAG): ReactivationManager initialized")
}
// MARK: - Recovery Execution
/**
* Perform recovery on app launch
* Phase 3: Includes boot detection and recovery
*
* Scenario detection implemented:
* - .none: Empty database (first launch)
* - .coldStart: Notifications exist, may need verification
* - .warmStart: Notifications match DB state (optimization, no recovery)
* - .termination: App terminated, notifications cleared
*
* Phase 3: Boot detection added
*
* Runs asynchronously with timeout to avoid blocking app startup
*
* Rollback Safety: If recovery fails, app continues normally
*/
func performRecovery() {
Task {
do {
try await withTimeout(seconds: Self.RECOVERY_TIMEOUT_SECONDS) {
NSLog("\(Self.TAG): Starting app launch recovery")
// Phase 3: Check for boot scenario first
let isBoot = detectBootScenario()
if isBoot {
NSLog("\(Self.TAG): Boot scenario detected - performing boot recovery")
let result = try await performBootRecovery()
NSLog("\(Self.TAG): Boot recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
// Update last launch time after boot recovery
updateLastLaunchTime()
return
}
// Step 1: Detect scenario
let scenario = try await detectScenario()
NSLog("\(Self.TAG): Detected scenario: \(scenario.rawValue)")
// Step 2: Handle based on scenario
switch scenario {
case .none:
NSLog("\(Self.TAG): No recovery needed (first launch or no notifications)")
updateLastLaunchTime()
return
case .warmStart:
NSLog("\(Self.TAG): Warm start detected - no recovery needed")
updateLastLaunchTime()
return
case .coldStart:
let result = try await performColdStartRecovery()
NSLog("\(Self.TAG): App launch recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
updateLastLaunchTime()
case .termination:
// Phase 2: Termination recovery
NSLog("\(Self.TAG): Termination scenario detected - performing full recovery")
let result = try await handleTerminationRecovery()
NSLog("\(Self.TAG): Termination recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
updateLastLaunchTime()
}
}
} catch is TimeoutError {
NSLog("\(Self.TAG): Recovery timed out after \(Self.RECOVERY_TIMEOUT_SECONDS) seconds (non-fatal)")
} catch {
// Rollback: Log error but don't crash
NSLog("\(Self.TAG): Recovery failed (non-fatal): \(error.localizedDescription)")
// Record failure in history (best effort, don't fail if this fails)
do {
try await recordRecoveryFailure(error)
} catch {
NSLog("\(Self.TAG): Failed to record recovery failure in history")
}
}
}
}
// MARK: - Scenario Detection
/**
* Detect recovery scenario
*
* Phase 1: Basic scenario detection
* - .none: Empty database (first launch)
* - .coldStart: Notifications exist, may need verification
* - .warmStart: Notifications match DB state
*
* Phase 2: Will add termination detection
*
* @return RecoveryScenario
*
* Note: Internal for testing
*/
internal func detectScenario() async throws -> RecoveryScenario {
// Step 1: Check if database has notifications
let allNotifications = storage.getAllNotifications()
if allNotifications.isEmpty {
return .none // First launch
}
// Step 2: Get pending notifications from UNUserNotificationCenter
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Get notification IDs from storage
let dbIds = Set(allNotifications.map { $0.id })
// Step 4: Determine scenario
if pendingIds.isEmpty && !dbIds.isEmpty {
// DB has notifications but no notifications scheduled
// Phase 2: This indicates termination (system cleared notifications)
return .termination
} else if !pendingIds.isEmpty && !dbIds.isEmpty {
// Both have data - check if they match
if dbIds == pendingIds {
return .warmStart // Match indicates warm resume
} else {
return .coldStart // Mismatch indicates recovery needed
}
}
// Default: no recovery needed
return .none
}
// MARK: - Cold Start Recovery
/**
* Perform cold start recovery
*
* Steps:
* 1. Detect missed notifications (scheduled_time < now, not delivered)
* 2. Mark missed notifications in database
* 3. Verify future notifications are scheduled
* 4. Reschedule missing future notifications
*
* @return RecoveryResult with counts
*/
private func performColdStartRecovery() async throws -> RecoveryResult {
let currentTime = Date()
NSLog("\(Self.TAG): Cold start recovery: checking for missed notifications")
// Step 1: Detect missed notifications
let missedNotifications = try await detectMissedNotifications(currentTime: currentTime)
var missedCount = 0
var missedErrors = 0
// Step 2: Mark missed notifications
for notification in missedNotifications {
do {
// Data integrity check: verify notification is valid
if notification.id.isEmpty {
NSLog("\(Self.TAG): Skipping invalid notification: empty ID")
continue
}
try await markMissedNotification(notification)
missedCount += 1
NSLog("\(Self.TAG): Marked missed notification: \(notification.id)")
} catch {
missedErrors += 1
NSLog("\(Self.TAG): Failed to mark missed notification \(notification.id): \(error.localizedDescription)")
}
}
// Step 3: Verify future notifications
let verificationResult = try await verifyFutureNotifications()
var rescheduledCount = 0
var rescheduleErrors = 0
// Step 4: Reschedule missing notifications
if !verificationResult.missingIds.isEmpty {
NSLog("\(Self.TAG): Found \(verificationResult.missingIds.count) missing notifications, rescheduling...")
for missingId in verificationResult.missingIds {
do {
// Reschedule using scheduler
// Note: For Phase 1, we'll need to get the notification content from storage
// and reschedule it. This may need to be enhanced in Phase 2.
try await rescheduleMissingNotification(id: missingId)
rescheduledCount += 1
NSLog("\(Self.TAG): Rescheduled missing notification: \(missingId)")
} catch {
rescheduleErrors += 1
NSLog("\(Self.TAG): Failed to reschedule notification \(missingId): \(error.localizedDescription)")
}
}
}
// Record recovery in history
let result = RecoveryResult(
missedCount: missedCount,
rescheduledCount: rescheduledCount,
verifiedCount: verificationResult.notificationsFound,
errors: missedErrors + rescheduleErrors
)
try await recordRecoveryHistory(result, scenario: .coldStart)
return result
}
// MARK: - Missed Notification Detection
/**
* Detect missed notifications
*
* @param currentTime Current time for comparison
* @return Array of missed notifications
*
* Note: Internal for testing
*/
internal func detectMissedNotifications(currentTime: Date) async throws -> [NotificationContent] {
// Get all notifications from storage
let allNotifications = storage.getAllNotifications()
// Convert currentTime to milliseconds (Int64) for comparison
let currentTimeMs = Int64(currentTime.timeIntervalSince1970 * 1000)
// Filter for missed notifications:
// - scheduled_time < currentTime
// - delivery_status != 'delivered' (if deliveryStatus property exists)
// Note: For Phase 1, we'll check if notification is past scheduled time
// In Phase 2, we'll add deliveryStatus tracking
let missed = allNotifications.filter { notification in
notification.scheduledTime < currentTimeMs
// TODO: Add deliveryStatus check when property is added to NotificationContent
}
NSLog("\(Self.TAG): Detected \(missed.count) missed notifications")
return missed
}
/**
* Mark notification as missed
*
* @param notification Notification to mark as missed
*/
private func markMissedNotification(_ notification: NotificationContent) async throws {
// Note: NotificationContent doesn't have deliveryStatus property yet
// For Phase 1, we'll save the notification with updated metadata
// In Phase 2, we'll add deliveryStatus tracking to NotificationContent
// Save to storage (notification already exists, this updates it)
storage.saveNotificationContent(notification)
// Record in history (if history table exists)
// Note: History recording may need to be implemented based on database structure
NSLog("\(Self.TAG): Marked notification \(notification.id) as missed")
// TODO: Add deliveryStatus property to NotificationContent in Phase 2
// TODO: Add lastDeliveryAttempt property to NotificationContent in Phase 2
}
// MARK: - Future Notification Verification
/**
* Verify future notifications are scheduled
*
* @return VerificationResult with comparison details
*
* Note: Internal for testing
*/
internal func verifyFutureNotifications() async throws -> VerificationResult {
// Get pending notifications from UNUserNotificationCenter
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Get all notifications from storage that are scheduled for future
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
let allNotifications = storage.getAllNotifications()
let futureNotifications = allNotifications.filter { $0.scheduledTime >= currentTimeMs }
let futureIds = Set(futureNotifications.map { $0.id })
// Compare and find missing
let missingIds = Array(futureIds.subtracting(pendingIds))
NSLog("\(Self.TAG): Verification: total=\(futureNotifications.count), found=\(pendingIds.count), missing=\(missingIds.count)")
return VerificationResult(
totalSchedules: futureNotifications.count,
notificationsFound: pendingIds.count,
notificationsMissing: missingIds.count,
missingIds: missingIds
)
}
/**
* Reschedule missing notification
*
* @param id Notification ID to reschedule
*/
private func rescheduleMissingNotification(id: String) async throws {
// Get notification content from storage
guard let notification = storage.getNotificationContent(id: id) else {
throw ReactivationError.notificationNotFound(id: id)
}
// Reschedule using scheduler
let success = await scheduler.scheduleNotification(notification)
if !success {
throw ReactivationError.rescheduleFailed(id: id)
}
}
// MARK: - Phase 2: Termination Recovery
/**
* Handle termination recovery
*
* Phase 2: Comprehensive recovery when app was terminated by system
* and notifications were cleared.
*
* Steps:
* 1. Detect all missed notifications (past scheduled times)
* 2. Mark all as missed
* 3. Reschedule all future notifications
* 4. Reschedule all fetch schedules (if applicable)
*
* @return RecoveryResult with counts
*/
private func handleTerminationRecovery() async throws -> RecoveryResult {
NSLog("\(Self.TAG): Handling termination recovery - comprehensive recovery")
// Use full recovery which handles both notify and fetch schedules
return try await performFullRecovery()
}
/**
* Perform full recovery
*
* Phase 2: Comprehensive recovery that handles:
* - All missed notifications (past scheduled times)
* - All future notifications (reschedule if missing)
* - All fetch schedules (reschedule if needed)
* - Multiple schedules with batch operations
*
* @return RecoveryResult with comprehensive counts
*/
private func performFullRecovery() async throws -> RecoveryResult {
let currentTime = Date()
let currentTimeMs = Int64(currentTime.timeIntervalSince1970 * 1000)
NSLog("\(Self.TAG): Performing full recovery")
// Step 1: Get all notifications from storage
let allNotifications = storage.getAllNotifications()
if allNotifications.isEmpty {
NSLog("\(Self.TAG): No notifications to recover")
return RecoveryResult(missedCount: 0, rescheduledCount: 0, verifiedCount: 0, errors: 0)
}
NSLog("\(Self.TAG): Processing \(allNotifications.count) notifications")
// Step 2: Get pending notifications once (batch operation)
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Separate missed and future notifications (batch processing)
var missedNotifications: [NotificationContent] = []
var futureNotifications: [NotificationContent] = []
for notification in allNotifications {
if notification.scheduledTime < currentTimeMs {
missedNotifications.append(notification)
} else {
futureNotifications.append(notification)
}
}
NSLog("\(Self.TAG): Found \(missedNotifications.count) missed and \(futureNotifications.count) future notifications")
// Step 4: Process missed notifications (batch)
var missedCount = 0
var missedErrors = 0
for notification in missedNotifications {
do {
try await markMissedNotification(notification)
missedCount += 1
} catch {
missedErrors += 1
NSLog("\(Self.TAG): Failed to mark missed notification \(notification.id): \(error.localizedDescription)")
}
}
// Step 5: Process future notifications (batch verification)
var rescheduledCount = 0
var rescheduleErrors = 0
var missingFutureIds: [String] = []
for notification in futureNotifications {
if !pendingIds.contains(notification.id) {
missingFutureIds.append(notification.id)
}
}
// Step 6: Reschedule missing future notifications (batch)
if !missingFutureIds.isEmpty {
NSLog("\(Self.TAG): Rescheduling \(missingFutureIds.count) missing future notifications...")
for missingId in missingFutureIds {
do {
try await rescheduleMissingNotification(id: missingId)
rescheduledCount += 1
} catch {
rescheduleErrors += 1
NSLog("\(Self.TAG): Failed to reschedule notification \(missingId): \(error.localizedDescription)")
}
}
}
// Step 7: Verify final state
let verificationResult = try await verifyFutureNotifications()
// Record recovery in history
let result = RecoveryResult(
missedCount: missedCount,
rescheduledCount: rescheduledCount,
verifiedCount: verificationResult.notificationsFound,
errors: missedErrors + rescheduleErrors
)
try await recordRecoveryHistory(result, scenario: .termination)
NSLog("\(Self.TAG): Full recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
return result
}
// MARK: - Phase 3: Boot Detection & Recovery
/**
* Detect boot scenario
*
* Phase 3: Detects if device was rebooted since last app launch
*
* Detection method:
* 1. Get system uptime (time since last boot)
* 2. Get last launch time from UserDefaults
* 3. If system uptime < last launch time, device was rebooted
*
* @return true if boot scenario detected
*
* Note: Internal for testing
*/
internal func detectBootScenario() -> Bool {
let systemUptime = ProcessInfo.processInfo.systemUptime
let lastLaunchTime = getLastLaunchTime()
// If no last launch time recorded, this is first launch (not boot)
guard let lastLaunch = lastLaunchTime else {
NSLog("\(Self.TAG): No last launch time recorded - first launch")
return false
}
// Calculate time since last launch
let timeSinceLastLaunch = Date().timeIntervalSince1970 - lastLaunch
// If system uptime is less than time since last launch, device was rebooted
// Also check if system uptime is very small (just booted)
let isBoot = systemUptime < timeSinceLastLaunch || systemUptime < Self.BOOT_DETECTION_THRESHOLD_SECONDS
if isBoot {
NSLog("\(Self.TAG): Boot detected - systemUptime=\(systemUptime)s, timeSinceLastLaunch=\(timeSinceLastLaunch)s")
}
return isBoot
}
/**
* Get last launch time from UserDefaults
*
* @return Last launch timestamp or nil if not set
*/
private func getLastLaunchTime() -> TimeInterval? {
let lastLaunch = UserDefaults.standard.double(forKey: Self.LAST_LAUNCH_TIME_KEY)
return lastLaunch > 0 ? lastLaunch : nil
}
/**
* Update last launch time in UserDefaults
*/
private func updateLastLaunchTime() {
let currentTime = Date().timeIntervalSince1970
UserDefaults.standard.set(currentTime, forKey: Self.LAST_LAUNCH_TIME_KEY)
NSLog("\(Self.TAG): Updated last launch time: \(currentTime)")
}
/**
* Perform boot recovery
*
* Phase 3: Comprehensive recovery after device reboot
*
* Steps:
* 1. Detect all missed notifications (past scheduled times)
* 2. Mark all as missed
* 3. Reschedule all future notifications
* 4. Reschedule all fetch schedules (if applicable)
*
* Similar to termination recovery, but triggered by boot detection
*
* Note: BGTaskScheduler may also trigger boot recovery, but this
* method provides immediate recovery on app launch after boot.
*
* @return RecoveryResult with counts
*/
private func performBootRecovery() async throws -> RecoveryResult {
NSLog("\(Self.TAG): Performing boot recovery - comprehensive recovery after device reboot")
// Boot recovery is similar to termination recovery
// Use full recovery which handles all notifications
let result = try await performFullRecovery()
// Record as boot recovery in history
try await recordRecoveryHistory(result, scenario: .boot)
NSLog("\(Self.TAG): Boot recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
return result
}
/**
* Verify BGTaskScheduler registration
*
* Phase 3: Verifies that background tasks are properly registered
*
* This is a diagnostic method to check registration status.
* Actual registration happens in DailyNotificationPlugin.setupBackgroundTasks()
*
* @return Dictionary with registration status
*/
func verifyBGTaskRegistration() -> [String: Any] {
guard #available(iOS 13.0, *) else {
return [
"available": false,
"message": "Background tasks not available on this iOS version"
]
}
let registeredIdentifiers = BGTaskScheduler.shared.registeredTaskIdentifiers
let fetchTaskRegistered = registeredIdentifiers.contains("com.timesafari.dailynotification.fetch")
let notifyTaskRegistered = registeredIdentifiers.contains("com.timesafari.dailynotification.notify")
return [
"available": true,
"fetchTaskRegistered": fetchTaskRegistered,
"notifyTaskRegistered": notifyTaskRegistered,
"registeredIdentifiers": Array(registeredIdentifiers.map { $0.rawValue })
]
}
// MARK: - History Recording
/**
* Record recovery history
*
* @param result Recovery result
* @param scenario Recovery scenario
*/
private func recordRecoveryHistory(_ result: RecoveryResult, scenario: RecoveryScenario) async throws {
// Note: History recording implementation depends on database structure
// For Phase 1, we'll log the recovery result
let diagJson = """
{
"scenario": "\(scenario.rawValue)",
"missedCount": \(result.missedCount),
"rescheduledCount": \(result.rescheduledCount),
"verifiedCount": \(result.verifiedCount),
"errors": \(result.errors)
}
"""
NSLog("\(Self.TAG): Recovery history: \(diagJson)")
// TODO: Record in history table when database structure supports it
}
/**
* Record recovery failure
*
* @param error Error that occurred
*/
private func recordRecoveryFailure(_ error: Error) async throws {
let diagJson = """
{
"error": "\(error.localizedDescription)",
"errorType": "\(type(of: error))"
}
"""
NSLog("\(Self.TAG): Recovery failure: \(diagJson)")
// TODO: Record in history table when database structure supports it
}
}
// MARK: - Supporting Types
/**
* Recovery scenario enum
*/
enum RecoveryScenario: String {
case none = "NONE"
case coldStart = "COLD_START"
case termination = "TERMINATION"
case warmStart = "WARM_START"
case boot = "BOOT" // Phase 3: Boot recovery
}
/**
* Recovery result
*/
struct RecoveryResult {
let missedCount: Int
let rescheduledCount: Int
let verifiedCount: Int
let errors: Int
}
/**
* Verification result
*/
struct VerificationResult {
let totalSchedules: Int
let notificationsFound: Int
let notificationsMissing: Int
let missingIds: [String]
}
/**
* Reactivation errors
*/
enum ReactivationError: LocalizedError {
case notificationNotFound(id: String)
case rescheduleFailed(id: String)
var errorDescription: String? {
switch self {
case .notificationNotFound(let id):
return "Notification not found: \(id)"
case .rescheduleFailed(let id):
return "Failed to reschedule notification: \(id)"
}
}
}
// MARK: - Timeout Helper
/**
* Timeout error
*/
struct TimeoutError: Error {}
/**
* Execute async code with timeout
*/
func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
}

View File

@@ -0,0 +1,411 @@
/**
* NotificationConfigDAO.swift
*
* Data Access Object (DAO) for NotificationConfig Core Data entity
* Provides CRUD operations and query helpers for notification configuration
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-12-08
*/
import Foundation
import CoreData
/**
* Extension providing DAO methods for NotificationConfig entity
*
* This extension adds CRUD operations and query helpers to the
* auto-generated NotificationConfig Core Data class.
*/
extension NotificationConfig {
// MARK: - Constants
private static let TAG = "DNP-NOTIFICATION-CONFIG-DAO"
// MARK: - Create/Insert Methods
/**
* Create a new NotificationConfig entity in the given context
*
* @param context Core Data managed object context
* @param id Unique configuration identifier
* @param timesafariDid TimeSafari device ID
* @param configType Type of configuration
* @param configKey Configuration key
* @param configValue Configuration value (string representation)
* @param configDataType Data type of value (e.g., "string", "int", "bool", "json")
* @param isEncrypted Whether value is encrypted
* @param encryptionKeyId Encryption key identifier
* @param ttlSeconds Time-to-live in seconds
* @param isActive Whether configuration is active
* @param metadata Additional metadata (JSON string)
* @return Created NotificationConfig entity
*/
static func create(
in context: NSManagedObjectContext,
id: String,
timesafariDid: String? = nil,
configType: String? = nil,
configKey: String? = nil,
configValue: String? = nil,
configDataType: String? = nil,
isEncrypted: Bool = false,
encryptionKeyId: String? = nil,
ttlSeconds: Int64 = 604800, // 7 days default
isActive: Bool = true,
metadata: String? = nil
) -> NotificationConfig {
let entity = NotificationConfig(context: context)
let now = Date()
entity.id = id
entity.timesafariDid = timesafariDid
entity.configType = configType
entity.configKey = configKey
entity.configValue = configValue
entity.configDataType = configDataType
entity.isEncrypted = isEncrypted
entity.encryptionKeyId = encryptionKeyId
entity.createdAt = now
entity.updatedAt = now
entity.ttlSeconds = ttlSeconds
entity.isActive = isActive
entity.metadata = metadata
print("\(Self.TAG): Created NotificationConfig with id: \(id)")
return entity
}
/**
* Create from dictionary representation
*
* @param context Core Data managed object context
* @param dict Dictionary with configuration data
* @return Created NotificationConfig entity or nil
*/
static func create(
in context: NSManagedObjectContext,
from dict: [String: Any]
) -> NotificationConfig? {
guard let id = dict["id"] as? String else {
print("\(Self.TAG): Missing required 'id' field")
return nil
}
// Convert createdAt/updatedAt if present
let createdAt: Date
if let createdMillis = dict["createdAt"] as? Int64 {
createdAt = DailyNotificationDataConversions.dateFromEpochMillis(createdMillis)
} else if let createdDate = dict["createdAt"] as? Date {
createdAt = createdDate
} else {
createdAt = Date()
}
let updatedAt: Date
if let updatedMillis = dict["updatedAt"] as? Int64 {
updatedAt = DailyNotificationDataConversions.dateFromEpochMillis(updatedMillis)
} else if let updatedDate = dict["updatedAt"] as? Date {
updatedAt = updatedDate
} else {
updatedAt = Date()
}
let entity = NotificationConfig(context: context)
entity.id = id
entity.timesafariDid = dict["timesafariDid"] as? String
entity.configType = dict["configType"] as? String
entity.configKey = dict["configKey"] as? String
entity.configValue = dict["configValue"] as? String
entity.configDataType = dict["configDataType"] as? String
entity.isEncrypted = dict["isEncrypted"] as? Bool ?? false
entity.encryptionKeyId = dict["encryptionKeyId"] as? String
entity.createdAt = createdAt
entity.updatedAt = updatedAt
entity.ttlSeconds = dict["ttlSeconds"] as? Int64 ?? 604800
entity.isActive = dict["isActive"] as? Bool ?? true
entity.metadata = dict["metadata"] as? String
return entity
}
// MARK: - Read/Query Methods
/**
* Fetch NotificationConfig by ID
*
* @param context Core Data managed object context
* @param id Configuration ID
* @return NotificationConfig entity or nil
*/
static func fetch(
by id: String,
in context: NSManagedObjectContext
) -> NotificationConfig? {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
do {
let results = try context.fetch(request)
return results.first
} catch {
print("\(Self.TAG): Error fetching by id: \(error.localizedDescription)")
return nil
}
}
/**
* Fetch NotificationConfig by key (configKey)
*
* @param context Core Data managed object context
* @param configKey Configuration key
* @return NotificationConfig entity or nil
*/
static func fetch(
by configKey: String,
in context: NSManagedObjectContext
) -> NotificationConfig? {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(format: "configKey == %@", configKey)
request.fetchLimit = 1
do {
let results = try context.fetch(request)
return results.first
} catch {
print("\(Self.TAG): Error fetching by configKey: \(error.localizedDescription)")
return nil
}
}
/**
* Fetch all NotificationConfig entities
*
* @param context Core Data managed object context
* @return Array of NotificationConfig entities
*/
static func fetchAll(
in context: NSManagedObjectContext
) -> [NotificationConfig] {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error fetching all: \(error.localizedDescription)")
return []
}
}
/**
* Query by timesafariDid
*
* @param context Core Data managed object context
* @param timesafariDid TimeSafari device ID
* @return Array of NotificationConfig entities
*/
static func query(
by timesafariDid: String,
in context: NSManagedObjectContext
) -> [NotificationConfig] {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(format: "timesafariDid == %@", timesafariDid)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by timesafariDid: \(error.localizedDescription)")
return []
}
}
/**
* Query by configType
*
* @param context Core Data managed object context
* @param configType Configuration type
* @return Array of NotificationConfig entities
*/
static func query(
by configType: String,
in context: NSManagedObjectContext
) -> [NotificationConfig] {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(format: "configType == %@", configType)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by configType: \(error.localizedDescription)")
return []
}
}
/**
* Query active configurations only
*
* @param context Core Data managed object context
* @return Array of active NotificationConfig entities
*/
static func queryActive(
in context: NSManagedObjectContext
) -> [NotificationConfig] {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(format: "isActive == YES")
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying active: \(error.localizedDescription)")
return []
}
}
/**
* Query by configType and isActive
*
* @param context Core Data managed object context
* @param configType Configuration type
* @param isActive Whether configuration is active
* @return Array of NotificationConfig entities
*/
static func query(
by configType: String,
isActive: Bool,
in context: NSManagedObjectContext
) -> [NotificationConfig] {
let request: NSFetchRequest<NotificationConfig> = NotificationConfig.fetchRequest()
request.predicate = NSPredicate(
format: "configType == %@ AND isActive == %@",
configType,
NSNumber(value: isActive)
)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by configType and isActive: \(error.localizedDescription)")
return []
}
}
// MARK: - Update Methods
/**
* Update configuration value
*
* @param value New configuration value
*/
func updateValue(_ value: String?) {
self.configValue = value
self.updatedAt = Date()
}
/**
* Activate or deactivate configuration
*
* @param active Whether configuration should be active
*/
func setActive(_ active: Bool) {
self.isActive = active
self.updatedAt = Date()
}
/**
* Update this entity's updatedAt timestamp
*/
func touch() {
self.updatedAt = Date()
}
// MARK: - Delete Methods
/**
* Delete NotificationConfig by ID
*
* @param context Core Data managed object context
* @param id Configuration ID
* @return true if deleted, false otherwise
*/
static func delete(
by id: String,
in context: NSManagedObjectContext
) -> Bool {
guard let entity = fetch(by: id, in: context) else {
return false
}
context.delete(entity)
do {
try context.save()
print("\(Self.TAG): Deleted NotificationConfig with id: \(id)")
return true
} catch {
print("\(Self.TAG): Error deleting: \(error.localizedDescription)")
context.rollback()
return false
}
}
/**
* Delete NotificationConfig by configKey
*
* @param context Core Data managed object context
* @param configKey Configuration key
* @return true if deleted, false otherwise
*/
static func delete(
by configKey: String,
in context: NSManagedObjectContext
) -> Bool {
guard let entity = fetch(by: configKey, in: context) else {
return false
}
context.delete(entity)
do {
try context.save()
print("\(Self.TAG): Deleted NotificationConfig with configKey: \(configKey)")
return true
} catch {
print("\(Self.TAG): Error deleting: \(error.localizedDescription)")
context.rollback()
return false
}
}
/**
* Delete all NotificationConfig entities
*
* @param context Core Data managed object context
* @return Number of entities deleted
*/
static func deleteAll(
in context: NSManagedObjectContext
) -> Int {
let request: NSFetchRequest<NSFetchRequestResult> = NotificationConfig.fetchRequest()
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
do {
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
try context.save()
let count = result?.result as? Int ?? 0
print("\(Self.TAG): Deleted \(count) NotificationConfig entities")
return count
} catch {
print("\(Self.TAG): Error deleting all: \(error.localizedDescription)")
context.rollback()
return 0
}
}
}

View File

@@ -0,0 +1,440 @@
/**
* NotificationContentDAO.swift
*
* Data Access Object (DAO) for NotificationContent Core Data entity
* Provides CRUD operations and query helpers for notification content
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-12-08
*/
import Foundation
import CoreData
/**
* Extension providing DAO methods for NotificationContent entity
*
* This extension adds CRUD operations and query helpers to the
* auto-generated NotificationContent Core Data class.
*/
extension NotificationContent {
// MARK: - Constants
private static let TAG = "DNP-NOTIFICATION-CONTENT-DAO"
// MARK: - Create/Insert Methods
/**
* Create a new NotificationContent entity in the given context
*
* @param context Core Data managed object context
* @param id Unique notification identifier
* @param pluginVersion Plugin version string
* @param timesafariDid TimeSafari device ID
* @param notificationType Type of notification
* @param title Notification title
* @param body Notification body
* @param scheduledTime Scheduled delivery time (Date)
* @param timezone Timezone string
* @param priority Notification priority (0-10)
* @param vibrationEnabled Whether vibration is enabled
* @param soundEnabled Whether sound is enabled
* @param mediaUrl URL to media content
* @param encryptedContent Encrypted content string
* @param encryptionKeyId Encryption key identifier
* @param ttlSeconds Time-to-live in seconds
* @param deliveryStatus Current delivery status
* @param deliveryAttempts Number of delivery attempts
* @param metadata Additional metadata (JSON string)
* @return Created NotificationContent entity
*/
static func create(
in context: NSManagedObjectContext,
id: String,
pluginVersion: String? = nil,
timesafariDid: String? = nil,
notificationType: String? = nil,
title: String? = nil,
body: String? = nil,
scheduledTime: Date,
timezone: String? = nil,
priority: Int32 = 0,
vibrationEnabled: Bool = false,
soundEnabled: Bool = true,
mediaUrl: String? = nil,
encryptedContent: String? = nil,
encryptionKeyId: String? = nil,
ttlSeconds: Int64 = 604800, // 7 days default
deliveryStatus: String? = nil,
deliveryAttempts: Int32 = 0,
metadata: String? = nil
) -> NotificationContent {
let entity = NotificationContent(context: context)
let now = Date()
entity.id = id
entity.pluginVersion = pluginVersion
entity.timesafariDid = timesafariDid
entity.notificationType = notificationType
entity.title = title
entity.body = body
entity.scheduledTime = scheduledTime
entity.timezone = timezone
entity.priority = priority
entity.vibrationEnabled = vibrationEnabled
entity.soundEnabled = soundEnabled
entity.mediaUrl = mediaUrl
entity.encryptedContent = encryptedContent
entity.encryptionKeyId = encryptionKeyId
entity.createdAt = now
entity.updatedAt = now
entity.ttlSeconds = ttlSeconds
entity.deliveryStatus = deliveryStatus
entity.deliveryAttempts = deliveryAttempts
entity.lastDeliveryAttempt = nil
entity.userInteractionCount = 0
entity.lastUserInteraction = nil
entity.metadata = metadata
print("\(Self.TAG): Created NotificationContent with id: \(id)")
return entity
}
/**
* Create from dictionary representation
*
* @param context Core Data managed object context
* @param dict Dictionary with notification data
* @return Created NotificationContent entity or nil
*/
static func create(
in context: NSManagedObjectContext,
from dict: [String: Any]
) -> NotificationContent? {
guard let id = dict["id"] as? String else {
print("\(Self.TAG): Missing required 'id' field")
return nil
}
// Convert scheduledTime from epoch milliseconds or Date
let scheduledTime: Date
if let timeMillis = dict["scheduledTime"] as? Int64 {
scheduledTime = DailyNotificationDataConversions.dateFromEpochMillis(timeMillis)
} else if let timeDate = dict["scheduledTime"] as? Date {
scheduledTime = timeDate
} else {
print("\(Self.TAG): Missing or invalid 'scheduledTime' field")
return nil
}
// Convert createdAt/updatedAt if present
let createdAt: Date
if let createdMillis = dict["createdAt"] as? Int64 {
createdAt = DailyNotificationDataConversions.dateFromEpochMillis(createdMillis)
} else if let createdDate = dict["createdAt"] as? Date {
createdAt = createdDate
} else {
createdAt = Date()
}
let updatedAt: Date
if let updatedMillis = dict["updatedAt"] as? Int64 {
updatedAt = DailyNotificationDataConversions.dateFromEpochMillis(updatedMillis)
} else if let updatedDate = dict["updatedAt"] as? Date {
updatedAt = updatedDate
} else {
updatedAt = Date()
}
let entity = NotificationContent(context: context)
entity.id = id
entity.pluginVersion = dict["pluginVersion"] as? String
entity.timesafariDid = dict["timesafariDid"] as? String
entity.notificationType = dict["notificationType"] as? String
entity.title = dict["title"] as? String
entity.body = dict["body"] as? String
entity.scheduledTime = scheduledTime
entity.timezone = dict["timezone"] as? String
entity.priority = DailyNotificationDataConversions.int32FromInt(
dict["priority"] as? Int ?? 0
)
entity.vibrationEnabled = dict["vibrationEnabled"] as? Bool ?? false
entity.soundEnabled = dict["soundEnabled"] as? Bool ?? true
entity.mediaUrl = dict["mediaUrl"] as? String
entity.encryptedContent = dict["encryptedContent"] as? String
entity.encryptionKeyId = dict["encryptionKeyId"] as? String
entity.createdAt = createdAt
entity.updatedAt = updatedAt
entity.ttlSeconds = dict["ttlSeconds"] as? Int64 ?? 604800
entity.deliveryStatus = dict["deliveryStatus"] as? String
entity.deliveryAttempts = DailyNotificationDataConversions.int32FromInt(
dict["deliveryAttempts"] as? Int ?? 0
)
if let lastAttemptMillis = dict["lastDeliveryAttempt"] as? Int64 {
entity.lastDeliveryAttempt = DailyNotificationDataConversions.dateFromEpochMillis(lastAttemptMillis)
}
entity.userInteractionCount = DailyNotificationDataConversions.int32FromInt(
dict["userInteractionCount"] as? Int ?? 0
)
if let lastInteractionMillis = dict["lastUserInteraction"] as? Int64 {
entity.lastUserInteraction = DailyNotificationDataConversions.dateFromEpochMillis(lastInteractionMillis)
}
entity.metadata = dict["metadata"] as? String
return entity
}
// MARK: - Read/Query Methods
/**
* Fetch NotificationContent by ID
*
* @param context Core Data managed object context
* @param id Notification ID
* @return NotificationContent entity or nil
*/
static func fetch(
by id: String,
in context: NSManagedObjectContext
) -> NotificationContent? {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
do {
let results = try context.fetch(request)
return results.first
} catch {
print("\(Self.TAG): Error fetching by id: \(error.localizedDescription)")
return nil
}
}
/**
* Fetch all NotificationContent entities
*
* @param context Core Data managed object context
* @return Array of NotificationContent entities
*/
static func fetchAll(
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error fetching all: \(error.localizedDescription)")
return []
}
}
/**
* Query by timesafariDid
*
* @param context Core Data managed object context
* @param timesafariDid TimeSafari device ID
* @return Array of NotificationContent entities
*/
static func query(
by timesafariDid: String,
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(format: "timesafariDid == %@", timesafariDid)
request.sortDescriptors = [NSSortDescriptor(key: "scheduledTime", ascending: true)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by timesafariDid: \(error.localizedDescription)")
return []
}
}
/**
* Query by notificationType
*
* @param context Core Data managed object context
* @param notificationType Notification type string
* @return Array of NotificationContent entities
*/
static func query(
by notificationType: String,
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(format: "notificationType == %@", notificationType)
request.sortDescriptors = [NSSortDescriptor(key: "scheduledTime", ascending: true)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by notificationType: \(error.localizedDescription)")
return []
}
}
/**
* Query by scheduledTime range
*
* @param context Core Data managed object context
* @param startDate Start date (inclusive)
* @param endDate End date (inclusive)
* @return Array of NotificationContent entities
*/
static func query(
scheduledTimeBetween startDate: Date,
and endDate: Date,
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(
format: "scheduledTime >= %@ AND scheduledTime <= %@",
startDate as NSDate,
endDate as NSDate
)
request.sortDescriptors = [NSSortDescriptor(key: "scheduledTime", ascending: true)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by scheduledTime range: \(error.localizedDescription)")
return []
}
}
/**
* Query by deliveryStatus
*
* @param context Core Data managed object context
* @param deliveryStatus Delivery status string
* @return Array of NotificationContent entities
*/
static func query(
by deliveryStatus: String,
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(format: "deliveryStatus == %@", deliveryStatus)
request.sortDescriptors = [NSSortDescriptor(key: "scheduledTime", ascending: true)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by deliveryStatus: \(error.localizedDescription)")
return []
}
}
/**
* Query notifications ready for delivery (scheduledTime <= currentTime)
*
* @param context Core Data managed object context
* @param currentTime Current time for comparison
* @return Array of NotificationContent entities
*/
static func queryReadyForDelivery(
currentTime: Date,
in context: NSManagedObjectContext
) -> [NotificationContent] {
let request: NSFetchRequest<NotificationContent> = NotificationContent.fetchRequest()
request.predicate = NSPredicate(format: "scheduledTime <= %@", currentTime as NSDate)
request.sortDescriptors = [NSSortDescriptor(key: "scheduledTime", ascending: true)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying ready for delivery: \(error.localizedDescription)")
return []
}
}
// MARK: - Update Methods
/**
* Update this entity's updatedAt timestamp
*/
func touch() {
self.updatedAt = Date()
}
/**
* Update delivery status and increment attempts
*
* @param status New delivery status
*/
func updateDeliveryStatus(_ status: String) {
self.deliveryStatus = status
self.deliveryAttempts += 1
self.lastDeliveryAttempt = Date()
self.touch()
}
/**
* Record user interaction
*/
func recordUserInteraction() {
self.userInteractionCount += 1
self.lastUserInteraction = Date()
self.touch()
}
// MARK: - Delete Methods
/**
* Delete NotificationContent by ID
*
* @param context Core Data managed object context
* @param id Notification ID
* @return true if deleted, false otherwise
*/
static func delete(
by id: String,
in context: NSManagedObjectContext
) -> Bool {
guard let entity = fetch(by: id, in: context) else {
return false
}
context.delete(entity)
do {
try context.save()
print("\(Self.TAG): Deleted NotificationContent with id: \(id)")
return true
} catch {
print("\(Self.TAG): Error deleting: \(error.localizedDescription)")
context.rollback()
return false
}
}
/**
* Delete all NotificationContent entities
*
* @param context Core Data managed object context
* @return Number of entities deleted
*/
static func deleteAll(
in context: NSManagedObjectContext
) -> Int {
let request: NSFetchRequest<NSFetchRequestResult> = NotificationContent.fetchRequest()
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
do {
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
try context.save()
let count = result?.result as? Int ?? 0
print("\(Self.TAG): Deleted \(count) NotificationContent entities")
return count
} catch {
print("\(Self.TAG): Error deleting all: \(error.localizedDescription)")
context.rollback()
return 0
}
}
}

View File

@@ -0,0 +1,414 @@
/**
* NotificationDeliveryDAO.swift
*
* Data Access Object (DAO) for NotificationDelivery Core Data entity
* Provides CRUD operations and query helpers for notification delivery tracking
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-12-08
*/
import Foundation
import CoreData
/**
* Extension providing DAO methods for NotificationDelivery entity
*
* This extension adds CRUD operations and query helpers to the
* auto-generated NotificationDelivery Core Data class.
*/
extension NotificationDelivery {
// MARK: - Constants
private static let TAG = "DNP-NOTIFICATION-DELIVERY-DAO"
// MARK: - Create/Insert Methods
/**
* Create a new NotificationDelivery entity in the given context
*
* @param context Core Data managed object context
* @param id Unique delivery identifier
* @param notificationId Associated notification content ID
* @param notificationContent Associated NotificationContent entity
* @param timesafariDid TimeSafari device ID
* @param deliveryTimestamp When delivery occurred
* @param deliveryStatus Delivery status string
* @param deliveryMethod Delivery method string
* @param deliveryAttemptNumber Attempt number (1-based)
* @param deliveryDurationMs Duration of delivery in milliseconds
* @param userInteractionType Type of user interaction (if any)
* @param userInteractionTimestamp When user interacted
* @param userInteractionDurationMs Duration of interaction in milliseconds
* @param errorCode Error code (if delivery failed)
* @param errorMessage Error message (if delivery failed)
* @param deviceInfo Device information JSON string
* @param networkInfo Network information JSON string
* @param batteryLevel Battery level (0-100, -1 if unknown)
* @param dozeModeActive Whether device was in doze mode
* @param exactAlarmPermission Whether exact alarm permission granted
* @param notificationPermission Whether notification permission granted
* @param metadata Additional metadata (JSON string)
* @return Created NotificationDelivery entity
*/
static func create(
in context: NSManagedObjectContext,
id: String,
notificationId: String,
notificationContent: NotificationContent? = nil,
timesafariDid: String? = nil,
deliveryTimestamp: Date,
deliveryStatus: String? = nil,
deliveryMethod: String? = nil,
deliveryAttemptNumber: Int32 = 1,
deliveryDurationMs: Int64 = 0,
userInteractionType: String? = nil,
userInteractionTimestamp: Date? = nil,
userInteractionDurationMs: Int64 = 0,
errorCode: String? = nil,
errorMessage: String? = nil,
deviceInfo: String? = nil,
networkInfo: String? = nil,
batteryLevel: Int32 = -1,
dozeModeActive: Bool = false,
exactAlarmPermission: Bool = false,
notificationPermission: Bool = false,
metadata: String? = nil
) -> NotificationDelivery {
let entity = NotificationDelivery(context: context)
entity.id = id
entity.notificationId = notificationId
entity.notificationContent = notificationContent
entity.timesafariDid = timesafariDid
entity.deliveryTimestamp = deliveryTimestamp
entity.deliveryStatus = deliveryStatus
entity.deliveryMethod = deliveryMethod
entity.deliveryAttemptNumber = deliveryAttemptNumber
entity.deliveryDurationMs = deliveryDurationMs
entity.userInteractionType = userInteractionType
entity.userInteractionTimestamp = userInteractionTimestamp
entity.userInteractionDurationMs = userInteractionDurationMs
entity.errorCode = errorCode
entity.errorMessage = errorMessage
entity.deviceInfo = deviceInfo
entity.networkInfo = networkInfo
entity.batteryLevel = batteryLevel
entity.dozeModeActive = dozeModeActive
entity.exactAlarmPermission = exactAlarmPermission
entity.notificationPermission = notificationPermission
entity.metadata = metadata
print("\(Self.TAG): Created NotificationDelivery with id: \(id)")
return entity
}
/**
* Create from dictionary representation
*
* @param context Core Data managed object context
* @param dict Dictionary with delivery data
* @param notificationContent Optional associated NotificationContent entity
* @return Created NotificationDelivery entity or nil
*/
static func create(
in context: NSManagedObjectContext,
from dict: [String: Any],
notificationContent: NotificationContent? = nil
) -> NotificationDelivery? {
guard let id = dict["id"] as? String,
let notificationId = dict["notificationId"] as? String else {
print("\(Self.TAG): Missing required fields")
return nil
}
// Convert deliveryTimestamp from epoch milliseconds or Date
let deliveryTimestamp: Date
if let timeMillis = dict["deliveryTimestamp"] as? Int64 {
deliveryTimestamp = DailyNotificationDataConversions.dateFromEpochMillis(timeMillis)
} else if let timeDate = dict["deliveryTimestamp"] as? Date {
deliveryTimestamp = timeDate
} else {
print("\(Self.TAG): Missing or invalid 'deliveryTimestamp' field")
return nil
}
// Convert userInteractionTimestamp if present
let userInteractionTimestamp: Date?
if let interactionMillis = dict["userInteractionTimestamp"] as? Int64 {
userInteractionTimestamp = DailyNotificationDataConversions.dateFromEpochMillis(interactionMillis)
} else if let interactionDate = dict["userInteractionTimestamp"] as? Date {
userInteractionTimestamp = interactionDate
} else {
userInteractionTimestamp = nil
}
let entity = NotificationDelivery(context: context)
entity.id = id
entity.notificationId = notificationId
entity.notificationContent = notificationContent
entity.timesafariDid = dict["timesafariDid"] as? String
entity.deliveryTimestamp = deliveryTimestamp
entity.deliveryStatus = dict["deliveryStatus"] as? String
entity.deliveryMethod = dict["deliveryMethod"] as? String
entity.deliveryAttemptNumber = DailyNotificationDataConversions.int32FromInt(
dict["deliveryAttemptNumber"] as? Int ?? 1
)
entity.deliveryDurationMs = dict["deliveryDurationMs"] as? Int64 ?? 0
entity.userInteractionType = dict["userInteractionType"] as? String
entity.userInteractionTimestamp = userInteractionTimestamp
entity.userInteractionDurationMs = dict["userInteractionDurationMs"] as? Int64 ?? 0
entity.errorCode = dict["errorCode"] as? String
entity.errorMessage = dict["errorMessage"] as? String
entity.deviceInfo = dict["deviceInfo"] as? String
entity.networkInfo = dict["networkInfo"] as? String
entity.batteryLevel = DailyNotificationDataConversions.int32FromInt(
dict["batteryLevel"] as? Int ?? -1
)
entity.dozeModeActive = dict["dozeModeActive"] as? Bool ?? false
entity.exactAlarmPermission = dict["exactAlarmPermission"] as? Bool ?? false
entity.notificationPermission = dict["notificationPermission"] as? Bool ?? false
entity.metadata = dict["metadata"] as? String
return entity
}
// MARK: - Read/Query Methods
/**
* Fetch NotificationDelivery by ID
*
* @param context Core Data managed object context
* @param id Delivery ID
* @return NotificationDelivery entity or nil
*/
static func fetch(
by id: String,
in context: NSManagedObjectContext
) -> NotificationDelivery? {
let request: NSFetchRequest<NotificationDelivery> = NotificationDelivery.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
do {
let results = try context.fetch(request)
return results.first
} catch {
print("\(Self.TAG): Error fetching by id: \(error.localizedDescription)")
return nil
}
}
/**
* Query by notificationId
*
* @param context Core Data managed object context
* @param notificationId Notification content ID
* @return Array of NotificationDelivery entities
*/
static func query(
by notificationId: String,
in context: NSManagedObjectContext
) -> [NotificationDelivery] {
let request: NSFetchRequest<NotificationDelivery> = NotificationDelivery.fetchRequest()
request.predicate = NSPredicate(format: "notificationId == %@", notificationId)
request.sortDescriptors = [NSSortDescriptor(key: "deliveryTimestamp", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by notificationId: \(error.localizedDescription)")
return []
}
}
/**
* Query by deliveryTimestamp range
*
* @param context Core Data managed object context
* @param startDate Start date (inclusive)
* @param endDate End date (inclusive)
* @return Array of NotificationDelivery entities
*/
static func query(
deliveryTimestampBetween startDate: Date,
and endDate: Date,
in context: NSManagedObjectContext
) -> [NotificationDelivery] {
let request: NSFetchRequest<NotificationDelivery> = NotificationDelivery.fetchRequest()
request.predicate = NSPredicate(
format: "deliveryTimestamp >= %@ AND deliveryTimestamp <= %@",
startDate as NSDate,
endDate as NSDate
)
request.sortDescriptors = [NSSortDescriptor(key: "deliveryTimestamp", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by deliveryTimestamp range: \(error.localizedDescription)")
return []
}
}
/**
* Query by deliveryStatus
*
* @param context Core Data managed object context
* @param deliveryStatus Delivery status string
* @return Array of NotificationDelivery entities
*/
static func query(
by deliveryStatus: String,
in context: NSManagedObjectContext
) -> [NotificationDelivery] {
let request: NSFetchRequest<NotificationDelivery> = NotificationDelivery.fetchRequest()
request.predicate = NSPredicate(format: "deliveryStatus == %@", deliveryStatus)
request.sortDescriptors = [NSSortDescriptor(key: "deliveryTimestamp", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by deliveryStatus: \(error.localizedDescription)")
return []
}
}
/**
* Query by timesafariDid
*
* @param context Core Data managed object context
* @param timesafariDid TimeSafari device ID
* @return Array of NotificationDelivery entities
*/
static func query(
by timesafariDid: String,
in context: NSManagedObjectContext
) -> [NotificationDelivery] {
let request: NSFetchRequest<NotificationDelivery> = NotificationDelivery.fetchRequest()
request.predicate = NSPredicate(format: "timesafariDid == %@", timesafariDid)
request.sortDescriptors = [NSSortDescriptor(key: "deliveryTimestamp", ascending: false)]
do {
return try context.fetch(request)
} catch {
print("\(Self.TAG): Error querying by timesafariDid: \(error.localizedDescription)")
return []
}
}
// MARK: - Update Methods
/**
* Update delivery status
*
* @param status New delivery status
*/
func updateDeliveryStatus(_ status: String) {
self.deliveryStatus = status
}
/**
* Record user interaction
*
* @param interactionType Type of interaction
* @param timestamp When interaction occurred
* @param durationMs Duration of interaction in milliseconds
*/
func recordUserInteraction(
type: String,
timestamp: Date,
durationMs: Int64
) {
self.userInteractionType = type
self.userInteractionTimestamp = timestamp
self.userInteractionDurationMs = durationMs
}
// MARK: - Delete Methods
/**
* Delete NotificationDelivery by ID
*
* @param context Core Data managed object context
* @param id Delivery ID
* @return true if deleted, false otherwise
*/
static func delete(
by id: String,
in context: NSManagedObjectContext
) -> Bool {
guard let entity = fetch(by: id, in: context) else {
return false
}
context.delete(entity)
do {
try context.save()
print("\(Self.TAG): Deleted NotificationDelivery with id: \(id)")
return true
} catch {
print("\(Self.TAG): Error deleting: \(error.localizedDescription)")
context.rollback()
return false
}
}
/**
* Delete all NotificationDelivery entities for a notification
*
* @param context Core Data managed object context
* @param notificationId Notification content ID
* @return Number of entities deleted
*/
static func deleteAll(
for notificationId: String,
in context: NSManagedObjectContext
) -> Int {
let deliveries = query(by: notificationId, in: context)
let count = deliveries.count
for delivery in deliveries {
context.delete(delivery)
}
do {
try context.save()
print("\(Self.TAG): Deleted \(count) NotificationDelivery entities for notification: \(notificationId)")
return count
} catch {
print("\(Self.TAG): Error deleting all: \(error.localizedDescription)")
context.rollback()
return 0
}
}
/**
* Delete all NotificationDelivery entities
*
* @param context Core Data managed object context
* @return Number of entities deleted
*/
static func deleteAll(
in context: NSManagedObjectContext
) -> Int {
let request: NSFetchRequest<NSFetchRequestResult> = NotificationDelivery.fetchRequest()
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
do {
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
try context.save()
let count = result?.result as? Int ?? 0
print("\(Self.TAG): Deleted \(count) NotificationDelivery entities")
return count
} catch {
print("\(Self.TAG): Error deleting all: \(error.localizedDescription)")
context.rollback()
return 0
}
}
}