diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index c1cc1d4..6c0192f 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -8,7 +8,7 @@ import BackgroundTasks final class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - // Register the briefing background-refresh handler before launch completes. + // Briefing notifications refresh BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in Task { @MainActor in await NotificationManager.shared.applySettings() @@ -16,6 +16,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate { task.setTaskCompleted(success: true) } } + // Story feed cache refresh + BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisFeedRefreshID, using: nil) { task in + guard let refresh = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false); return + } + BackgroundRefreshManager.handleFeedRefresh(task: refresh) + } return true } } @@ -31,6 +38,13 @@ struct JarvisApp: App { @StateObject private var connManager = ConnectivityManager.shared @StateObject private var notifications = NotificationManager.shared + // Single ModelContainer instance shared with BackgroundRefreshManager. + private let container: ModelContainer = { + let schema = Schema([CachedStory.self, CachedArticle.self, + ReadStory.self, SavedStory.self, SeenStory.self]) + return try! ModelContainer(for: schema) + }() + var body: some Scene { WindowGroup { Group { @@ -48,12 +62,15 @@ struct JarvisApp: App { .environmentObject(connManager) .environmentObject(notifications) .task { + // Wire the shared container before scheduling BG work. + BackgroundRefreshManager.container = container + BackgroundRefreshManager.scheduleFeedRefresh() + connManager.start() await connManager.resolveAndActivate() await notifications.bootstrap() } } - .modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, - SavedStory.self, SeenStory.self]) + .modelContainer(container) } } diff --git a/Jarvis/Store/BackgroundRefreshManager.swift b/Jarvis/Store/BackgroundRefreshManager.swift new file mode 100644 index 0000000..0f59276 --- /dev/null +++ b/Jarvis/Store/BackgroundRefreshManager.swift @@ -0,0 +1,77 @@ +// BackgroundRefreshManager.swift +// Jarvis — background story cache refresh. Fires via BGAppRefreshTask every ~30 min, +// fetches the latest feed, and upserts CachedStory rows so the SwiftData cache is +// warm before the user opens the app. + +import Foundation +import BackgroundTasks +import SwiftData + +let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh" + +enum BackgroundRefreshManager { + + // Injected once from JarvisApp so the BG task can open a context on the same store. + static var container: ModelContainer? + + // MARK: - Task handler + + static func handleFeedRefresh(task: BGAppRefreshTask) { + // Reschedule first — if the task is killed we still get another run later. + scheduleFeedRefresh() + + guard let container else { + task.setTaskCompleted(success: false) + return + } + + let work = Task { + await fetchAndCache(container: container) + } + + task.expirationHandler = { + work.cancel() + task.setTaskCompleted(success: false) + } + + Task { + _ = await work.result + task.setTaskCompleted(success: !work.isCancelled) + } + } + + // MARK: - Core fetch + upsert + + private static func fetchAndCache(container: ModelContainer) async { + do { + let page = try await APIClient.shared.fetchStories(limit: 40) + let context = ModelContext(container) + + for story in page.data { + let id = story.id + if let existing = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { + // Refresh signal score and freshness on already-cached rows. + existing.signalScore = story.signalScore + existing.sourceCount = story.sourceCount + existing.updatedAt = story.updatedAt + } else { + context.insert(CachedStory(from: story)) + } + } + + try? context.save() + } catch { + // Network unavailable in background — fail silently, next run will catch up. + } + } + + // MARK: - Schedule + + static func scheduleFeedRefresh() { + let req = BGAppRefreshTaskRequest(identifier: jarvisFeedRefreshID) + // iOS enforces a minimum interval (~15 min); 30 min is a reasonable target. + req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + try? BGTaskScheduler.shared.submit(req) + } +} diff --git a/project.yml b/project.yml index b25b2f4..e6d162f 100644 --- a/project.yml +++ b/project.yml @@ -34,6 +34,7 @@ targets: - processing BGTaskSchedulerPermittedIdentifiers: - com.kisani.jarvis.briefing.refresh + - com.kisani.jarvis.feed.refresh settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis