feat(bg): add background feed refresh every ~30 min
Registers com.kisani.jarvis.feed.refresh as a BGAppRefreshTask. On each fire it fetches the latest 40 stories and upserts CachedStory rows so the SwiftData cache is warm before the user opens the app. Already-cached rows have signalScore / sourceCount refreshed in-place. Single ModelContainer owned by JarvisApp and shared via BackgroundRefreshManager.container to avoid multi-container conflicts. BG task ID registered in AppDelegate and declared in project.yml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
77
Jarvis/Store/BackgroundRefreshManager.swift
Normal file
77
Jarvis/Store/BackgroundRefreshManager.swift
Normal file
@@ -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<CachedStory>(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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user