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
84 lines
2.7 KiB
Swift
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
|
|
}
|
|
}
|