Files
daily-notification-plugin/ios/Plugin/NativeNotificationContentFetcher.swift
Jose Olarte III fbb5a94071 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
2026-04-02 16:48:06 +08:00

84 lines
2.7 KiB
Swift

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