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
+217
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))