chore(release): v3.0.0 — iOS native fetcher, starred plans, chained dual (iOS + Android)
BREAKING CHANGE (iOS): configureNativeFetcher now requires DailyNotificationPlugin.registerNativeFetcher(_) first, aligned with Android. iOS: - Add NativeNotificationContentFetcher SPI, registry, FetchContext, timeout helper - Add updateStarredPlans / getStarredPlans; persist daily_notification_timesafari.starredPlanIds - Chained dual: prefetch only on scheduleDualNotification; arm one-shot UN after fetch - configureNativeFetcher invokes fetcher.configure; BG fetch prefers registered fetcher - Public NotificationContent for host implementations Android: - Dual notify alarm scheduled after dual FetchWorker completes (DualScheduleNotifyScheduler) - Persist dual_notify_schedule_id; remove upfront NotifyReceiver for dual setup Docs: CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md; CHANGELOG 3.0.0 Made-with: Cursor
This commit is contained in:
@@ -36,6 +36,12 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual"
|
||||
/// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
|
||||
private let dualScheduleConfigKey = "dual_schedule_config"
|
||||
/// Matches Android [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY] — stable id for chained dual notify.
|
||||
private let dualNotifyScheduleIdStorageKey = "dual_notify_schedule_id"
|
||||
/// Max slip after nominal notify time before showing fallback (parity with product discussion).
|
||||
private let dualChainedMaxSlipSeconds: TimeInterval = 15 * 60
|
||||
/// Parity with Android SharedPreferences `daily_notification_timesafari` + `starredPlanIds`.
|
||||
private let starredPlanIdsStorageKey = "daily_notification_timesafari.starredPlanIds"
|
||||
|
||||
// Phase 1: Storage and Scheduler components
|
||||
var storage: DailyNotificationStorage?
|
||||
@@ -232,6 +238,11 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard NativeNotificationFetcherRegistry.shared.fetcher != nil else {
|
||||
call.reject("No native fetcher registered. Host app must call DailyNotificationPlugin.registerNativeFetcher(_:) before configureNativeFetcher.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let apiBaseUrl = options["apiBaseUrl"] as? String else {
|
||||
call.reject("apiBaseUrl is required")
|
||||
return
|
||||
@@ -277,9 +288,13 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
print("DNP-PLUGIN: jwtTokenPool size=\(pool.count)")
|
||||
}
|
||||
|
||||
// Store configuration in database for persistence across app restarts
|
||||
// Note: iOS native fetcher interface not yet implemented, but we store config for future use
|
||||
let configId = "native_fetcher_config"
|
||||
NativeNotificationFetcherRegistry.shared.fetcher?.configure(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
activeDid: activeDid,
|
||||
jwtToken: jwtToken,
|
||||
jwtTokenPool: jwtTokenPool
|
||||
)
|
||||
|
||||
var configValue: [String: Any] = [
|
||||
"apiBaseUrl": apiBaseUrl,
|
||||
"activeDid": activeDid,
|
||||
@@ -289,16 +304,12 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
configValue["jwtTokenPool"] = pool
|
||||
}
|
||||
|
||||
// Convert to JSON string for storage
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
call.reject("Failed to serialize configuration")
|
||||
return
|
||||
}
|
||||
|
||||
// Store configuration in UserDefaults for now
|
||||
// This matches Android's approach of storing in database, but uses UserDefaults for simplicity
|
||||
// Can be enhanced later to use CoreData when native fetcher interface is implemented
|
||||
let configKey = "native_fetcher_config"
|
||||
UserDefaults.standard.set(jsonString, forKey: configKey)
|
||||
print("DNP-PLUGIN: Native fetcher configuration stored successfully")
|
||||
@@ -389,24 +400,11 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
|
||||
do {
|
||||
// Delegate to ScheduleHelper for dual scheduling orchestration
|
||||
try DailyNotificationScheduleHelper.scheduleDualNotification(
|
||||
contentFetchConfig: contentFetchConfig,
|
||||
userNotificationConfig: userNotificationConfig,
|
||||
scheduleBackgroundFetch: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleBackgroundFetch(config: config)
|
||||
},
|
||||
scheduleUserNotification: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleUserNotification(config: config)
|
||||
}
|
||||
)
|
||||
// Chained dual: prefetch BG task only; user notification is armed after prefetch completes.
|
||||
try scheduleBackgroundFetch(config: contentFetchConfig)
|
||||
saveDualScheduleConfig(config)
|
||||
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
||||
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
|
||||
@@ -445,6 +443,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
backgroundTaskScheduler.cancel(taskRequestWithIdentifier: fetchTaskIdentifier)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
|
||||
UserDefaults.standard.removeObject(forKey: dualScheduleConfigKey)
|
||||
UserDefaults.standard.removeObject(forKey: dualNotifyScheduleIdStorageKey)
|
||||
print("DNP-PLUGIN: Canceled dual schedule (fetch task + user notification)")
|
||||
}
|
||||
|
||||
@@ -457,29 +456,86 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
do {
|
||||
performCancelDualSchedule()
|
||||
try DailyNotificationScheduleHelper.scheduleDualNotification(
|
||||
contentFetchConfig: contentFetchConfig,
|
||||
userNotificationConfig: userNotificationConfig,
|
||||
scheduleBackgroundFetch: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleBackgroundFetch(config: config)
|
||||
},
|
||||
scheduleUserNotification: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleUserNotification(config: config)
|
||||
}
|
||||
)
|
||||
try scheduleBackgroundFetch(config: contentFetchConfig)
|
||||
saveDualScheduleConfig(config)
|
||||
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
||||
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Update dual schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateStarredPlans(_ call: CAPPluginCall) {
|
||||
guard let raw = call.options?["planIds"] else {
|
||||
call.reject("planIds is required and must be a string array")
|
||||
return
|
||||
}
|
||||
var planIds: [String] = []
|
||||
if let a = raw as? [String] {
|
||||
planIds = a
|
||||
} else if let anyArr = raw as? [Any] {
|
||||
for (index, item) in anyArr.enumerated() {
|
||||
guard let s = item as? String else {
|
||||
call.reject("planIds must be an array of strings (non-string at index \(index))")
|
||||
return
|
||||
}
|
||||
planIds.append(s)
|
||||
}
|
||||
} else {
|
||||
call.reject("planIds must be a string array")
|
||||
return
|
||||
}
|
||||
for (index, id) in planIds.enumerated() {
|
||||
if id.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
call.reject("planIds[\(index)] must be a non-empty string")
|
||||
return
|
||||
}
|
||||
}
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: planIds, options: [])
|
||||
guard let jsonStr = String(data: jsonData, encoding: .utf8) else {
|
||||
call.reject("Failed to serialize planIds")
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(jsonStr, forKey: starredPlanIdsStorageKey)
|
||||
let updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
UserDefaults.standard.set(updatedAt, forKey: "\(starredPlanIdsStorageKey).updatedAt")
|
||||
call.resolve([
|
||||
"success": true,
|
||||
"planIdsCount": planIds.count,
|
||||
"updatedAt": updatedAt
|
||||
])
|
||||
} catch {
|
||||
call.reject("Failed to update starred plans: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getStarredPlans(_ call: CAPPluginCall) {
|
||||
let jsonStr = UserDefaults.standard.string(forKey: starredPlanIdsStorageKey) ?? "[]"
|
||||
let planIds: [String]
|
||||
if let data = jsonStr.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {
|
||||
planIds = arr
|
||||
} else {
|
||||
planIds = []
|
||||
}
|
||||
let updatedKey = "\(starredPlanIdsStorageKey).updatedAt"
|
||||
let updatedAt: Int64
|
||||
if let n = UserDefaults.standard.object(forKey: updatedKey) as? Int64 {
|
||||
updatedAt = n
|
||||
} else if let num = UserDefaults.standard.object(forKey: updatedKey) as? NSNumber {
|
||||
updatedAt = num.int64Value
|
||||
} else {
|
||||
updatedAt = 0
|
||||
}
|
||||
call.resolve([
|
||||
"planIds": planIds,
|
||||
"count": planIds.count,
|
||||
"updatedAt": updatedAt
|
||||
])
|
||||
}
|
||||
|
||||
/// Persist dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
|
||||
private func saveDualScheduleConfig(_ config: [String: Any]) {
|
||||
guard config["userNotification"] != nil,
|
||||
@@ -488,8 +544,8 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
UserDefaults.standard.set(jsonString, forKey: dualScheduleConfigKey)
|
||||
}
|
||||
|
||||
/// Replace the pending dual user notification with resolved title/body (from fetched content if within contentTimeout, else default). Call after saving content in handleBackgroundFetch.
|
||||
private func updateDualNotificationWithResolvedContent(fetchedContent: NotificationContent) {
|
||||
/// Chained dual: arm a **one-shot** user notification at `max(T, prefetchCompletedAt)` (capped by slip), never before prefetch completes.
|
||||
private func armChainedDualNotificationAfterPrefetch(fetchedContent: NotificationContent, prefetchCompletedAt: Date) {
|
||||
guard let configJson = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
@@ -502,9 +558,23 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let useFetched = (nowMs - fetchedContent.fetchedAt) <= contentTimeoutMs
|
||||
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
|
||||
guard let nominalT = dualNextWallClockDate(scheduleStr: scheduleStr, after: prefetchCompletedAt.addingTimeInterval(-1)) else {
|
||||
print("DNP-FETCH: Dual chained: invalid notify cron")
|
||||
return
|
||||
}
|
||||
let slippedLate = prefetchCompletedAt.timeIntervalSince(nominalT) > dualChainedMaxSlipSeconds
|
||||
var fireDate = max(nominalT, prefetchCompletedAt)
|
||||
if slippedLate {
|
||||
fireDate = Date().addingTimeInterval(2)
|
||||
}
|
||||
|
||||
let title: String
|
||||
let body: String
|
||||
if useFetched {
|
||||
if slippedLate && fallbackBehavior == "show_default" {
|
||||
title = userNotification["title"] as? String ?? "Daily Notification"
|
||||
body = userNotification["body"] as? String ?? "Your daily update is ready"
|
||||
} else if useFetched {
|
||||
title = fetchedContent.title ?? "Daily Notification"
|
||||
body = fetchedContent.body ?? "Your daily update is ready"
|
||||
} else if fallbackBehavior == "show_default" {
|
||||
@@ -514,39 +584,43 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
|
||||
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
|
||||
var hour = 9, minute = 0
|
||||
if parts.count >= 2, let m = Int(parts[0]), let h = Int(parts[1]), m >= 0, m <= 59, h >= 0, h <= 23 {
|
||||
minute = m
|
||||
hour = h
|
||||
}
|
||||
var dateComp = DateComponents()
|
||||
dateComp.hour = hour
|
||||
dateComp.minute = minute
|
||||
dateComp.second = 0
|
||||
let cal = Calendar.current
|
||||
guard let nextDate = cal.nextDate(after: Date(), matching: dateComp, matchingPolicy: .nextTime), nextDate.timeIntervalSinceNow > 0 else {
|
||||
print("DNP-FETCH: Dual notify time already passed, skipping notification update")
|
||||
return
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = (userNotification["sound"] as? Bool ?? true) ? .default : nil
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
|
||||
let scheduledMs = Int64(fireDate.timeIntervalSince1970 * 1000)
|
||||
content.userInfo = [
|
||||
"notification_id": dualNotificationRequestIdentifier,
|
||||
"scheduled_time": NSNumber(value: scheduledMs)
|
||||
]
|
||||
let cal = Calendar.current
|
||||
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: fireDate)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
|
||||
let request = UNNotificationRequest(identifier: dualNotificationRequestIdentifier, content: content, trigger: trigger)
|
||||
notificationCenter.add(request) { [weak self] err in
|
||||
notificationCenter.add(request) { err in
|
||||
if let e = err {
|
||||
print("DNP-FETCH: Failed to update dual notification: \(e.localizedDescription)")
|
||||
print("DNP-FETCH: Failed to arm chained dual notification: \(e.localizedDescription)")
|
||||
} else {
|
||||
print("DNP-FETCH: Updated dual notification with \(useFetched ? "fetched" : "default") content")
|
||||
print("DNP-FETCH: Armed chained dual notification at \(fireDate) (useFetched=\(useFetched))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dualNextWallClockDate(scheduleStr: String, after date: Date) -> Date? {
|
||||
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
|
||||
guard parts.count >= 2,
|
||||
let minute = Int(parts[0]), minute >= 0, minute <= 59,
|
||||
let hour = Int(parts[1]), hour >= 0, hour <= 23 else {
|
||||
return nil
|
||||
}
|
||||
var comp = DateComponents()
|
||||
comp.hour = hour
|
||||
comp.minute = minute
|
||||
comp.second = 0
|
||||
return Calendar.current.nextDate(after: date, matching: comp, matchingPolicy: .nextTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status for dual scheduling system
|
||||
*
|
||||
@@ -611,133 +685,158 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
taskCompleted = true
|
||||
}
|
||||
|
||||
// Phase 3: Check for JWT-signed fetcher configuration
|
||||
// If native fetcher is configured, use it; otherwise fall back to dummy content
|
||||
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
|
||||
|
||||
// Save content to storage via state actor (thread-safe)
|
||||
Task {
|
||||
let prefetchCompletedAt = Date()
|
||||
let fetchTimeMs = Int64(prefetchCompletedAt.timeIntervalSince1970 * 1000)
|
||||
let content: NotificationContent
|
||||
|
||||
if let configJson = nativeFetcherConfig,
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let apiBaseUrl = config["apiBaseUrl"] as? String,
|
||||
let activeDid = config["activeDid"] as? String {
|
||||
if let reg = NativeNotificationFetcherRegistry.shared.fetcher {
|
||||
let ctx = FetchContext(
|
||||
trigger: "prefetch",
|
||||
scheduledTimeMillis: nil,
|
||||
fetchTimeMillis: fetchTimeMs,
|
||||
metadata: [:]
|
||||
)
|
||||
do {
|
||||
let list = try await withTimeout(milliseconds: 30_000) {
|
||||
try await reg.fetchContent(context: ctx)
|
||||
}
|
||||
if let first = list.first {
|
||||
content = first
|
||||
print("DNP-FETCH: Native fetcher returned content id=\(first.id)")
|
||||
} else {
|
||||
print("DNP-FETCH: Native fetcher returned empty list; using placeholder")
|
||||
content = NotificationContent(
|
||||
id: "empty_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
|
||||
fetchedAt: fetchTimeMs,
|
||||
url: nil,
|
||||
payload: ["empty": true],
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
print("DNP-FETCH: Native fetcher failed (\(error.localizedDescription)), using fallback content")
|
||||
content = NotificationContent(
|
||||
id: "fallback_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
|
||||
fetchedAt: fetchTimeMs,
|
||||
url: nil,
|
||||
payload: ["fetchError": error.localizedDescription],
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} else if let configJson = nativeFetcherConfig,
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let apiBaseUrl = config["apiBaseUrl"] as? String,
|
||||
let activeDid = config["activeDid"] as? String {
|
||||
let jwtFromPrimary = (config["jwtToken"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let jwtFromPool = (config["jwtTokenPool"] as? [String])?.first { !$0.isEmpty }
|
||||
let bearerToken = jwtFromPrimary ?? jwtFromPool
|
||||
if let jwtToken = bearerToken {
|
||||
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
|
||||
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
|
||||
|
||||
// Attempt to fetch content from TimeSafari API
|
||||
// Note: This is a minimal implementation - can be extended with full API client
|
||||
print("DNP-FETCH: Legacy in-plugin HTTP (no registered native fetcher)")
|
||||
do {
|
||||
let fetchedContent = try await fetchContentFromAPI(
|
||||
content = try await fetchContentFromAPI(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
activeDid: activeDid,
|
||||
jwtToken: jwtToken
|
||||
)
|
||||
content = fetchedContent
|
||||
print("DNP-FETCH: Successfully fetched content from API")
|
||||
} catch {
|
||||
// Fallback to dummy content on fetch failure
|
||||
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
|
||||
content = NotificationContent(
|
||||
id: "fallback_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
|
||||
fetchedAt: fetchTimeMs,
|
||||
url: nil,
|
||||
payload: ["fetchError": error.localizedDescription],
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Config present but no bearer (empty jwtToken and pool)
|
||||
print("DNP-FETCH: Using dummy content (no bearer token)")
|
||||
content = NotificationContent(
|
||||
id: "dummy_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
|
||||
fetchedAt: fetchTimeMs,
|
||||
url: nil,
|
||||
payload: nil,
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Fallback: Dummy content fetch (no network)
|
||||
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
|
||||
print("DNP-FETCH: Using dummy content (no native fetcher, no config)")
|
||||
content = NotificationContent(
|
||||
id: "dummy_\(Date().timeIntervalSince1970)",
|
||||
title: "Daily Update",
|
||||
body: "Your daily notification is ready",
|
||||
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
|
||||
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
|
||||
fetchedAt: fetchTimeMs,
|
||||
url: nil,
|
||||
payload: nil,
|
||||
etag: nil
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
// Use the content (either from JWT fetcher or dummy)
|
||||
if #available(iOS 13.0, *) {
|
||||
if let stateActor = await self.stateActor {
|
||||
await stateActor.saveNotificationContent(content)
|
||||
|
||||
// Mark successful run
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
|
||||
} else {
|
||||
// Fallback to direct storage access
|
||||
self.storage?.saveNotificationContent(content)
|
||||
} else {
|
||||
self.storage?.saveNotificationContent(content)
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
|
||||
}
|
||||
} else {
|
||||
// Fallback for iOS < 13
|
||||
self.storage?.saveNotificationContent(content)
|
||||
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
|
||||
}
|
||||
|
||||
// Relationship: update pending dual notification with resolved content (fetched if within contentTimeout, else default)
|
||||
self.updateDualNotificationWithResolvedContent(fetchedContent: content)
|
||||
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) != nil {
|
||||
self.armChainedDualNotificationAfterPrefetch(fetchedContent: content, prefetchCompletedAt: prefetchCompletedAt)
|
||||
do {
|
||||
try self.scheduleNextDualPrefetchFromPersistedConfig()
|
||||
} catch {
|
||||
print("DNP-FETCH: Next dual prefetch schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3.3: Recovery logic - verify scheduled notifications
|
||||
// Check if notifications are still scheduled after fetch
|
||||
if let reactivationManager = self.reactivationManager {
|
||||
// Perform lightweight verification (non-blocking)
|
||||
Task {
|
||||
do {
|
||||
let verificationResult = try await reactivationManager.verifyFutureNotifications()
|
||||
if verificationResult.notificationsMissing > 0 {
|
||||
print("DNP-FETCH: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
|
||||
// Note: Full recovery happens on app launch, not in background task
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: Log but don't fail task
|
||||
print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3.3: Schedule next background task
|
||||
// Calculate next fetch time based on notification schedule
|
||||
if let scheduler = self.scheduler {
|
||||
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
||||
if let nextTime = nextScheduledTime {
|
||||
self.scheduleBackgroundFetch(scheduledTime: nextTime)
|
||||
print("DNP-FETCH: Next background fetch scheduled")
|
||||
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) == nil {
|
||||
if let scheduler = self.scheduler {
|
||||
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
||||
if let nextTime = nextScheduledTime {
|
||||
self.scheduleBackgroundFetch(scheduledTime: nextTime)
|
||||
print("DNP-FETCH: Next background fetch scheduled")
|
||||
} else {
|
||||
print("DNP-FETCH: No future notifications found, skipping next task schedule")
|
||||
}
|
||||
} else {
|
||||
print("DNP-FETCH: No future notifications found, skipping next task schedule")
|
||||
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
|
||||
}
|
||||
} else {
|
||||
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
|
||||
}
|
||||
|
||||
guard !taskCompleted else { return }
|
||||
@@ -754,6 +853,16 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextDualPrefetchFromPersistedConfig() throws {
|
||||
guard let json = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
|
||||
let data = json.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let cf = root["contentFetch"] as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
try scheduleBackgroundFetch(config: cf)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle background notification task
|
||||
*
|
||||
@@ -2403,7 +2512,16 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "updateDualScheduleConfig", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "updateStarredPlans", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "getStarredPlans", returnType: CAPPluginReturnPromise))
|
||||
|
||||
return methods
|
||||
}
|
||||
}
|
||||
|
||||
extension DailyNotificationPlugin {
|
||||
/// Register the host app’s `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
|
||||
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
|
||||
NativeNotificationFetcherRegistry.shared.set(fetcher)
|
||||
}
|
||||
}
|
||||
83
ios/Plugin/NativeNotificationContentFetcher.swift
Normal file
83
ios/Plugin/NativeNotificationContentFetcher.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// NativeNotificationContentFetcher.swift
|
||||
// DailyNotificationPlugin
|
||||
//
|
||||
// Swift SPI mirroring org.timesafari.dailynotification.NativeNotificationContentFetcher (Android).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Context for a native fetch (trigger, scheduled user notification time, metadata).
|
||||
public struct FetchContext {
|
||||
public let trigger: String
|
||||
public let scheduledTimeMillis: Int64?
|
||||
public let fetchTimeMillis: Int64
|
||||
public let metadata: [String: Any]
|
||||
|
||||
public init(
|
||||
trigger: String,
|
||||
scheduledTimeMillis: Int64?,
|
||||
fetchTimeMillis: Int64,
|
||||
metadata: [String: Any] = [:]
|
||||
) {
|
||||
self.trigger = trigger
|
||||
self.scheduledTimeMillis = scheduledTimeMillis
|
||||
self.fetchTimeMillis = fetchTimeMillis
|
||||
self.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
/// Host app implements this and registers via `DailyNotificationPlugin.registerNativeFetcher(_:)`.
|
||||
public protocol NativeNotificationContentFetcher: AnyObject {
|
||||
/// Called when TypeScript invokes `configureNativeFetcher` (mirrors Android `NativeNotificationContentFetcher.configure`).
|
||||
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?)
|
||||
|
||||
/// Background-safe fetch; return empty array when there is nothing to show (not an error).
|
||||
func fetchContent(context: FetchContext) async throws -> [NotificationContent]
|
||||
}
|
||||
|
||||
public extension NativeNotificationContentFetcher {
|
||||
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
|
||||
// Default: no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the registered host fetcher (thread-safe).
|
||||
public final class NativeNotificationFetcherRegistry {
|
||||
public static let shared = NativeNotificationFetcherRegistry()
|
||||
private let lock = NSLock()
|
||||
private weak var weakFetcher: NativeNotificationContentFetcher?
|
||||
|
||||
private init() {}
|
||||
|
||||
public var fetcher: NativeNotificationContentFetcher? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return weakFetcher
|
||||
}
|
||||
|
||||
public func set(_ fetcher: NativeNotificationContentFetcher?) {
|
||||
lock.lock()
|
||||
weakFetcher = fetcher
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeFetcherTimeoutError: Error {
|
||||
case timedOut(ms: Int)
|
||||
}
|
||||
|
||||
func withTimeout<T>(milliseconds: Int, operation: @escaping () async throws -> T) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await operation()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000)
|
||||
throw NativeFetcherTimeoutError.timedOut(ms: milliseconds)
|
||||
}
|
||||
let first = try await group.next()!
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,18 @@ import Foundation
|
||||
* This class encapsulates all the information needed for a notification
|
||||
* including scheduling, content, and metadata.
|
||||
*/
|
||||
class NotificationContent: Codable {
|
||||
public class NotificationContent: Codable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let id: String
|
||||
let title: String?
|
||||
let body: String?
|
||||
let scheduledTime: Int64 // milliseconds since epoch (matches Android long)
|
||||
let fetchedAt: Int64 // milliseconds since epoch (matches Android long)
|
||||
let url: String?
|
||||
let payload: [String: Any]?
|
||||
let etag: String?
|
||||
public let id: String
|
||||
public let title: String?
|
||||
public let body: String?
|
||||
public let scheduledTime: Int64 // milliseconds since epoch (matches Android long)
|
||||
public let fetchedAt: Int64 // milliseconds since epoch (matches Android long)
|
||||
public let url: String?
|
||||
public let payload: [String: Any]?
|
||||
public let etag: String?
|
||||
/** When > 0, next occurrence is this many minutes after trigger (dev/testing). Nil/0 = 24h. Persisted for rollover and recovery. */
|
||||
var rolloverIntervalMinutes: Int?
|
||||
|
||||
@@ -50,7 +50,7 @@ class NotificationContent: Codable {
|
||||
case lastDeliveryAttempt
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
@@ -72,7 +72,7 @@ class NotificationContent: Codable {
|
||||
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(title, forKey: .title)
|
||||
@@ -109,7 +109,7 @@ class NotificationContent: Codable {
|
||||
* @param deliveryStatus Delivery status (optional, Phase 2)
|
||||
* @param lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
|
||||
*/
|
||||
init(id: String,
|
||||
public init(id: String,
|
||||
title: String?,
|
||||
body: String?,
|
||||
scheduledTime: Int64,
|
||||
|
||||
Reference in New Issue
Block a user