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 {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
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
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await NotificationManager.shared.applySettings()
|
await NotificationManager.shared.applySettings()
|
||||||
@@ -16,6 +16,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
task.setTaskCompleted(success: true)
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +38,13 @@ struct JarvisApp: App {
|
|||||||
@StateObject private var connManager = ConnectivityManager.shared
|
@StateObject private var connManager = ConnectivityManager.shared
|
||||||
@StateObject private var notifications = NotificationManager.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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
@@ -48,12 +62,15 @@ struct JarvisApp: App {
|
|||||||
.environmentObject(connManager)
|
.environmentObject(connManager)
|
||||||
.environmentObject(notifications)
|
.environmentObject(notifications)
|
||||||
.task {
|
.task {
|
||||||
|
// Wire the shared container before scheduling BG work.
|
||||||
|
BackgroundRefreshManager.container = container
|
||||||
|
BackgroundRefreshManager.scheduleFeedRefresh()
|
||||||
|
|
||||||
connManager.start()
|
connManager.start()
|
||||||
await connManager.resolveAndActivate()
|
await connManager.resolveAndActivate()
|
||||||
await notifications.bootstrap()
|
await notifications.bootstrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self,
|
.modelContainer(container)
|
||||||
SavedStory.self, SeenStory.self])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
- processing
|
||||||
BGTaskSchedulerPermittedIdentifiers:
|
BGTaskSchedulerPermittedIdentifiers:
|
||||||
- com.kisani.jarvis.briefing.refresh
|
- com.kisani.jarvis.briefing.refresh
|
||||||
|
- com.kisani.jarvis.feed.refresh
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
|
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
|
||||||
|
|||||||
Reference in New Issue
Block a user