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:
Jose Olarte III
2026-04-02 16:48:06 +08:00
parent 9121b1e0f7
commit fbb5a94071
12 changed files with 544 additions and 146 deletions

View File

@@ -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 apps `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
NativeNotificationFetcherRegistry.shared.set(fetcher)
}
}

View 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
}
}

View File

@@ -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,